Compare commits
25 Commits
de6d8fda3e
...
fusion_acc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
43e1f3d6f5 | ||
|
|
69453bd8ae | ||
|
|
7e2c31e371 | ||
|
|
6344a75150 | ||
|
|
59ecc9fc5b | ||
|
|
2ee341316c | ||
|
|
02885108f2 | ||
|
|
af8c72a3b1 | ||
|
|
1491f455fe | ||
|
|
3efef7efc7 | ||
|
|
92f445eb8f | ||
|
|
892c37e2b0 | ||
|
|
a6ef7e0c2a | ||
|
|
9794970429 | ||
|
|
c0b8cc4159 | ||
|
|
51bff01f13 | ||
|
|
7ba15c65aa | ||
|
|
bf8689716c | ||
|
|
bddd22cabd | ||
|
|
6051ef22a0 | ||
|
|
24f8a5857e | ||
|
|
475d17c1aa | ||
|
|
fec1c12246 | ||
|
|
c939b83812 | ||
|
|
1e70b8d5c0 |
@@ -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.
|
||||
@@ -1,3 +1,5 @@
|
||||
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.17',
|
||||
'version': '19.0.1.0.36',
|
||||
'category': 'Accounting/Accounting',
|
||||
'summary': 'AI-augmented asset management with depreciation schedules.',
|
||||
'description': """
|
||||
@@ -28,15 +28,45 @@ menu hides; the engine + AI tools remain available for the chat.
|
||||
'depends': [
|
||||
'fusion_accounting_core',
|
||||
'fusion_accounting_ai',
|
||||
'fusion_accounting_migration',
|
||||
'account',
|
||||
'mail',
|
||||
],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'data/cron.xml',
|
||||
'wizards/create_asset_wizard_views.xml',
|
||||
'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': [
|
||||
'fusion_accounting_assets/static/src/scss/_variables.scss',
|
||||
'fusion_accounting_assets/static/src/scss/assets.scss',
|
||||
'fusion_accounting_assets/static/src/scss/dark_mode.scss',
|
||||
'fusion_accounting_assets/static/src/services/assets_service.js',
|
||||
'fusion_accounting_assets/static/src/views/asset_dashboard/asset_dashboard.js',
|
||||
'fusion_accounting_assets/static/src/views/asset_dashboard/asset_dashboard.xml',
|
||||
'fusion_accounting_assets/static/src/views/asset_dashboard/asset_dashboard_view.js',
|
||||
'fusion_accounting_assets/static/src/components/asset_card/asset_card.js',
|
||||
'fusion_accounting_assets/static/src/components/asset_card/asset_card.xml',
|
||||
'fusion_accounting_assets/static/src/components/asset_detail_panel/asset_detail_panel.js',
|
||||
'fusion_accounting_assets/static/src/components/asset_detail_panel/asset_detail_panel.xml',
|
||||
'fusion_accounting_assets/static/src/components/depreciation_board/depreciation_board.js',
|
||||
'fusion_accounting_assets/static/src/components/depreciation_board/depreciation_board.xml',
|
||||
'fusion_accounting_assets/static/src/components/disposal_dialog/disposal_dialog.js',
|
||||
'fusion_accounting_assets/static/src/components/disposal_dialog/disposal_dialog.xml',
|
||||
'fusion_accounting_assets/static/src/components/ai_useful_life_panel/ai_useful_life_panel.js',
|
||||
'fusion_accounting_assets/static/src/components/ai_useful_life_panel/ai_useful_life_panel.xml',
|
||||
'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,
|
||||
|
||||
@@ -11,6 +11,16 @@
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="cron_fusion_assets_refresh_book_values_mv" model="ir.cron">
|
||||
<field name="name">Fusion Assets — Refresh Book Values MV</field>
|
||||
<field name="model_id" ref="model_fusion_assets_cron"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_refresh_book_values_mv()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">hours</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"/>
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
-- Materialized view: per-asset book value snapshot.
|
||||
-- Refreshed via cron. Used by the OWL dashboard for portfolio summaries.
|
||||
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS fusion_asset_book_values_mv AS
|
||||
SELECT
|
||||
a.id AS id,
|
||||
a.id AS asset_id,
|
||||
a.company_id,
|
||||
a.category_id,
|
||||
a.state,
|
||||
a.cost,
|
||||
a.salvage_value,
|
||||
COALESCE(SUM(CASE WHEN l.is_posted THEN l.amount ELSE 0 END), 0) AS total_depreciated,
|
||||
a.cost - COALESCE(SUM(CASE WHEN l.is_posted THEN l.amount ELSE 0 END), 0) AS book_value,
|
||||
COUNT(l.id) FILTER (WHERE l.is_posted) AS posted_periods,
|
||||
COUNT(l.id) FILTER (WHERE NOT l.is_posted) AS pending_periods,
|
||||
a.acquisition_date,
|
||||
a.in_service_date
|
||||
FROM fusion_asset a
|
||||
LEFT JOIN fusion_asset_depreciation_line l ON l.asset_id = a.id
|
||||
GROUP BY a.id, a.company_id, a.category_id, a.state, a.cost, a.salvage_value,
|
||||
a.acquisition_date, a.in_service_date;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS fusion_asset_book_values_mv_pkey
|
||||
ON fusion_asset_book_values_mv (id);
|
||||
CREATE INDEX IF NOT EXISTS fusion_asset_book_values_mv_company_state
|
||||
ON fusion_asset_book_values_mv (company_id, state);
|
||||
CREATE INDEX IF NOT EXISTS fusion_asset_book_values_mv_category
|
||||
ON fusion_asset_book_values_mv (category_id) WHERE category_id IS NOT NULL;
|
||||
@@ -6,3 +6,5 @@ from . import fusion_asset_anomaly
|
||||
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
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"""MV of per-asset book value snapshot. Refresh via cron or model._refresh()."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionAssetBookValuesMV(models.Model):
|
||||
_name = "fusion.asset.book.values.mv"
|
||||
_description = "MV of asset book value snapshot"
|
||||
_auto = False
|
||||
_table = "fusion_asset_book_values_mv"
|
||||
_order = "book_value desc"
|
||||
|
||||
asset_id = fields.Many2one('fusion.asset', readonly=True)
|
||||
company_id = fields.Many2one('res.company', readonly=True)
|
||||
category_id = fields.Many2one('fusion.asset.category', readonly=True)
|
||||
state = fields.Char(readonly=True)
|
||||
cost = fields.Float(readonly=True)
|
||||
salvage_value = fields.Float(readonly=True)
|
||||
total_depreciated = fields.Float(readonly=True)
|
||||
book_value = fields.Float(readonly=True)
|
||||
posted_periods = fields.Integer(readonly=True)
|
||||
pending_periods = fields.Integer(readonly=True)
|
||||
acquisition_date = fields.Date(readonly=True)
|
||||
in_service_date = fields.Date(readonly=True)
|
||||
|
||||
def init(self):
|
||||
sql_path = os.path.join(
|
||||
os.path.dirname(__file__), '..', 'data', 'sql',
|
||||
'create_mv_asset_book_values.sql',
|
||||
)
|
||||
with open(sql_path, 'r') as f:
|
||||
self.env.cr.execute(f.read())
|
||||
_logger.info("fusion_asset_book_values_mv: created/verified MV")
|
||||
|
||||
@api.model
|
||||
def _refresh(self, *, concurrently=True):
|
||||
# CONCURRENTLY requires a unique index (we have one) and that the MV
|
||||
# has been populated at least once. Wrap the concurrent attempt in a
|
||||
# savepoint so a failure (e.g. first-ever refresh before the MV is
|
||||
# populated) does NOT poison the surrounding transaction; we then
|
||||
# fall back to a plain REFRESH.
|
||||
if concurrently:
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
self.env.cr.execute(
|
||||
"REFRESH MATERIALIZED VIEW CONCURRENTLY "
|
||||
"fusion_asset_book_values_mv"
|
||||
)
|
||||
return
|
||||
except Exception as e: # noqa: BLE001
|
||||
_logger.warning("Concurrent MV refresh failed (%s); fallback", e)
|
||||
self.env.cr.execute(
|
||||
"REFRESH MATERIALIZED VIEW fusion_asset_book_values_mv"
|
||||
)
|
||||
@@ -36,6 +36,17 @@ class FusionAssetsCron(models.AbstractModel):
|
||||
"Cron: posted depreciation on %d lines across %d running assets",
|
||||
posted_total, len(running_assets),
|
||||
)
|
||||
# Keep the book-value MV in sync after posting so the dashboard
|
||||
# reflects today's numbers without waiting for the dedicated MV cron.
|
||||
try:
|
||||
self.env['fusion.asset.book.values.mv']._refresh()
|
||||
except Exception as e: # noqa: BLE001
|
||||
_logger.warning("Post-cron MV refresh failed: %s", e)
|
||||
|
||||
@api.model
|
||||
def _cron_refresh_book_values_mv(self):
|
||||
"""Refresh the per-asset book value MV (hourly)."""
|
||||
self.env['fusion.asset.book.values.mv']._refresh()
|
||||
|
||||
@api.model
|
||||
def _cron_anomaly_scan(self):
|
||||
|
||||
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>
|
||||
@@ -9,3 +9,7 @@ access_fusion_asset_disposal_user,fusion.asset.disposal.user,model_fusion_asset_
|
||||
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
|
||||
access_fusion_create_asset_wizard_user,fusion.create.asset.wizard.user,model_fusion_create_asset_wizard,base.group_user,1,1,1,0
|
||||
access_fusion_disposal_wizard_user,fusion.disposal.wizard.user,model_fusion_disposal_wizard,base.group_user,1,1,1,0
|
||||
access_fusion_partial_sale_wizard_user,fusion.partial.sale.wizard.user,model_fusion_partial_sale_wizard,base.group_user,1,1,1,0
|
||||
access_fusion_depreciation_run_wizard_user,fusion.depreciation.run.wizard.user,model_fusion_depreciation_run_wizard,base.group_user,1,1,1,0
|
||||
|
||||
|
@@ -0,0 +1,41 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component, useState } from "@odoo/owl";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
export class AiUsefulLifePanel extends Component {
|
||||
static template = "fusion_accounting_assets.AiUsefulLifePanel";
|
||||
static props = {
|
||||
description: { type: String, optional: true },
|
||||
amount: { type: Number, optional: true },
|
||||
onSelect: { type: Function, optional: true },
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.assets = useService("fusion_assets");
|
||||
this.state = useState({
|
||||
suggestion: null,
|
||||
isLoading: false,
|
||||
descInput: this.props.description || '',
|
||||
amountInput: this.props.amount || '',
|
||||
});
|
||||
}
|
||||
|
||||
async onSuggest() {
|
||||
this.state.isLoading = true;
|
||||
try {
|
||||
this.state.suggestion = await this.assets.suggestUsefulLife(
|
||||
this.state.descInput,
|
||||
parseFloat(this.state.amountInput) || null,
|
||||
);
|
||||
} finally {
|
||||
this.state.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
onUseSuggestion() {
|
||||
if (this.state.suggestion && this.props.onSelect) {
|
||||
this.props.onSelect(this.state.suggestion);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_accounting_assets.AiUsefulLifePanel">
|
||||
<div style="background: white; padding: 1rem; border: 1px solid #e5e7eb; border-radius: 0.5rem;">
|
||||
<h5>AI Suggest Useful Life</h5>
|
||||
<div class="mb-2">
|
||||
<label>Description</label>
|
||||
<input class="form-control" t-att-value="state.descInput"
|
||||
t-on-input="(ev) => state.descInput = ev.target.value"/>
|
||||
</div>
|
||||
<div class="mb-2">
|
||||
<label>Amount</label>
|
||||
<input type="number" class="form-control" t-att-value="state.amountInput"
|
||||
t-on-input="(ev) => state.amountInput = ev.target.value"/>
|
||||
</div>
|
||||
<button class="btn_asset primary" t-on-click="onSuggest"
|
||||
t-att-disabled="state.isLoading">
|
||||
<t t-if="state.isLoading">Asking AI...</t>
|
||||
<t t-else="">Suggest</t>
|
||||
</button>
|
||||
|
||||
<div t-if="state.suggestion" class="mt-3 p-2"
|
||||
style="background: #eff6ff; border-radius: 0.25rem;">
|
||||
<div><strong>Suggested life:</strong> <t t-esc="state.suggestion.useful_life_years"/> years</div>
|
||||
<div><strong>Method:</strong> <t t-esc="state.suggestion.depreciation_method"/></div>
|
||||
<div class="text-muted small">
|
||||
<em><t t-esc="state.suggestion.rationale"/></em>
|
||||
(confidence: <t t-esc="(state.suggestion.confidence * 100).toFixed(0)"/>%)
|
||||
</div>
|
||||
<button class="btn_asset mt-2" t-if="props.onSelect" t-on-click="onUseSuggestion">
|
||||
Use This
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,17 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
|
||||
export class AnomalyStrip extends Component {
|
||||
static template = "fusion_accounting_assets.AnomalyStrip";
|
||||
static props = {
|
||||
anomaly: { type: Object },
|
||||
};
|
||||
|
||||
formatNumber(n) {
|
||||
if (n === null || n === undefined) return "";
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
minimumFractionDigits: 0, maximumFractionDigits: 1,
|
||||
}).format(n);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_accounting_assets.AnomalyStrip">
|
||||
<div class="o_fusion_anomaly_strip" t-att-data-severity="props.anomaly.severity">
|
||||
<strong>
|
||||
<t t-esc="props.anomaly.asset_name || 'Asset'"/>
|
||||
</strong>
|
||||
<span class="ms-2">
|
||||
<t t-esc="props.anomaly.anomaly_type.replace('_', ' ')"/>:
|
||||
<t t-esc="formatNumber(props.anomaly.variance_pct)"/>%
|
||||
</span>
|
||||
<span class="ms-3 text-muted">
|
||||
<t t-esc="props.anomaly.detail"/>
|
||||
</span>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,13 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
|
||||
export class AssetCard extends Component {
|
||||
static template = "fusion_accounting_assets.AssetCard";
|
||||
static props = {
|
||||
asset: { type: Object },
|
||||
selected: { type: Boolean, optional: true },
|
||||
onSelect: { type: Function },
|
||||
formatCurrency: { type: Function },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_accounting_assets.AssetCard">
|
||||
<div class="o_fusion_assets_card"
|
||||
t-att-class="props.selected ? 'selected' : ''"
|
||||
t-on-click="props.onSelect">
|
||||
<div class="o_fusion_assets_card_header">
|
||||
<div class="asset-name">
|
||||
<t t-esc="props.asset.name"/>
|
||||
<span t-if="props.asset.code" class="text-muted ms-2">
|
||||
[<t t-esc="props.asset.code"/>]
|
||||
</span>
|
||||
</div>
|
||||
<div class="asset-state-badge" t-att-data-state="props.asset.state">
|
||||
<t t-esc="props.asset.state"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="asset-numbers">
|
||||
<div>
|
||||
<span class="label">Cost:</span>
|
||||
<span class="value">$<t t-esc="props.formatCurrency(props.asset.cost)"/></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Book Value:</span>
|
||||
<span class="value">$<t t-esc="props.formatCurrency(props.asset.book_value)"/></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Method:</span>
|
||||
<span class="value"><t t-esc="props.asset.method"/></span>
|
||||
</div>
|
||||
<div t-if="props.asset.category_name">
|
||||
<span class="label">Category:</span>
|
||||
<span class="value"><t t-esc="props.asset.category_name"/></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,36 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { DepreciationBoard } from "../depreciation_board/depreciation_board";
|
||||
|
||||
export class AssetDetailPanel extends Component {
|
||||
static template = "fusion_accounting_assets.AssetDetailPanel";
|
||||
static props = {
|
||||
detail: { type: Object },
|
||||
formatCurrency: { type: Function },
|
||||
};
|
||||
static components = { DepreciationBoard };
|
||||
|
||||
setup() {
|
||||
this.assets = useService("fusion_assets");
|
||||
}
|
||||
|
||||
async onComputeSchedule() {
|
||||
await this.assets.computeSchedule(this.props.detail.asset.id, false);
|
||||
}
|
||||
|
||||
async onRecomputeSchedule() {
|
||||
await this.assets.computeSchedule(this.props.detail.asset.id, true);
|
||||
}
|
||||
|
||||
async onPostDepreciation() {
|
||||
await this.assets.postDepreciation(this.props.detail.asset.id);
|
||||
}
|
||||
|
||||
async onDispose() {
|
||||
const saleAmount = parseFloat(prompt("Sale amount (0 for scrap)?", "0"));
|
||||
if (isNaN(saleAmount)) return;
|
||||
await this.assets.disposeAsset(this.props.detail.asset.id, { saleAmount });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_accounting_assets.AssetDetailPanel">
|
||||
<div style="background: white; padding: 1rem; border-radius: 0.5rem; border: 1px solid #e5e7eb;">
|
||||
<h3><t t-esc="props.detail.asset.name"/></h3>
|
||||
<div class="text-muted" t-if="props.detail.asset.code">
|
||||
[<t t-esc="props.detail.asset.code"/>]
|
||||
</div>
|
||||
<div class="mt-3">
|
||||
<div><strong>State:</strong> <t t-esc="props.detail.asset.state"/></div>
|
||||
<div><strong>Cost:</strong> $<t t-esc="props.formatCurrency(props.detail.asset.cost)"/></div>
|
||||
<div><strong>Salvage:</strong> $<t t-esc="props.formatCurrency(props.detail.asset.salvage_value)"/></div>
|
||||
<div><strong>Book Value:</strong> $<t t-esc="props.formatCurrency(props.detail.asset.book_value)"/></div>
|
||||
<div><strong>Total Depreciated:</strong> $<t t-esc="props.formatCurrency(props.detail.asset.total_depreciated)"/></div>
|
||||
<div><strong>Method:</strong> <t t-esc="props.detail.asset.method"/></div>
|
||||
<div><strong>Useful Life:</strong> <t t-esc="props.detail.asset.useful_life_years"/> years</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex mt-3" style="gap: 0.5rem; flex-wrap: wrap;">
|
||||
<button class="btn_asset" t-on-click="onComputeSchedule">Compute Schedule</button>
|
||||
<button class="btn_asset" t-on-click="onRecomputeSchedule">Recompute</button>
|
||||
<button class="btn_asset primary"
|
||||
t-if="props.detail.asset.state === 'running'"
|
||||
t-on-click="onPostDepreciation">Post Next</button>
|
||||
<button class="btn_asset danger"
|
||||
t-if="props.detail.asset.state !== 'disposed'"
|
||||
t-on-click="onDispose">Dispose</button>
|
||||
</div>
|
||||
|
||||
<h4 class="mt-4">Depreciation Schedule</h4>
|
||||
<DepreciationBoard t-if="props.detail.depreciation_lines"
|
||||
lines="props.detail.depreciation_lines"
|
||||
formatCurrency="props.formatCurrency"/>
|
||||
|
||||
<div t-if="props.detail.anomalies and props.detail.anomalies.length" class="mt-3">
|
||||
<h4>Active Anomalies</h4>
|
||||
<div t-foreach="props.detail.anomalies" t-as="a" t-key="a.id"
|
||||
class="o_fusion_anomaly_strip" t-att-data-severity="a.severity">
|
||||
<strong><t t-esc="a.anomaly_type"/></strong>: <t t-esc="a.detail"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,16 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
|
||||
export class DepreciationBoard extends Component {
|
||||
static template = "fusion_accounting_assets.DepreciationBoard";
|
||||
static props = {
|
||||
lines: { type: Array },
|
||||
formatCurrency: { type: Function },
|
||||
};
|
||||
|
||||
rowClass(line) {
|
||||
if (line.is_posted) return "posted";
|
||||
return "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_accounting_assets.DepreciationBoard">
|
||||
<div class="o_fusion_assets_table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>#</th>
|
||||
<th>Date</th>
|
||||
<th class="text-end">Amount</th>
|
||||
<th class="text-end">Accumulated</th>
|
||||
<th class="text-end">Book Value</th>
|
||||
<th>Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr t-foreach="props.lines" t-as="line" t-key="line.id"
|
||||
t-att-class="rowClass(line)">
|
||||
<td><t t-esc="line.period_index + 1"/></td>
|
||||
<td><t t-esc="line.scheduled_date"/></td>
|
||||
<td class="text-end">$<t t-esc="props.formatCurrency(line.amount)"/></td>
|
||||
<td class="text-end">$<t t-esc="props.formatCurrency(line.accumulated)"/></td>
|
||||
<td class="text-end">$<t t-esc="props.formatCurrency(line.book_value_at_end)"/></td>
|
||||
<td>
|
||||
<t t-if="line.is_posted">Posted</t>
|
||||
<t t-else="">Pending</t>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,34 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component, useState } from "@odoo/owl";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
export class DisposalDialog extends Component {
|
||||
static template = "fusion_accounting_assets.DisposalDialog";
|
||||
static props = {
|
||||
assetId: { type: Number },
|
||||
onClose: { type: Function },
|
||||
};
|
||||
|
||||
setup() {
|
||||
this.assets = useService("fusion_assets");
|
||||
this.state = useState({
|
||||
disposalType: 'sale',
|
||||
saleAmount: 0,
|
||||
saleDate: new Date().toISOString().slice(0, 10),
|
||||
});
|
||||
}
|
||||
|
||||
async onConfirm() {
|
||||
try {
|
||||
await this.assets.disposeAsset(this.props.assetId, {
|
||||
disposalType: this.state.disposalType,
|
||||
saleAmount: parseFloat(this.state.saleAmount) || 0,
|
||||
saleDate: this.state.saleDate,
|
||||
});
|
||||
this.props.onClose();
|
||||
} catch (e) {
|
||||
// Error already shown by service
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_accounting_assets.DisposalDialog">
|
||||
<div class="modal" style="display: block; background: rgba(0,0,0,0.5); position: fixed; top:0; left:0; right:0; bottom:0; z-index: 1050;">
|
||||
<div class="modal-dialog" style="margin: 5vh auto; max-width: 500px;">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5>Dispose Asset</h5>
|
||||
<button class="btn-close" t-on-click="props.onClose">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="mb-3">
|
||||
<label>Disposal Type</label>
|
||||
<select class="form-select"
|
||||
t-on-change="(ev) => state.disposalType = ev.target.value">
|
||||
<option value="sale" selected="state.disposalType === 'sale'">Sale</option>
|
||||
<option value="scrap" selected="state.disposalType === 'scrap'">Scrap</option>
|
||||
<option value="donation" selected="state.disposalType === 'donation'">Donation</option>
|
||||
<option value="lost" selected="state.disposalType === 'lost'">Lost</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-3" t-if="state.disposalType === 'sale'">
|
||||
<label>Sale Amount ($)</label>
|
||||
<input type="number" class="form-control"
|
||||
t-att-value="state.saleAmount"
|
||||
t-on-change="(ev) => state.saleAmount = ev.target.value"/>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label>Date</label>
|
||||
<input type="date" class="form-control"
|
||||
t-att-value="state.saleDate"
|
||||
t-on-change="(ev) => state.saleDate = ev.target.value"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button class="btn_asset" t-on-click="props.onClose">Cancel</button>
|
||||
<button class="btn_asset primary" t-on-click="onConfirm">Confirm Disposal</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
40
fusion_accounting_assets/static/src/scss/_variables.scss
Normal file
40
fusion_accounting_assets/static/src/scss/_variables.scss
Normal file
@@ -0,0 +1,40 @@
|
||||
// Fusion assets design tokens (extends Phase 1+2's tokens for consistency).
|
||||
|
||||
$asset-bg-primary: #ffffff;
|
||||
$asset-bg-secondary: #f9fafb;
|
||||
$asset-bg-tertiary: #f3f4f6;
|
||||
$asset-border: #e5e7eb;
|
||||
$asset-text-primary: #111827;
|
||||
$asset-text-secondary: #6b7280;
|
||||
$asset-text-muted: #9ca3af;
|
||||
$asset-accent: #3b82f6;
|
||||
$asset-accent-bg: #eff6ff;
|
||||
|
||||
// State colors
|
||||
$asset-state-draft: #6b7280;
|
||||
$asset-state-running: #10b981;
|
||||
$asset-state-paused: #f59e0b;
|
||||
$asset-state-disposed: #ef4444;
|
||||
|
||||
// Severity colors (mirrors phase 2)
|
||||
$asset-severity-high: #ef4444;
|
||||
$asset-severity-high-bg: #fef2f2;
|
||||
$asset-severity-medium: #f59e0b;
|
||||
$asset-severity-medium-bg: #fffbeb;
|
||||
$asset-severity-low: #10b981;
|
||||
$asset-severity-low-bg: #ecfdf5;
|
||||
|
||||
$asset-space-1: 0.25rem;
|
||||
$asset-space-2: 0.5rem;
|
||||
$asset-space-3: 0.75rem;
|
||||
$asset-space-4: 1rem;
|
||||
$asset-space-6: 1.5rem;
|
||||
|
||||
$asset-font-size-xs: 0.75rem;
|
||||
$asset-font-size-sm: 0.875rem;
|
||||
$asset-font-size-base: 1rem;
|
||||
$asset-font-size-lg: 1.125rem;
|
||||
$asset-font-size-xl: 1.25rem;
|
||||
|
||||
$asset-border-radius: 0.375rem;
|
||||
$asset-border-radius-md: 0.5rem;
|
||||
157
fusion_accounting_assets/static/src/scss/assets.scss
Normal file
157
fusion_accounting_assets/static/src/scss/assets.scss
Normal file
@@ -0,0 +1,157 @@
|
||||
@import "variables";
|
||||
|
||||
.o_fusion_assets {
|
||||
background: $asset-bg-secondary;
|
||||
min-height: 100vh;
|
||||
|
||||
&_header {
|
||||
background: $asset-bg-primary;
|
||||
border-bottom: 1px solid $asset-border;
|
||||
padding: $asset-space-4 $asset-space-6;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
h1 { font-size: $asset-font-size-xl; margin: 0; }
|
||||
|
||||
.o_fusion_assets_summary {
|
||||
display: flex;
|
||||
gap: $asset-space-6;
|
||||
font-size: $asset-font-size-sm;
|
||||
color: $asset-text-secondary;
|
||||
|
||||
.summary-value {
|
||||
font-weight: 600;
|
||||
color: $asset-text-primary;
|
||||
margin-left: $asset-space-1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&_card {
|
||||
background: $asset-bg-primary;
|
||||
border: 1px solid $asset-border;
|
||||
border-radius: $asset-border-radius-md;
|
||||
padding: $asset-space-4;
|
||||
margin-bottom: $asset-space-3;
|
||||
cursor: pointer;
|
||||
transition: all 200ms ease-in-out;
|
||||
|
||||
&:hover {
|
||||
border-color: $asset-accent;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
&.selected {
|
||||
border-color: $asset-accent;
|
||||
background: $asset-accent-bg;
|
||||
}
|
||||
|
||||
&_header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: $asset-space-2;
|
||||
}
|
||||
|
||||
.asset-name {
|
||||
font-weight: 600;
|
||||
font-size: $asset-font-size-base;
|
||||
}
|
||||
|
||||
.asset-state-badge {
|
||||
padding: $asset-space-1 $asset-space-2;
|
||||
border-radius: $asset-border-radius;
|
||||
font-size: $asset-font-size-xs;
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
|
||||
&[data-state="draft"] { background: lighten($asset-state-draft, 40%); color: $asset-state-draft; }
|
||||
&[data-state="running"] { background: lighten($asset-state-running, 45%); color: $asset-state-running; }
|
||||
&[data-state="paused"] { background: lighten($asset-state-paused, 35%); color: $asset-state-paused; }
|
||||
&[data-state="disposed"] { background: lighten($asset-state-disposed, 35%); color: $asset-state-disposed; }
|
||||
}
|
||||
|
||||
.asset-numbers {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: $asset-space-2;
|
||||
font-size: $asset-font-size-sm;
|
||||
color: $asset-text-secondary;
|
||||
|
||||
.label {
|
||||
font-weight: 500;
|
||||
margin-right: $asset-space-2;
|
||||
}
|
||||
.value {
|
||||
color: $asset-text-primary;
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
&_table {
|
||||
background: $asset-bg-primary;
|
||||
border-radius: $asset-border-radius-md;
|
||||
overflow: hidden;
|
||||
font-size: $asset-font-size-sm;
|
||||
|
||||
table { width: 100%; border-collapse: collapse; }
|
||||
|
||||
th {
|
||||
background: $asset-bg-tertiary;
|
||||
padding: $asset-space-3;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: $asset-text-secondary;
|
||||
border-bottom: 1px solid $asset-border;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: $asset-space-2 $asset-space-3;
|
||||
border-bottom: 1px solid lighten($asset-border, 5%);
|
||||
}
|
||||
|
||||
tr.posted { background: $asset-bg-secondary; }
|
||||
tr.due-now { background: $asset-severity-medium-bg; }
|
||||
.text-end { text-align: right; }
|
||||
}
|
||||
|
||||
.btn_asset {
|
||||
padding: $asset-space-2 $asset-space-4;
|
||||
border-radius: $asset-border-radius;
|
||||
background: $asset-bg-primary;
|
||||
border: 1px solid $asset-border;
|
||||
color: $asset-text-primary;
|
||||
font-size: $asset-font-size-sm;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover { background: $asset-bg-tertiary; }
|
||||
|
||||
&.primary {
|
||||
background: $asset-accent;
|
||||
border-color: $asset-accent;
|
||||
color: white;
|
||||
|
||||
&:hover { background: darken($asset-accent, 8%); }
|
||||
}
|
||||
|
||||
&.danger {
|
||||
background: $asset-severity-high;
|
||||
border-color: $asset-severity-high;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_fusion_anomaly_strip {
|
||||
margin: $asset-space-3 0;
|
||||
padding: $asset-space-3;
|
||||
border-radius: $asset-border-radius;
|
||||
border: 1px solid;
|
||||
font-size: $asset-font-size-sm;
|
||||
|
||||
&[data-severity="high"] { background: $asset-severity-high-bg; border-color: $asset-severity-high; }
|
||||
&[data-severity="medium"] { background: $asset-severity-medium-bg; border-color: $asset-severity-medium; }
|
||||
&[data-severity="low"] { background: $asset-severity-low-bg; border-color: $asset-severity-low; }
|
||||
}
|
||||
32
fusion_accounting_assets/static/src/scss/dark_mode.scss
Normal file
32
fusion_accounting_assets/static/src/scss/dark_mode.scss
Normal file
@@ -0,0 +1,32 @@
|
||||
@import "variables";
|
||||
|
||||
[data-color-scheme="dark"] .o_fusion_assets {
|
||||
background: #1f2937; color: #f9fafb;
|
||||
|
||||
&_header, &_card, &_table { background: #111827; border-color: #374151; }
|
||||
|
||||
&_card {
|
||||
&:hover { border-color: #60a5fa; }
|
||||
&.selected { background: #1e3a8a; border-color: #60a5fa; }
|
||||
.asset-numbers .label { color: #9ca3af; }
|
||||
.asset-numbers .value { color: #f9fafb; }
|
||||
}
|
||||
|
||||
&_table {
|
||||
th { background: #1f2937; color: #d1d5db; }
|
||||
td { border-color: #374151; }
|
||||
tr.posted { background: #1f2937; }
|
||||
}
|
||||
|
||||
.btn_asset {
|
||||
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); }
|
||||
}
|
||||
}
|
||||
149
fusion_accounting_assets/static/src/services/assets_service.js
Normal file
149
fusion_accounting_assets/static/src/services/assets_service.js
Normal file
@@ -0,0 +1,149 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { reactive } from "@odoo/owl";
|
||||
|
||||
const ENDPOINT_BASE = "/fusion/assets";
|
||||
|
||||
export class AssetsService {
|
||||
constructor(env, services) {
|
||||
this.env = env;
|
||||
this.rpc = services.rpc;
|
||||
this.notification = services.notification;
|
||||
|
||||
this.state = reactive({
|
||||
assets: [],
|
||||
count: 0,
|
||||
total: 0,
|
||||
stateFilter: null,
|
||||
categoryFilter: null,
|
||||
isLoading: false,
|
||||
isProcessing: false,
|
||||
selectedAssetId: null,
|
||||
selectedDetail: null,
|
||||
companyId: null,
|
||||
limit: 50,
|
||||
offset: 0,
|
||||
anomalies: [],
|
||||
});
|
||||
}
|
||||
|
||||
async loadAssets(companyId = null) {
|
||||
this.state.companyId = companyId;
|
||||
this.state.isLoading = true;
|
||||
try {
|
||||
const result = await this.rpc(`${ENDPOINT_BASE}/list`, {
|
||||
state: this.state.stateFilter,
|
||||
category_id: this.state.categoryFilter,
|
||||
limit: this.state.limit,
|
||||
offset: this.state.offset,
|
||||
company_id: companyId,
|
||||
});
|
||||
this.state.assets = result.assets;
|
||||
this.state.count = result.count;
|
||||
this.state.total = result.total;
|
||||
} finally {
|
||||
this.state.isLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async selectAsset(assetId) {
|
||||
this.state.selectedAssetId = assetId;
|
||||
this.state.selectedDetail = null;
|
||||
try {
|
||||
const result = await this.rpc(`${ENDPOINT_BASE}/get_detail`, {
|
||||
asset_id: assetId,
|
||||
});
|
||||
this.state.selectedDetail = result;
|
||||
} catch (err) {
|
||||
this.notification.add(`Failed to load asset detail: ${err.message || err}`, { type: "danger" });
|
||||
}
|
||||
}
|
||||
|
||||
async computeSchedule(assetId, recompute = false) {
|
||||
this.state.isProcessing = true;
|
||||
try {
|
||||
const result = await this.rpc(`${ENDPOINT_BASE}/compute_schedule`, {
|
||||
asset_id: assetId, recompute: recompute,
|
||||
});
|
||||
this.notification.add(`Schedule computed (${result.lines_created} lines)`, { type: "success" });
|
||||
if (this.state.selectedAssetId === assetId) {
|
||||
await this.selectAsset(assetId);
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
this.notification.add(`Compute failed: ${err.message || err}`, { type: "danger" });
|
||||
throw err;
|
||||
} finally {
|
||||
this.state.isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async postDepreciation(assetId) {
|
||||
this.state.isProcessing = true;
|
||||
try {
|
||||
const result = await this.rpc(`${ENDPOINT_BASE}/post_depreciation`, {
|
||||
asset_id: assetId,
|
||||
});
|
||||
this.notification.add(`Posted ${result.posted_count} period(s)`, { type: "success" });
|
||||
if (this.state.selectedAssetId === assetId) {
|
||||
await this.selectAsset(assetId);
|
||||
}
|
||||
return result;
|
||||
} catch (err) {
|
||||
this.notification.add(`Post failed: ${err.message || err}`, { type: "danger" });
|
||||
throw err;
|
||||
} finally {
|
||||
this.state.isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async disposeAsset(assetId, { saleAmount = 0, saleDate = null, salePartnerId = null, disposalType = "sale" } = {}) {
|
||||
this.state.isProcessing = true;
|
||||
try {
|
||||
const result = await this.rpc(`${ENDPOINT_BASE}/dispose`, {
|
||||
asset_id: assetId, sale_amount: saleAmount,
|
||||
sale_date: saleDate, sale_partner_id: salePartnerId,
|
||||
disposal_type: disposalType,
|
||||
});
|
||||
this.notification.add(`Asset disposed: gain/loss $${result.gain_loss_amount.toFixed(2)}`, { type: "success" });
|
||||
await this.loadAssets(this.state.companyId);
|
||||
return result;
|
||||
} catch (err) {
|
||||
this.notification.add(`Dispose failed: ${err.message || err}`, { type: "danger" });
|
||||
throw err;
|
||||
} finally {
|
||||
this.state.isProcessing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async fetchAnomalies(severity = null) {
|
||||
try {
|
||||
const result = await this.rpc(`${ENDPOINT_BASE}/get_anomalies`, {
|
||||
severity: severity, company_id: this.state.companyId,
|
||||
});
|
||||
this.state.anomalies = result.anomalies || [];
|
||||
} catch (err) {
|
||||
this.state.anomalies = [];
|
||||
}
|
||||
}
|
||||
|
||||
async suggestUsefulLife(description, amount = null, partnerName = null) {
|
||||
return await this.rpc(`${ENDPOINT_BASE}/suggest_useful_life`, {
|
||||
description: description, amount: amount, partner_name: partnerName,
|
||||
});
|
||||
}
|
||||
|
||||
setStateFilter(state) {
|
||||
this.state.stateFilter = state;
|
||||
this.state.offset = 0;
|
||||
this.loadAssets(this.state.companyId);
|
||||
}
|
||||
}
|
||||
|
||||
export const assetsService = {
|
||||
dependencies: ["rpc", "notification"],
|
||||
start(env, services) { return new AssetsService(env, services); },
|
||||
};
|
||||
|
||||
registry.category("services").add("fusion_assets", assetsService);
|
||||
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",
|
||||
},
|
||||
],
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component, useState, onWillStart } from "@odoo/owl";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { AssetCard } from "../../components/asset_card/asset_card";
|
||||
import { AssetDetailPanel } from "../../components/asset_detail_panel/asset_detail_panel";
|
||||
import { AnomalyStrip } from "../../components/anomaly_strip/anomaly_strip";
|
||||
|
||||
export class AssetDashboard extends Component {
|
||||
static template = "fusion_accounting_assets.AssetDashboard";
|
||||
static props = { "*": true };
|
||||
static components = { AssetCard, AssetDetailPanel, AnomalyStrip };
|
||||
|
||||
setup() {
|
||||
this.assets = useService("fusion_assets");
|
||||
this.state = useState(this.assets.state);
|
||||
|
||||
const companyId = this.env.services.user?.context?.allowed_company_ids?.[0];
|
||||
|
||||
onWillStart(async () => {
|
||||
await this.assets.loadAssets(companyId);
|
||||
await this.assets.fetchAnomalies();
|
||||
});
|
||||
}
|
||||
|
||||
onSelectAsset(id) {
|
||||
this.assets.selectAsset(id);
|
||||
}
|
||||
|
||||
onStateFilter(state) {
|
||||
this.assets.setStateFilter(state || null);
|
||||
}
|
||||
|
||||
formatCurrency(amount) {
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
minimumFractionDigits: 2, maximumFractionDigits: 2,
|
||||
}).format(amount || 0);
|
||||
}
|
||||
|
||||
get totalCost() {
|
||||
return this.state.assets.reduce((sum, a) => sum + a.cost, 0);
|
||||
}
|
||||
|
||||
get totalBookValue() {
|
||||
return this.state.assets.reduce((sum, a) => sum + a.book_value, 0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_accounting_assets.AssetDashboard">
|
||||
<div class="o_fusion_assets">
|
||||
<div class="o_fusion_assets_header">
|
||||
<div>
|
||||
<h1>Asset Management</h1>
|
||||
<div class="text-muted">
|
||||
<t t-esc="state.count"/> of <t t-esc="state.total"/> assets
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_fusion_assets_summary">
|
||||
<div>Cost: <span class="summary-value">$<t t-esc="formatCurrency(totalCost)"/></span></div>
|
||||
<div>Book Value: <span class="summary-value">$<t t-esc="formatCurrency(totalBookValue)"/></span></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex" style="gap: 0.5rem; padding: 0.75rem;">
|
||||
<button class="btn_asset" t-on-click="() => onStateFilter(null)"
|
||||
t-att-class="state.stateFilter === null ? 'primary' : ''">All</button>
|
||||
<button class="btn_asset" t-on-click="() => onStateFilter('draft')"
|
||||
t-att-class="state.stateFilter === 'draft' ? 'primary' : ''">Draft</button>
|
||||
<button class="btn_asset" t-on-click="() => onStateFilter('running')"
|
||||
t-att-class="state.stateFilter === 'running' ? 'primary' : ''">Running</button>
|
||||
<button class="btn_asset" t-on-click="() => onStateFilter('paused')"
|
||||
t-att-class="state.stateFilter === 'paused' ? 'primary' : ''">Paused</button>
|
||||
<button class="btn_asset" t-on-click="() => onStateFilter('disposed')"
|
||||
t-att-class="state.stateFilter === 'disposed' ? 'primary' : ''">Disposed</button>
|
||||
</div>
|
||||
|
||||
<AnomalyStrip t-foreach="state.anomalies" t-as="anomaly"
|
||||
t-key="anomaly.id" anomaly="anomaly"/>
|
||||
|
||||
<div class="d-flex" style="gap: 1rem; padding: 1rem;">
|
||||
<div style="flex: 1 1 60%;">
|
||||
<div t-if="state.isLoading" class="text-center p-4 text-muted">Loading...</div>
|
||||
<div t-elif="state.assets.length === 0" class="text-center p-4 text-muted">No assets found.</div>
|
||||
<div t-else="">
|
||||
<AssetCard t-foreach="state.assets" t-as="asset" t-key="asset.id"
|
||||
asset="asset" selected="state.selectedAssetId === asset.id"
|
||||
onSelect="() => onSelectAsset(asset.id)"
|
||||
formatCurrency="formatCurrency.bind(this)"/>
|
||||
</div>
|
||||
</div>
|
||||
<div style="flex: 1 1 40%;">
|
||||
<AssetDetailPanel t-if="state.selectedDetail"
|
||||
detail="state.selectedDetail"
|
||||
formatCurrency="formatCurrency.bind(this)"/>
|
||||
<div t-else="" class="p-4 text-muted">Select an asset to see details.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,14 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
import { AssetDashboard } from "./asset_dashboard";
|
||||
|
||||
export const fusionAssetDashboardView = {
|
||||
type: "fusion_assets",
|
||||
Controller: AssetDashboard,
|
||||
display_name: "Fusion Asset Management",
|
||||
icon: "fa-cubes",
|
||||
multiRecord: true,
|
||||
};
|
||||
|
||||
registry.category("views").add("fusion_assets", fusionAssetDashboardView);
|
||||
@@ -15,3 +15,17 @@ from . import test_assets_controller
|
||||
from . import test_assets_adapter
|
||||
from . import test_asset_tools
|
||||
from . import test_assets_cron
|
||||
from . import test_engine_property
|
||||
from . import test_method_integration
|
||||
from . import test_asset_book_values_mv
|
||||
from . import test_performance_benchmarks
|
||||
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
|
||||
|
||||
29
fusion_accounting_assets/tests/test_asset_book_values_mv.py
Normal file
29
fusion_accounting_assets/tests/test_asset_book_values_mv.py
Normal file
@@ -0,0 +1,29 @@
|
||||
"""Tests for the per-asset book value MV."""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.tests import tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestAssetBookValuesMV(TransactionCase):
|
||||
|
||||
def test_mv_exists_and_is_queryable(self):
|
||||
self.env['fusion.asset.book.values.mv']._refresh(concurrently=False)
|
||||
rows = self.env['fusion.asset.book.values.mv'].search([], limit=10)
|
||||
self.assertIsNotNone(rows)
|
||||
|
||||
def test_mv_includes_new_asset_after_refresh(self):
|
||||
asset = self.env['fusion.asset'].create({
|
||||
'name': 'MV Test', 'cost': 5000, 'salvage_value': 500,
|
||||
'acquisition_date': date(2026, 1, 1),
|
||||
'method': 'straight_line', 'useful_life_years': 5,
|
||||
})
|
||||
self.env.flush_all()
|
||||
self.env['fusion.asset.book.values.mv']._refresh(concurrently=False)
|
||||
mv_row = self.env['fusion.asset.book.values.mv'].search([
|
||||
('asset_id', '=', asset.id),
|
||||
], limit=1)
|
||||
self.assertTrue(mv_row)
|
||||
self.assertAlmostEqual(mv_row.book_value, 5000, places=2)
|
||||
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)
|
||||
62
fusion_accounting_assets/tests/test_create_asset_wizard.py
Normal file
62
fusion_accounting_assets/tests/test_create_asset_wizard.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from datetime import date
|
||||
|
||||
from odoo.tests import tagged
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestCreateAssetWizard(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.env['ir.config_parameter'].sudo().search([
|
||||
('key', 'in', ['fusion_accounting.provider.asset_useful_life',
|
||||
'fusion_accounting.provider.default'])
|
||||
]).unlink()
|
||||
|
||||
def test_create_minimal_asset(self):
|
||||
wizard = self.env['fusion.create.asset.wizard'].create({
|
||||
'name': 'Test Asset',
|
||||
'cost': 5000,
|
||||
'method': 'straight_line',
|
||||
'useful_life_years': 5,
|
||||
'acquisition_date': date(2026, 1, 1),
|
||||
'source_invoice_line_id': False,
|
||||
})
|
||||
action = wizard.action_create_asset()
|
||||
self.assertEqual(action['res_model'], 'fusion.asset')
|
||||
asset = self.env['fusion.asset'].browse(action['res_id'])
|
||||
self.assertEqual(asset.name, 'Test Asset')
|
||||
self.assertEqual(asset.cost, 5000)
|
||||
|
||||
def test_ai_suggest_fills_fields(self):
|
||||
wizard = self.env['fusion.create.asset.wizard'].create({
|
||||
'name': 'Dell laptop',
|
||||
'cost': 2000,
|
||||
'method': 'straight_line',
|
||||
'useful_life_years': 5,
|
||||
'acquisition_date': date(2026, 1, 1),
|
||||
})
|
||||
wizard.action_ai_suggest()
|
||||
self.assertEqual(wizard.ai_suggested_years, 4)
|
||||
self.assertEqual(wizard.useful_life_years, 4)
|
||||
|
||||
def test_category_onchange_pre_fills(self):
|
||||
category = self.env['fusion.asset.category'].create({
|
||||
'name': 'Test Category',
|
||||
'method': 'declining_balance',
|
||||
'useful_life_years': 7,
|
||||
'declining_rate_pct': 25.0,
|
||||
'salvage_value_pct': 10.0,
|
||||
})
|
||||
wizard = self.env['fusion.create.asset.wizard'].new({
|
||||
'name': 'Test', 'cost': 10000,
|
||||
'method': 'straight_line', 'useful_life_years': 5,
|
||||
'acquisition_date': date(2026, 1, 1),
|
||||
'category_id': category.id,
|
||||
})
|
||||
wizard._onchange_category_id()
|
||||
self.assertEqual(wizard.method, 'declining_balance')
|
||||
self.assertEqual(wizard.useful_life_years, 7)
|
||||
self.assertEqual(wizard.declining_rate_pct, 25.0)
|
||||
self.assertAlmostEqual(wizard.salvage_value, 1000, places=2)
|
||||
@@ -0,0 +1,43 @@
|
||||
from datetime import date
|
||||
|
||||
from odoo.tests import tagged
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestDepreciationRunWizard(TransactionCase):
|
||||
|
||||
def test_run_all_running_posts_due_periods(self):
|
||||
for amt in [3000, 5000]:
|
||||
asset = self.env['fusion.asset'].create({
|
||||
'name': f'Run Test {amt}', 'cost': amt,
|
||||
'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()
|
||||
wizard = self.env['fusion.depreciation.run.wizard'].create({
|
||||
'period_date': date(2030, 12, 31),
|
||||
'state_filter': 'all_running',
|
||||
})
|
||||
wizard.action_run()
|
||||
self.assertEqual(wizard.state, 'done')
|
||||
self.assertGreater(wizard.posted_count, 0)
|
||||
|
||||
def test_run_selected_posts_only_selected(self):
|
||||
asset = self.env['fusion.asset'].create({
|
||||
'name': 'Selected Test', 'cost': 1000,
|
||||
'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()
|
||||
wizard = self.env['fusion.depreciation.run.wizard'].create({
|
||||
'period_date': date(2030, 12, 31),
|
||||
'state_filter': 'selected',
|
||||
'asset_ids': [(6, 0, [asset.id])],
|
||||
})
|
||||
wizard.action_run()
|
||||
self.assertEqual(wizard.state, 'done')
|
||||
50
fusion_accounting_assets/tests/test_disposal_wizard.py
Normal file
50
fusion_accounting_assets/tests/test_disposal_wizard.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from datetime import date
|
||||
|
||||
from odoo.tests import tagged
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestDisposalWizard(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.asset = self.env['fusion.asset'].create({
|
||||
'name': 'Disposal Test Asset',
|
||||
'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(self.asset)
|
||||
self.asset.action_set_running()
|
||||
|
||||
def test_default_loads_active_asset(self):
|
||||
wizard = self.env['fusion.disposal.wizard'].with_context(
|
||||
active_model='fusion.asset', active_id=self.asset.id,
|
||||
).create({})
|
||||
self.assertEqual(wizard.asset_id, self.asset)
|
||||
|
||||
def test_action_dispose_marks_asset_disposed(self):
|
||||
wizard = self.env['fusion.disposal.wizard'].create({
|
||||
'asset_id': self.asset.id,
|
||||
'disposal_type': 'sale',
|
||||
'sale_amount': 4000,
|
||||
'disposal_date': date(2026, 6, 1),
|
||||
})
|
||||
wizard.action_dispose()
|
||||
self.asset.invalidate_recordset(['state'])
|
||||
self.assertEqual(self.asset.state, 'disposed')
|
||||
|
||||
def test_compute_gain_loss_sale(self):
|
||||
wizard = self.env['fusion.disposal.wizard'].create({
|
||||
'asset_id': self.asset.id,
|
||||
'disposal_type': 'sale',
|
||||
'sale_amount': 7000,
|
||||
})
|
||||
wizard._compute_gain_loss()
|
||||
self.assertAlmostEqual(
|
||||
wizard.estimated_gain_loss,
|
||||
7000 - self.asset.book_value,
|
||||
places=2,
|
||||
)
|
||||
101
fusion_accounting_assets/tests/test_engine_property.py
Normal file
101
fusion_accounting_assets/tests/test_engine_property.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""Property-based invariant tests for the asset engine.
|
||||
|
||||
Hypothesis generates random inputs; we assert mathematical invariants
|
||||
that must hold regardless of input."""
|
||||
|
||||
from hypothesis import given, settings, strategies as st, HealthCheck
|
||||
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', 'property_based')
|
||||
class TestDepreciationInvariants(TransactionCase):
|
||||
|
||||
@given(
|
||||
cost=st.floats(min_value=100.0, max_value=1000000.0,
|
||||
allow_nan=False, allow_infinity=False),
|
||||
salvage_pct=st.floats(min_value=0.0, max_value=0.5,
|
||||
allow_nan=False, allow_infinity=False),
|
||||
n_periods=st.integers(min_value=1, max_value=40),
|
||||
)
|
||||
@settings(max_examples=80, deadline=2000,
|
||||
suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_straight_line_total_equals_cost_minus_salvage(self, cost, salvage_pct, n_periods):
|
||||
cost = round(cost, 2)
|
||||
salvage = round(cost * salvage_pct, 2)
|
||||
steps = straight_line(cost=cost, salvage_value=salvage, n_periods=n_periods)
|
||||
total = sum(s.period_amount for s in steps)
|
||||
# Within 1c rounding tolerance
|
||||
self.assertAlmostEqual(
|
||||
total, cost - salvage, places=1,
|
||||
msg=f"cost={cost}, salvage={salvage}, n={n_periods}, total={total:.2f}",
|
||||
)
|
||||
|
||||
@given(
|
||||
cost=st.floats(min_value=100.0, max_value=1000000.0,
|
||||
allow_nan=False, allow_infinity=False),
|
||||
salvage_pct=st.floats(min_value=0.0, max_value=0.5,
|
||||
allow_nan=False, allow_infinity=False),
|
||||
n_periods=st.integers(min_value=1, max_value=20),
|
||||
)
|
||||
@settings(max_examples=50, deadline=2000,
|
||||
suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_straight_line_book_value_decreasing(self, cost, salvage_pct, n_periods):
|
||||
cost = round(cost, 2)
|
||||
salvage = round(cost * salvage_pct, 2)
|
||||
steps = straight_line(cost=cost, salvage_value=salvage, n_periods=n_periods)
|
||||
for i in range(1, len(steps)):
|
||||
self.assertLessEqual(
|
||||
steps[i].book_value_at_end,
|
||||
steps[i - 1].book_value_at_end + 0.01,
|
||||
)
|
||||
|
||||
@given(
|
||||
cost=st.floats(min_value=1000.0, max_value=100000.0,
|
||||
allow_nan=False, allow_infinity=False),
|
||||
salvage_pct=st.floats(min_value=0.0, max_value=0.3,
|
||||
allow_nan=False, allow_infinity=False),
|
||||
n_periods=st.integers(min_value=2, max_value=20),
|
||||
rate=st.floats(min_value=0.05, max_value=0.5,
|
||||
allow_nan=False, allow_infinity=False),
|
||||
)
|
||||
@settings(max_examples=50, deadline=3000,
|
||||
suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_declining_balance_never_below_salvage(self, cost, salvage_pct, n_periods, rate):
|
||||
cost = round(cost, 2)
|
||||
salvage = round(cost * salvage_pct, 2)
|
||||
steps = declining_balance(
|
||||
cost=cost, salvage_value=salvage,
|
||||
n_periods=n_periods, rate=rate,
|
||||
)
|
||||
for s in steps:
|
||||
self.assertGreaterEqual(
|
||||
s.book_value_at_end, salvage - 0.01,
|
||||
msg=f"cost={cost}, salvage={salvage}, rate={rate}, step={s}",
|
||||
)
|
||||
|
||||
@given(
|
||||
cost=st.floats(min_value=1000.0, max_value=100000.0,
|
||||
allow_nan=False, allow_infinity=False),
|
||||
total_units=st.floats(min_value=100.0, max_value=10000.0,
|
||||
allow_nan=False, allow_infinity=False),
|
||||
n_periods=st.integers(min_value=1, max_value=10),
|
||||
)
|
||||
@settings(max_examples=30, deadline=2000,
|
||||
suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_units_of_production_total_at_full_use_equals_depreciable(self, cost, total_units, n_periods):
|
||||
cost = round(cost, 2)
|
||||
salvage = 0.0
|
||||
# Distribute total_units evenly across periods
|
||||
per_period = total_units / n_periods
|
||||
steps = units_of_production(
|
||||
cost=cost, salvage_value=salvage,
|
||||
total_units_expected=total_units,
|
||||
units_per_period=[per_period] * n_periods,
|
||||
)
|
||||
total = sum(s.period_amount for s in steps)
|
||||
self.assertAlmostEqual(total, cost - salvage, places=1)
|
||||
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)
|
||||
112
fusion_accounting_assets/tests/test_method_integration.py
Normal file
112
fusion_accounting_assets/tests/test_method_integration.py
Normal file
@@ -0,0 +1,112 @@
|
||||
"""Integration tests verifying all 3 depreciation methods through the engine."""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.tests import tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'integration')
|
||||
class TestStraightLineIntegration(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.engine = self.env['fusion.asset.engine']
|
||||
|
||||
def test_straight_line_5yr_no_salvage(self):
|
||||
asset = self.env['fusion.asset'].create({
|
||||
'name': 'SL Test', 'cost': 10000, 'salvage_value': 0,
|
||||
'acquisition_date': date(2026, 1, 1),
|
||||
'in_service_date': date(2026, 1, 1),
|
||||
'method': 'straight_line', 'useful_life_years': 5,
|
||||
})
|
||||
self.engine.compute_depreciation_schedule(asset)
|
||||
lines = asset.depreciation_line_ids.sorted('period_index')
|
||||
self.assertEqual(len(lines), 5)
|
||||
for line in lines:
|
||||
self.assertAlmostEqual(line.amount, 2000, places=2)
|
||||
|
||||
def test_straight_line_10yr_with_salvage(self):
|
||||
asset = self.env['fusion.asset'].create({
|
||||
'name': 'SL10', 'cost': 50000, 'salvage_value': 5000,
|
||||
'acquisition_date': date(2026, 1, 1),
|
||||
'in_service_date': date(2026, 1, 1),
|
||||
'method': 'straight_line', 'useful_life_years': 10,
|
||||
})
|
||||
self.engine.compute_depreciation_schedule(asset)
|
||||
lines = asset.depreciation_line_ids.sorted('period_index')
|
||||
self.assertEqual(len(lines), 10)
|
||||
# Each year = (50000-5000)/10 = 4500; total depreciable = 45000
|
||||
self.assertAlmostEqual(sum(lines.mapped('amount')), 45000, places=2)
|
||||
|
||||
def test_straight_line_book_value_at_end_equals_salvage(self):
|
||||
asset = self.env['fusion.asset'].create({
|
||||
'name': 'SL', '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,
|
||||
})
|
||||
self.engine.compute_depreciation_schedule(asset)
|
||||
last = asset.depreciation_line_ids.sorted('period_index')[-1]
|
||||
self.assertAlmostEqual(last.book_value_at_end, 1000, places=2)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'integration')
|
||||
class TestDecliningBalanceIntegration(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.engine = self.env['fusion.asset.engine']
|
||||
|
||||
def test_declining_balance_30pct(self):
|
||||
asset = self.env['fusion.asset'].create({
|
||||
'name': 'DB', 'cost': 10000, 'salvage_value': 1000,
|
||||
'acquisition_date': date(2026, 1, 1),
|
||||
'in_service_date': date(2026, 1, 1),
|
||||
'method': 'declining_balance', 'useful_life_years': 5,
|
||||
'declining_rate_pct': 30.0,
|
||||
})
|
||||
self.engine.compute_depreciation_schedule(asset)
|
||||
lines = asset.depreciation_line_ids.sorted('period_index')
|
||||
# First period: 10000 * 0.30 = 3000
|
||||
self.assertAlmostEqual(lines[0].amount, 3000, places=2)
|
||||
# Should not exceed salvage at end
|
||||
self.assertGreaterEqual(lines[-1].book_value_at_end, 999.99)
|
||||
|
||||
def test_declining_balance_50pct_high_rate(self):
|
||||
asset = self.env['fusion.asset'].create({
|
||||
'name': 'DB50', 'cost': 8000, 'salvage_value': 500,
|
||||
'acquisition_date': date(2026, 1, 1),
|
||||
'in_service_date': date(2026, 1, 1),
|
||||
'method': 'declining_balance', 'useful_life_years': 5,
|
||||
'declining_rate_pct': 50.0,
|
||||
})
|
||||
self.engine.compute_depreciation_schedule(asset)
|
||||
# First period: 8000 * 0.50 = 4000
|
||||
first = asset.depreciation_line_ids.sorted('period_index')[0]
|
||||
self.assertAlmostEqual(first.amount, 4000, places=2)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'integration')
|
||||
class TestUnitsOfProductionIntegration(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.engine = self.env['fusion.asset.engine']
|
||||
|
||||
def test_units_of_production_5yr_even_distribution(self):
|
||||
asset = self.env['fusion.asset'].create({
|
||||
'name': 'UOP', 'cost': 50000, 'salvage_value': 0,
|
||||
'acquisition_date': date(2026, 1, 1),
|
||||
'in_service_date': date(2026, 1, 1),
|
||||
'method': 'units_of_production',
|
||||
'total_units_expected': 100000,
|
||||
'useful_life_years': 5,
|
||||
})
|
||||
self.engine.compute_depreciation_schedule(asset)
|
||||
lines = asset.depreciation_line_ids.sorted('period_index')
|
||||
# 5 periods, even distribution = 20000 units/period
|
||||
# Each period: (20000/100000) * 50000 = 10000
|
||||
self.assertEqual(len(lines), 5)
|
||||
for line in lines:
|
||||
self.assertAlmostEqual(line.amount, 10000, places=2)
|
||||
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'])
|
||||
48
fusion_accounting_assets/tests/test_partial_sale_wizard.py
Normal file
48
fusion_accounting_assets/tests/test_partial_sale_wizard.py
Normal file
@@ -0,0 +1,48 @@
|
||||
from datetime import date
|
||||
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tests import tagged
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestPartialSaleWizard(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.asset = self.env['fusion.asset'].create({
|
||||
'name': 'Partial Sale Test',
|
||||
'cost': 10000,
|
||||
'acquisition_date': date(2026, 1, 1),
|
||||
'in_service_date': date(2026, 1, 1),
|
||||
'method': 'straight_line', 'useful_life_years': 5,
|
||||
})
|
||||
self.env['fusion.asset.engine'].compute_depreciation_schedule(self.asset)
|
||||
self.asset.action_set_running()
|
||||
|
||||
def test_partial_sell_30pct_creates_child(self):
|
||||
wizard = self.env['fusion.partial.sale.wizard'].create({
|
||||
'asset_id': self.asset.id,
|
||||
'sold_pct': 30.0, 'sold_amount': 4000,
|
||||
'sale_date': date(2026, 6, 1),
|
||||
})
|
||||
wizard.action_partial_sell()
|
||||
self.asset.invalidate_recordset(['cost'])
|
||||
self.assertAlmostEqual(self.asset.cost, 7000, places=2)
|
||||
|
||||
def test_invalid_pct_raises(self):
|
||||
wizard = self.env['fusion.partial.sale.wizard'].create({
|
||||
'asset_id': self.asset.id,
|
||||
'sold_pct': 0, 'sold_amount': 100,
|
||||
})
|
||||
with self.assertRaises(UserError):
|
||||
wizard.action_partial_sell()
|
||||
|
||||
def test_compute_estimated_gain_loss(self):
|
||||
wizard = self.env['fusion.partial.sale.wizard'].new({
|
||||
'asset_id': self.asset.id,
|
||||
'sold_pct': 30.0, 'sold_amount': 4000,
|
||||
})
|
||||
wizard._compute_sold_cost()
|
||||
self.assertAlmostEqual(wizard.estimated_sold_cost, 3000, places=2)
|
||||
self.assertAlmostEqual(wizard.estimated_gain_loss, 1000, places=2)
|
||||
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)
|
||||
117
fusion_accounting_assets/tests/test_performance_benchmarks.py
Normal file
117
fusion_accounting_assets/tests/test_performance_benchmarks.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""Performance benchmarks tagged 'benchmark'."""
|
||||
|
||||
import json
|
||||
import statistics
|
||||
import time
|
||||
from datetime import date
|
||||
|
||||
from odoo.tests.common import HttpCase, TransactionCase, new_test_user
|
||||
from odoo.tests import tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'benchmark')
|
||||
class TestEngineBenchmarks(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.engine = self.env['fusion.asset.engine']
|
||||
|
||||
def _percentile(self, samples, p):
|
||||
if len(samples) <= 1:
|
||||
return samples[0] if samples else 0
|
||||
sorted_s = sorted(samples)
|
||||
idx = int(len(sorted_s) * p / 100)
|
||||
return sorted_s[min(idx, len(sorted_s) - 1)]
|
||||
|
||||
def test_compute_schedule_p95(self):
|
||||
timings = []
|
||||
for i in range(10):
|
||||
asset = self.env['fusion.asset'].create({
|
||||
'name': f'PerfAsset{i}', 'cost': 100000, 'salvage_value': 5000,
|
||||
'acquisition_date': date(2026, 1, 1),
|
||||
'method': 'straight_line', 'useful_life_years': 10,
|
||||
})
|
||||
start = time.perf_counter()
|
||||
self.engine.compute_depreciation_schedule(asset)
|
||||
timings.append((time.perf_counter() - start) * 1000)
|
||||
p95 = self._percentile(timings, 95)
|
||||
median = statistics.median(timings)
|
||||
msg = f"compute_schedule(10yr): median={median:.0f}ms p95={p95:.0f}ms"
|
||||
print(f"\n PERF: {msg} (target <500ms)")
|
||||
self.assertLess(p95, 5000, f"way over budget: {msg}")
|
||||
|
||||
def test_post_depreciation_p95(self):
|
||||
asset = self.env['fusion.asset'].create({
|
||||
'name': 'PostPerf', 'cost': 50000,
|
||||
'acquisition_date': date(2026, 1, 1),
|
||||
'in_service_date': date(2026, 1, 1),
|
||||
'method': 'straight_line', 'useful_life_years': 10,
|
||||
})
|
||||
self.engine.compute_depreciation_schedule(asset)
|
||||
asset.action_set_running()
|
||||
timings = []
|
||||
for _ in range(5):
|
||||
start = time.perf_counter()
|
||||
self.engine.post_depreciation_entry(asset)
|
||||
timings.append((time.perf_counter() - start) * 1000)
|
||||
p95 = self._percentile(timings, 95)
|
||||
median = statistics.median(timings)
|
||||
msg = f"post_depreciation: median={median:.0f}ms p95={p95:.0f}ms"
|
||||
print(f"\n PERF: {msg} (target <300ms)")
|
||||
self.assertLess(p95, 3000)
|
||||
|
||||
def test_dispose_asset_p95(self):
|
||||
timings = []
|
||||
for i in range(5):
|
||||
asset = self.env['fusion.asset'].create({
|
||||
'name': f'DispPerf{i}', 'cost': 10000,
|
||||
'acquisition_date': date(2026, 1, 1),
|
||||
'in_service_date': date(2026, 1, 1),
|
||||
'method': 'straight_line', 'useful_life_years': 5,
|
||||
})
|
||||
self.engine.compute_depreciation_schedule(asset)
|
||||
asset.action_set_running()
|
||||
start = time.perf_counter()
|
||||
self.engine.dispose_asset(asset, sale_amount=5000)
|
||||
timings.append((time.perf_counter() - start) * 1000)
|
||||
p95 = self._percentile(timings, 95)
|
||||
median = statistics.median(timings)
|
||||
msg = f"dispose_asset: median={median:.0f}ms p95={p95:.0f}ms"
|
||||
print(f"\n PERF: {msg} (target <300ms)")
|
||||
self.assertLess(p95, 3000)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'benchmark')
|
||||
class TestControllerBenchmarks(HttpCase):
|
||||
|
||||
def test_list_endpoint_p95(self):
|
||||
new_test_user(
|
||||
self.env, login='asset_perf',
|
||||
groups='base.group_user,account.group_account_invoice',
|
||||
)
|
||||
for i in range(20):
|
||||
self.env['fusion.asset'].create({
|
||||
'name': f'ListPerf{i}', 'cost': 1000,
|
||||
'acquisition_date': date(2026, 1, 1),
|
||||
'method': 'straight_line', 'useful_life_years': 4,
|
||||
})
|
||||
self.authenticate('asset_perf', 'asset_perf')
|
||||
timings = []
|
||||
for _ in range(5):
|
||||
start = time.perf_counter()
|
||||
response = self.url_open(
|
||||
'/fusion/assets/list',
|
||||
data=json.dumps({
|
||||
'jsonrpc': '2.0', 'method': 'call', 'id': 1,
|
||||
'params': {'company_id': self.env.company.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.list: median={median:.0f}ms p95={p95:.0f}ms"
|
||||
print(f"\n PERF: {msg} (target <300ms)")
|
||||
self.assertLess(p95, 3000)
|
||||
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>
|
||||
@@ -0,0 +1,4 @@
|
||||
from . import create_asset_wizard
|
||||
from . import disposal_wizard
|
||||
from . import partial_sale_wizard
|
||||
from . import depreciation_run_wizard
|
||||
|
||||
133
fusion_accounting_assets/wizards/create_asset_wizard.py
Normal file
133
fusion_accounting_assets/wizards/create_asset_wizard.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""Create-asset-from-invoice-line wizard.
|
||||
|
||||
Reads an account.move.line as the source, pre-fills name/cost/category,
|
||||
and optionally calls the AI useful-life predictor for suggestions."""
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
from ..services.useful_life_predictor import predict_useful_life
|
||||
|
||||
|
||||
class FusionCreateAssetWizard(models.TransientModel):
|
||||
_name = "fusion.create.asset.wizard"
|
||||
_description = "Create Fusion Asset from Invoice Line"
|
||||
|
||||
source_invoice_line_id = fields.Many2one(
|
||||
'account.move.line', string='Source Invoice Line',
|
||||
default=lambda self: self._default_source_line(),
|
||||
)
|
||||
name = fields.Char(required=True)
|
||||
cost = fields.Monetary(required=True)
|
||||
salvage_value = fields.Monetary(default=0.0)
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency', required=True,
|
||||
default=lambda self: self.env.company.currency_id,
|
||||
)
|
||||
category_id = fields.Many2one('fusion.asset.category')
|
||||
method = fields.Selection([
|
||||
('straight_line', 'Straight Line'),
|
||||
('declining_balance', 'Declining Balance'),
|
||||
('units_of_production', 'Units of Production'),
|
||||
], required=True, default='straight_line')
|
||||
useful_life_years = fields.Integer(default=5)
|
||||
declining_rate_pct = fields.Float(default=20.0)
|
||||
acquisition_date = fields.Date(required=True, default=fields.Date.today)
|
||||
in_service_date = fields.Date(default=fields.Date.today)
|
||||
|
||||
ai_suggested_years = fields.Integer(readonly=True)
|
||||
ai_suggested_method = fields.Char(readonly=True)
|
||||
ai_rationale = fields.Text(readonly=True)
|
||||
ai_confidence = fields.Float(readonly=True)
|
||||
|
||||
@api.model
|
||||
def _default_source_line(self):
|
||||
ctx = self.env.context
|
||||
if ctx.get('active_model') == 'account.move.line':
|
||||
return ctx.get('active_id')
|
||||
return False
|
||||
|
||||
@api.onchange('source_invoice_line_id')
|
||||
def _onchange_source_invoice_line_id(self):
|
||||
if not self.source_invoice_line_id:
|
||||
return
|
||||
line = self.source_invoice_line_id
|
||||
if not self.name:
|
||||
self.name = line.name or line.move_id.name or 'New Asset'
|
||||
if not self.cost:
|
||||
self.cost = abs(line.balance) if line.balance else (line.price_unit * line.quantity)
|
||||
if line.currency_id and not self.currency_id:
|
||||
self.currency_id = line.currency_id
|
||||
|
||||
@api.onchange('category_id')
|
||||
def _onchange_category_id(self):
|
||||
if self.category_id:
|
||||
self.method = self.category_id.method
|
||||
self.useful_life_years = self.category_id.useful_life_years
|
||||
self.declining_rate_pct = self.category_id.declining_rate_pct
|
||||
if self.category_id.salvage_value_pct and self.cost:
|
||||
self.salvage_value = round(
|
||||
self.cost * self.category_id.salvage_value_pct / 100, 2)
|
||||
|
||||
def action_ai_suggest(self):
|
||||
"""Call AI useful-life predictor."""
|
||||
self.ensure_one()
|
||||
if not self.name and not self.source_invoice_line_id:
|
||||
raise UserError(_("Need a name or source invoice line first."))
|
||||
description = self.name
|
||||
if self.source_invoice_line_id and self.source_invoice_line_id.name:
|
||||
description = self.source_invoice_line_id.name
|
||||
partner_name = None
|
||||
if self.source_invoice_line_id and self.source_invoice_line_id.partner_id:
|
||||
partner_name = self.source_invoice_line_id.partner_id.name
|
||||
|
||||
suggestion = predict_useful_life(
|
||||
self.env, description=description,
|
||||
amount=self.cost, partner_name=partner_name,
|
||||
)
|
||||
self.write({
|
||||
'ai_suggested_years': suggestion.get('useful_life_years'),
|
||||
'ai_suggested_method': suggestion.get('depreciation_method'),
|
||||
'ai_rationale': suggestion.get('rationale'),
|
||||
'ai_confidence': suggestion.get('confidence'),
|
||||
'useful_life_years': suggestion.get('useful_life_years'),
|
||||
'method': suggestion.get('depreciation_method'),
|
||||
})
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': self._name,
|
||||
'res_id': self.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
}
|
||||
|
||||
def action_create_asset(self):
|
||||
"""Create the fusion.asset record + link to source invoice line."""
|
||||
self.ensure_one()
|
||||
if not self.cost:
|
||||
raise UserError(_("Cost is required."))
|
||||
Asset = self.env['fusion.asset']
|
||||
asset = Asset.create({
|
||||
'name': self.name,
|
||||
'cost': self.cost,
|
||||
'salvage_value': self.salvage_value,
|
||||
'currency_id': self.currency_id.id,
|
||||
'category_id': self.category_id.id if self.category_id else False,
|
||||
'method': self.method,
|
||||
'useful_life_years': self.useful_life_years,
|
||||
'declining_rate_pct': self.declining_rate_pct,
|
||||
'acquisition_date': self.acquisition_date,
|
||||
'in_service_date': self.in_service_date,
|
||||
'source_invoice_line_id': self.source_invoice_line_id.id if self.source_invoice_line_id else False,
|
||||
'company_id': self.env.company.id,
|
||||
})
|
||||
if self.source_invoice_line_id:
|
||||
self.source_invoice_line_id.fusion_asset_id = asset.id
|
||||
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'fusion.asset',
|
||||
'res_id': asset.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_fusion_create_asset_wizard_form" model="ir.ui.view">
|
||||
<field name="name">fusion.create.asset.wizard.form</field>
|
||||
<field name="model">fusion.create.asset.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Create Fixed Asset">
|
||||
<group>
|
||||
<group string="Basics">
|
||||
<field name="name"/>
|
||||
<field name="source_invoice_line_id"
|
||||
options="{'no_create': True}"
|
||||
readonly="source_invoice_line_id"/>
|
||||
<field name="cost"/>
|
||||
<field name="currency_id" invisible="1"/>
|
||||
<field name="salvage_value"/>
|
||||
<field name="category_id" options="{'no_create': True}"/>
|
||||
</group>
|
||||
<group string="Depreciation">
|
||||
<field name="method"/>
|
||||
<field name="useful_life_years"
|
||||
invisible="method == 'units_of_production'"/>
|
||||
<field name="declining_rate_pct"
|
||||
invisible="method != 'declining_balance'"/>
|
||||
<field name="acquisition_date"/>
|
||||
<field name="in_service_date"/>
|
||||
</group>
|
||||
</group>
|
||||
<group string="AI Suggestion" invisible="not ai_suggested_years">
|
||||
<field name="ai_suggested_years" readonly="1"/>
|
||||
<field name="ai_suggested_method" readonly="1"/>
|
||||
<field name="ai_rationale" readonly="1"/>
|
||||
<field name="ai_confidence" readonly="1"/>
|
||||
</group>
|
||||
<footer>
|
||||
<button name="action_ai_suggest" type="object"
|
||||
string="AI Suggest" class="btn-secondary"/>
|
||||
<button name="action_create_asset" type="object"
|
||||
string="Create Asset" class="btn-primary"/>
|
||||
<button special="cancel" string="Cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fusion_create_asset_wizard" model="ir.actions.act_window">
|
||||
<field name="name">Create Asset from Invoice</field>
|
||||
<field name="res_model">fusion.create.asset.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
<field name="binding_model_id" ref="account.model_account_move_line"/>
|
||||
<field name="binding_view_types">list</field>
|
||||
</record>
|
||||
</odoo>
|
||||
72
fusion_accounting_assets/wizards/depreciation_run_wizard.py
Normal file
72
fusion_accounting_assets/wizards/depreciation_run_wizard.py
Normal file
@@ -0,0 +1,72 @@
|
||||
"""Manual depreciation run wizard.
|
||||
|
||||
Operator picks a period_date and the wizard posts all running assets'
|
||||
unposted lines whose scheduled_date <= period_date."""
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class FusionDepreciationRunWizard(models.TransientModel):
|
||||
_name = "fusion.depreciation.run.wizard"
|
||||
_description = "Manual Depreciation Run Wizard"
|
||||
|
||||
period_date = fields.Date(
|
||||
required=True, default=fields.Date.today,
|
||||
help="Post all unposted lines whose scheduled_date is on or before this date.",
|
||||
)
|
||||
state_filter = fields.Selection([
|
||||
('all_running', 'All Running Assets'),
|
||||
('selected', 'Selected Asset(s) Only'),
|
||||
], default='all_running', required=True)
|
||||
asset_ids = fields.Many2many(
|
||||
'fusion.asset', domain=[('state', '=', 'running')],
|
||||
)
|
||||
|
||||
state = fields.Selection(
|
||||
[('draft', 'Draft'), ('done', 'Done')], default='draft',
|
||||
)
|
||||
posted_count = fields.Integer(readonly=True)
|
||||
skipped_count = fields.Integer(readonly=True)
|
||||
error_count = fields.Integer(readonly=True)
|
||||
summary = fields.Text(readonly=True)
|
||||
|
||||
def action_run(self):
|
||||
self.ensure_one()
|
||||
if self.state_filter == 'all_running':
|
||||
assets = self.env['fusion.asset'].search([
|
||||
('state', '=', 'running'),
|
||||
('company_id', '=', self.env.company.id),
|
||||
])
|
||||
else:
|
||||
assets = self.asset_ids
|
||||
|
||||
engine = self.env['fusion.asset.engine']
|
||||
posted = 0
|
||||
skipped = 0
|
||||
errors = []
|
||||
for asset in assets:
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
result = engine.post_depreciation_entry(
|
||||
asset, period_date=self.period_date,
|
||||
)
|
||||
posted += result.get('posted_count', 0)
|
||||
if result.get('posted_count', 0) == 0:
|
||||
skipped += 1
|
||||
except Exception as e: # noqa: BLE001
|
||||
errors.append(f"{asset.name}: {e}")
|
||||
|
||||
self.write({
|
||||
'state': 'done',
|
||||
'posted_count': posted,
|
||||
'skipped_count': skipped,
|
||||
'error_count': len(errors),
|
||||
'summary': '\n'.join(errors[:20]) if errors else False,
|
||||
})
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': self._name,
|
||||
'res_id': self.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'new',
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_fusion_depreciation_run_wizard_form" model="ir.ui.view">
|
||||
<field name="name">fusion.depreciation.run.wizard.form</field>
|
||||
<field name="model">fusion.depreciation.run.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Run Depreciation">
|
||||
<group invisible="state == 'done'">
|
||||
<field name="period_date"/>
|
||||
<field name="state_filter" widget="radio"/>
|
||||
<field name="asset_ids" widget="many2many_tags"
|
||||
invisible="state_filter != 'selected'"/>
|
||||
</group>
|
||||
<group invisible="state != 'done'" string="Results">
|
||||
<field name="posted_count"/>
|
||||
<field name="skipped_count"/>
|
||||
<field name="error_count"/>
|
||||
<field name="summary"/>
|
||||
</group>
|
||||
<field name="state" invisible="1"/>
|
||||
<footer>
|
||||
<button name="action_run" type="object" string="Run"
|
||||
class="btn-primary" invisible="state == 'done'"/>
|
||||
<button special="cancel" string="Close"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fusion_depreciation_run_wizard" model="ir.actions.act_window">
|
||||
<field name="name">Run Depreciation</field>
|
||||
<field name="res_model">fusion.depreciation.run.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
</odoo>
|
||||
65
fusion_accounting_assets/wizards/disposal_wizard.py
Normal file
65
fusion_accounting_assets/wizards/disposal_wizard.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""Asset disposal wizard (sale, scrap, donation, lost)."""
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class FusionDisposalWizard(models.TransientModel):
|
||||
_name = "fusion.disposal.wizard"
|
||||
_description = "Asset Disposal Wizard"
|
||||
|
||||
asset_id = fields.Many2one(
|
||||
'fusion.asset', required=True,
|
||||
default=lambda self: self._default_asset(),
|
||||
)
|
||||
company_id = fields.Many2one(related='asset_id.company_id')
|
||||
currency_id = fields.Many2one(related='asset_id.currency_id')
|
||||
book_value = fields.Monetary(related='asset_id.book_value', readonly=True)
|
||||
|
||||
disposal_type = fields.Selection([
|
||||
('sale', 'Sale'),
|
||||
('scrap', 'Scrap'),
|
||||
('donation', 'Donation'),
|
||||
('lost', 'Lost / Stolen'),
|
||||
], required=True, default='sale')
|
||||
disposal_date = fields.Date(required=True, default=fields.Date.today)
|
||||
sale_amount = fields.Monetary(default=0.0)
|
||||
sale_partner_id = fields.Many2one('res.partner')
|
||||
notes = fields.Text()
|
||||
|
||||
estimated_gain_loss = fields.Monetary(compute='_compute_gain_loss')
|
||||
|
||||
@api.model
|
||||
def _default_asset(self):
|
||||
ctx = self.env.context
|
||||
if ctx.get('active_model') == 'fusion.asset':
|
||||
return ctx.get('active_id')
|
||||
return False
|
||||
|
||||
@api.depends('sale_amount', 'book_value', 'disposal_type')
|
||||
def _compute_gain_loss(self):
|
||||
for w in self:
|
||||
if w.disposal_type == 'sale':
|
||||
w.estimated_gain_loss = w.sale_amount - w.book_value
|
||||
else:
|
||||
w.estimated_gain_loss = -w.book_value
|
||||
|
||||
def action_dispose(self):
|
||||
self.ensure_one()
|
||||
if self.asset_id.state == 'disposed':
|
||||
raise UserError(_("Asset already disposed."))
|
||||
partner = self.sale_partner_id if self.disposal_type == 'sale' else None
|
||||
self.env['fusion.asset.engine'].dispose_asset(
|
||||
self.asset_id,
|
||||
sale_amount=self.sale_amount if self.disposal_type == 'sale' else 0,
|
||||
sale_date=self.disposal_date,
|
||||
sale_partner=partner,
|
||||
disposal_type=self.disposal_type,
|
||||
)
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'fusion.asset',
|
||||
'res_id': self.asset_id.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
39
fusion_accounting_assets/wizards/disposal_wizard_views.xml
Normal file
39
fusion_accounting_assets/wizards/disposal_wizard_views.xml
Normal file
@@ -0,0 +1,39 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_fusion_disposal_wizard_form" model="ir.ui.view">
|
||||
<field name="name">fusion.disposal.wizard.form</field>
|
||||
<field name="model">fusion.disposal.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Dispose Asset">
|
||||
<group>
|
||||
<field name="asset_id" options="{'no_create': True}" readonly="1"/>
|
||||
<field name="book_value" readonly="1"/>
|
||||
<field name="company_id" invisible="1"/>
|
||||
<field name="currency_id" invisible="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="disposal_type"/>
|
||||
<field name="disposal_date"/>
|
||||
<field name="sale_amount" invisible="disposal_type != 'sale'"/>
|
||||
<field name="sale_partner_id" invisible="disposal_type != 'sale'"/>
|
||||
<field name="estimated_gain_loss" readonly="1"/>
|
||||
<field name="notes"/>
|
||||
</group>
|
||||
<footer>
|
||||
<button name="action_dispose" type="object"
|
||||
string="Confirm Disposal" class="btn-primary"/>
|
||||
<button special="cancel" string="Cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fusion_disposal_wizard" model="ir.actions.act_window">
|
||||
<field name="name">Dispose Asset</field>
|
||||
<field name="res_model">fusion.disposal.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
<field name="binding_model_id" ref="model_fusion_asset"/>
|
||||
<field name="binding_view_types">form,list</field>
|
||||
</record>
|
||||
</odoo>
|
||||
67
fusion_accounting_assets/wizards/partial_sale_wizard.py
Normal file
67
fusion_accounting_assets/wizards/partial_sale_wizard.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""Partial sale wizard (sell a portion of an asset).
|
||||
|
||||
Splits the asset into a child (the sold portion) and disposes the child;
|
||||
parent retains remaining cost + salvage."""
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class FusionPartialSaleWizard(models.TransientModel):
|
||||
_name = "fusion.partial.sale.wizard"
|
||||
_description = "Asset Partial Sale Wizard"
|
||||
|
||||
asset_id = fields.Many2one(
|
||||
'fusion.asset', required=True,
|
||||
default=lambda self: self._default_asset(),
|
||||
)
|
||||
company_id = fields.Many2one(related='asset_id.company_id')
|
||||
currency_id = fields.Many2one(related='asset_id.currency_id')
|
||||
cost = fields.Monetary(related='asset_id.cost', readonly=True)
|
||||
book_value = fields.Monetary(related='asset_id.book_value', readonly=True)
|
||||
|
||||
sold_pct = fields.Float(
|
||||
string='% of cost being sold', default=30.0,
|
||||
help="Percentage of original cost attributed to the sold portion.",
|
||||
)
|
||||
sold_amount = fields.Monetary(string='Sale Amount', required=True)
|
||||
sale_date = fields.Date(required=True, default=fields.Date.today)
|
||||
sale_partner_id = fields.Many2one('res.partner')
|
||||
|
||||
estimated_sold_cost = fields.Monetary(compute='_compute_sold_cost')
|
||||
estimated_gain_loss = fields.Monetary(compute='_compute_sold_cost')
|
||||
|
||||
@api.model
|
||||
def _default_asset(self):
|
||||
ctx = self.env.context
|
||||
if ctx.get('active_model') == 'fusion.asset':
|
||||
return ctx.get('active_id')
|
||||
return False
|
||||
|
||||
@api.depends('sold_pct', 'sold_amount', 'cost')
|
||||
def _compute_sold_cost(self):
|
||||
for w in self:
|
||||
w.estimated_sold_cost = round(w.cost * (w.sold_pct or 0) / 100, 2)
|
||||
w.estimated_gain_loss = w.sold_amount - w.estimated_sold_cost
|
||||
|
||||
def action_partial_sell(self):
|
||||
self.ensure_one()
|
||||
if not (0 < self.sold_pct < 100):
|
||||
raise UserError(_("sold_pct must be strictly between 0 and 100."))
|
||||
if self.asset_id.state == 'disposed':
|
||||
raise UserError(_("Asset already disposed."))
|
||||
|
||||
result = self.env['fusion.asset.engine'].partial_sale(
|
||||
self.asset_id,
|
||||
sold_amount=self.sold_amount,
|
||||
sold_qty=self.sold_pct / 100,
|
||||
sale_date=self.sale_date,
|
||||
sale_partner=self.sale_partner_id,
|
||||
)
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'fusion.asset',
|
||||
'res_id': result['parent_asset_id'],
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="view_fusion_partial_sale_wizard_form" model="ir.ui.view">
|
||||
<field name="name">fusion.partial.sale.wizard.form</field>
|
||||
<field name="model">fusion.partial.sale.wizard</field>
|
||||
<field name="arch" type="xml">
|
||||
<form string="Partial Sale">
|
||||
<group>
|
||||
<field name="asset_id" readonly="1" options="{'no_create': True}"/>
|
||||
<field name="cost" readonly="1"/>
|
||||
<field name="book_value" readonly="1"/>
|
||||
<field name="company_id" invisible="1"/>
|
||||
<field name="currency_id" invisible="1"/>
|
||||
</group>
|
||||
<group>
|
||||
<field name="sold_pct"/>
|
||||
<field name="estimated_sold_cost" readonly="1"/>
|
||||
<field name="sold_amount"/>
|
||||
<field name="estimated_gain_loss" readonly="1"/>
|
||||
<field name="sale_date"/>
|
||||
<field name="sale_partner_id"/>
|
||||
</group>
|
||||
<footer>
|
||||
<button name="action_partial_sell" type="object"
|
||||
string="Confirm Partial Sale" class="btn-primary"/>
|
||||
<button special="cancel" string="Cancel"/>
|
||||
</footer>
|
||||
</form>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<record id="action_fusion_partial_sale_wizard" model="ir.actions.act_window">
|
||||
<field name="name">Partial Sale</field>
|
||||
<field name="res_model">fusion.partial.sale.wizard</field>
|
||||
<field name="view_mode">form</field>
|
||||
<field name="target">new</field>
|
||||
</record>
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user