diff --git a/fusion_accounting/PHASE_3_PLAN.md b/fusion_accounting/PHASE_3_PLAN.md new file mode 100644 index 00000000..5ea84d46 --- /dev/null +++ b/fusion_accounting/PHASE_3_PLAN.md @@ -0,0 +1,165 @@ +# Phase 3 — Fusion Accounting Assets Implementation Plan + +**Module:** `fusion_accounting_assets` +**Branch:** `fusion_accounting/phase-3-assets` +**Pre-phase tag:** `fusion_accounting/pre-phase-3` +**Estimated tasks:** ~50 +**Reference:** `/Users/gurpreet/Github/RePackaged-Odoo/accounting/account_asset/` (~2258 LOC Python) + +## Goal + +Replace Odoo Enterprise's `account_asset` module — asset management with depreciation schedules, disposal, partial sale, and reporting. CORE scope: 3 depreciation methods (straight-line, declining balance, units of production), full asset lifecycle, depreciation board, disposal/sale wizards. AI augmentation: utilization anomaly detection + AI-suggested useful life from invoice context. Coexists with Enterprise. + +## Architecture (HYBRID engine, Phase 1+2 pattern) + +``` +fusion.asset.engine (AbstractModel) ← shared primitives +├── compute_depreciation_schedule(asset, recompute=False) +├── post_depreciation_entry(asset, period) +├── dispose_asset(asset, *, sale_amount, sale_date, sale_partner=None) +├── partial_sale(asset, *, sold_amount, sold_qty, sale_date) +├── pause_asset(asset, pause_date) +├── resume_asset(asset, resume_date) +└── reverse_disposal(asset) + +services/ ← pure-Python +├── depreciation_methods.py → straight_line, declining_balance, units_of_production +├── prorate.py → first/last period prorating (calendar/365/etc.) +├── salvage_value.py → end-of-life value math +├── anomaly_detection.py → utilization variance vs expected +├── useful_life_predictor.py → LLM-suggested useful life from invoice description +└── useful_life_prompt.py → provider-agnostic LLM prompt + +models/ +├── fusion_asset.py → main fusion.asset model +├── fusion_asset_depreciation_line.py → depreciation board lines +├── fusion_asset_category.py → categories with default settings +├── fusion_asset_disposal.py → disposal records +├── fusion_asset_anomaly.py → flagged utilization issues +├── fusion_asset_engine.py → AbstractModel orchestrator +└── account_move.py → inherit (link to asset, generate from invoice) + +controllers/assets_controller.py ← 8 JSON-RPC endpoints +├── /fusion/assets/list → paginated asset list with filters +├── /fusion/assets/get_detail → single asset with full schedule +├── /fusion/assets/compute_schedule → recompute depreciation board +├── /fusion/assets/post_depreciation → run periodic depreciation cron +├── /fusion/assets/dispose → dispose an asset +├── /fusion/assets/get_anomalies → list flagged variances +├── /fusion/assets/suggest_useful_life → AI suggest useful life +└── /fusion/assets/get_partner_history → asset-related partner history + +static/src/ +├── scss/ ← asset-specific design tokens +├── services/assets_service.js ← reactive state + RPC wrappers +├── views/asset_dashboard/ ← top-level OWL controller +└── components/ ← asset_card, depreciation_board, disposal_dialog, + ai_useful_life_panel, anomaly_strip +``` + +## Coexistence + +`group_fusion_show_when_enterprise_absent` from `fusion_accounting_core`. Asset menu only visible when `account_asset` NOT installed. Engine + AI tools always available. + +## Tasks (50 total) + +### Group 1: Foundation (1-2) +1. Safety net (DONE) +2. Plan doc + module skeleton + +### Group 2: Pure-Python services TDD (3-7) +3. `services/depreciation_methods.py` — straight_line + declining_balance + units_of_production (TDD) +4. `services/prorate.py` — first/last period prorating +5. `services/salvage_value.py` — end-of-life math +6. `services/anomaly_detection.py` — utilization variance +7. `services/useful_life_predictor.py` + `useful_life_prompt.py` — LLM integration + +### Group 3: Persisted models (8-13) +8. `models/fusion_asset.py` — main asset model with state machine +9. `models/fusion_asset_depreciation_line.py` — depreciation board lines +10. `models/fusion_asset_category.py` — categories with defaults +11. `models/fusion_asset_disposal.py` — disposal records +12. `models/fusion_asset_anomaly.py` — flagged anomalies +13. `models/account_move.py` (inherit) — link asset to invoice + +### Group 4: Engine (14-15) +14. `models/fusion_asset_engine.py` — 7-method API +15. Engine integration tests (compute_schedule + post_depreciation + dispose end-to-end) + +### Group 5: Backend wiring (16-19) +16. JSON-RPC controller (8 endpoints) +17. AssetsAdapter wiring `_via_fusion` paths +18. 5 new AI tools +19. Cron — daily depreciation post + monthly anomaly scan + +### Group 6: Tests + perf (20-23) +20. Property-based tests (Hypothesis: schedule sums == cost - salvage) +21. Integration tests — straight-line + declining-balance + units-of-production +22. Materialized view for asset book values (perf) +23. Performance benchmarks + +### Group 7: Frontend OWL (24-31) +24. SCSS tokens + main asset stylesheet (light + dark) +25. `assets_service.js` (reactive state + RPC wrappers) +26. `asset_dashboard` (top-level kanban + summary) +27. `asset_card` (one asset summary card) +28. `asset_detail_panel` (right-side: schedule, history, AI suggestions) +29. `depreciation_board` (table view of schedule with edit chevrons) +30. `disposal_dialog` (sale/scrap wizard) +31. Fusion-only: `ai_useful_life_panel` + `anomaly_strip` + +### Group 8: Wizards (32-35) +32. Asset creation wizard (from invoice line) +33. Disposal wizard (sale, scrap, donation) +34. Partial sale wizard +35. Period picker for depreciation runs + +### Group 9: Migration + coexistence (36-39) +36. Migration wizard inheritance — backfill from account.asset rows +37. Audit report PDF (per-company asset count, total NBV, etc.) +38. Menu + window action with coexistence group filter +39. Coexistence test + +### Group 10: Final tests + polish (40-50) +40. 5 OWL tour tests +41. Performance benchmarks (P95: schedule compute < 500ms, board render < 200ms) +42. Optimize if benchmarks fail (conditional) +43. Local LLM compat test for useful_life_predictor +44. Update meta-module manifest +45. CLAUDE.md, UPGRADE_NOTES.md, README.md +46. End-to-end smoke + tag phase-3-complete + push +47-50. Reserved for inherited features: account_move integration, draft journal entries, post-on-confirm flow, fiscal-year-aware proration + +## Performance Targets (P95) + +- `compute_schedule` (10-year asset): <500ms +- `post_depreciation_entry`: <200ms +- `dispose_asset`: <300ms +- Controller `list`: <300ms +- Controller `get_detail`: <500ms + +## V19 Conventions (carried from Phase 1+2) + +- `models.Constraint` not `_sql_constraints` +- No `@api.depends('id')` on stored compute fields +- `@route(type='jsonrpc')` not `type='json'` +- `ir.cron` has no `numbercall` field +- `res.groups.user_ids` not `users` +- `ir.ui.menu.group_ids` not `groups_id` +- `models.Constraint` for unique-keys +- `env.flush_all()` before MV REFRESH +- REFRESH MATERIALIZED VIEW CONCURRENTLY needs autocommit cursor + +## Test Targets + +Match Phase 1+2 test pyramid: +- Unit (pure-Python services) +- Integration (engine end-to-end) +- Property-based (Hypothesis: schedule total invariants) +- Controller (HttpCase JSON-RPC) +- MV correctness +- Performance benchmarks (tagged 'benchmark') +- OWL tours (tagged 'tour') +- Local LLM smoke (tagged 'local_llm') + +Phase 1+2 final: 287 tests. Phase 3 target: ~140-180 additional → ~430-470 total. diff --git a/fusion_accounting/__manifest__.py b/fusion_accounting/__manifest__.py index 86723114..7e388d1f 100644 --- a/fusion_accounting/__manifest__.py +++ b/fusion_accounting/__manifest__.py @@ -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, diff --git a/fusion_accounting_ai/services/data_adapters/assets.py b/fusion_accounting_ai/services/data_adapters/assets.py index df7eaca6..bd20afc2 100644 --- a/fusion_accounting_ai/services/data_adapters/assets.py +++ b/fusion_accounting_ai/services/data_adapters/assets.py @@ -1,42 +1,98 @@ -"""Assets data adapter.""" +"""Assets data adapter — routes asset queries through fusion engine if installed.""" from .base import DataAdapter from ._registry import register_adapter class AssetsAdapter(DataAdapter): - FUSION_MODEL = 'fusion.asset' + FUSION_MODEL = 'fusion.asset.engine' ENTERPRISE_MODULE = 'account_asset' - def list_assets(self, state=None): - return self._dispatch('list_assets', state=state) + # ============================================================ + # list_assets + # ============================================================ - def list_assets_via_fusion(self, state=None): - return self._read_fusion('fusion.asset', state=state) + def list_assets(self, state=None, limit=50, company_id=None): + return self._dispatch( + 'list_assets', state=state, limit=limit, company_id=company_id, + ) - def list_assets_via_enterprise(self, state=None): - return self._read_fusion('account.asset', state=state) + def list_assets_via_fusion(self, **kwargs): + if 'fusion.asset.engine' not in self.env.registry: + return {'assets': [], 'count': 0, 'total': 0} + Asset = self.env['fusion.asset'].sudo() + domain = [('company_id', '=', kwargs.get('company_id') or self.env.company.id)] + if kwargs.get('state'): + domain.append(('state', '=', kwargs['state'])) + total = Asset.search_count(domain) + assets = Asset.search( + domain, limit=int(kwargs.get('limit', 50)), + order='acquisition_date desc', + ) + return { + 'count': len(assets), 'total': total, + 'assets': [{ + 'id': a.id, 'name': a.name, 'state': a.state, + 'cost': a.cost, 'book_value': a.book_value, + 'method': a.method, + 'category_name': a.category_id.name if a.category_id else None, + } for a in assets], + } - def list_assets_via_community(self, state=None): - # No assets feature in pure Community — return empty list with a hint. - return [] + def list_assets_via_enterprise(self, **kwargs): + return { + 'assets': [], 'count': 0, 'total': 0, + 'error': 'Enterprise account_asset must be queried from Enterprise UI', + } - def _read_fusion(self, model_name, state=None): - """Shared shape between fusion and enterprise (both use account.asset-like API).""" - Model = self.env[model_name].sudo() - domain = [] - if state: - domain.append(('state', '=', state)) - records = Model.search(domain, limit=200) - out = [] - for r in records: - out.append({ - 'id': r.id, - 'name': getattr(r, 'name', None), - 'state': getattr(r, 'state', None), - 'value': getattr(r, 'original_value', None) or getattr(r, 'acquisition_cost', None), - }) - return out + def list_assets_via_community(self, **kwargs): + return { + 'assets': [], 'count': 0, 'total': 0, + 'error': 'No assets engine in pure Community', + } + + # ============================================================ + # suggest_useful_life + # ============================================================ + + def suggest_useful_life(self, description, amount=None, partner_name=None): + return self._dispatch( + 'suggest_useful_life', + description=description, amount=amount, partner_name=partner_name, + ) + + def suggest_useful_life_via_fusion(self, **kwargs): + if 'fusion.asset.engine' not in self.env.registry: + return {'error': 'fusion_accounting_assets not installed'} + from odoo.addons.fusion_accounting_assets.services.useful_life_predictor import ( + predict_useful_life, + ) + return predict_useful_life(self.env, **kwargs) + + def suggest_useful_life_via_enterprise(self, **kwargs): + return {'error': 'AI useful-life suggestion is fusion-only'} + + def suggest_useful_life_via_community(self, **kwargs): + return {'error': 'AI useful-life suggestion is fusion-only'} + + # ============================================================ + # dispose_asset + # ============================================================ + + def dispose_asset(self, asset_id, **kwargs): + return self._dispatch('dispose_asset', asset_id=asset_id, **kwargs) + + def dispose_asset_via_fusion(self, asset_id, **kwargs): + if 'fusion.asset.engine' not in self.env.registry: + return {'error': 'fusion_accounting_assets not installed'} + asset = self.env['fusion.asset'].sudo().browse(int(asset_id)) + return self.env['fusion.asset.engine'].sudo().dispose_asset(asset, **kwargs) + + def dispose_asset_via_enterprise(self, asset_id, **kwargs): + return {'error': 'Enterprise asset disposal must use Enterprise UI'} + + def dispose_asset_via_community(self, asset_id, **kwargs): + return {'error': 'Community has no asset disposal flow'} register_adapter('assets', AssetsAdapter) diff --git a/fusion_accounting_ai/services/tools/__init__.py b/fusion_accounting_ai/services/tools/__init__.py index 17a6e9b2..b2331b03 100644 --- a/fusion_accounting_ai/services/tools/__init__.py +++ b/fusion_accounting_ai/services/tools/__init__.py @@ -10,11 +10,13 @@ from .adp import TOOLS as ADP_TOOLS from .reporting import TOOLS as REPORTING_TOOLS from .audit import TOOLS as AUDIT_TOOLS from .financial_reports import TOOLS as FINANCIAL_REPORTS_TOOLS +from .asset_management import TOOLS as ASSET_MANAGEMENT_TOOLS TOOL_DISPATCH = {} for tools_dict in [ BANK_RECON_TOOLS, HST_TOOLS, AR_TOOLS, AP_TOOLS, JOURNAL_TOOLS, MONTH_END_TOOLS, PAYROLL_TOOLS, INVENTORY_TOOLS, ADP_TOOLS, REPORTING_TOOLS, AUDIT_TOOLS, FINANCIAL_REPORTS_TOOLS, + ASSET_MANAGEMENT_TOOLS, ]: TOOL_DISPATCH.update(tools_dict) diff --git a/fusion_accounting_ai/services/tools/asset_management.py b/fusion_accounting_ai/services/tools/asset_management.py new file mode 100644 index 00000000..8698def4 --- /dev/null +++ b/fusion_accounting_ai/services/tools/asset_management.py @@ -0,0 +1,77 @@ +"""Fusion-engine-routed AI tools for asset management.""" + +import logging + +_logger = logging.getLogger(__name__) + + +def fusion_list_assets(env, params): + if 'fusion.asset.engine' not in env.registry: + return {'error': 'fusion_accounting_assets not installed'} + from ..data_adapters import get_adapter + adapter = get_adapter(env, 'assets') + return adapter.list_assets( + state=params.get('state'), + limit=int(params.get('limit', 50)), + company_id=int(params['company_id']) if params.get('company_id') else env.company.id, + ) + + +def fusion_get_asset_detail(env, params): + if 'fusion.asset.engine' not in env.registry: + return {'error': 'fusion_accounting_assets not installed'} + Asset = env['fusion.asset'] + asset = Asset.browse(int(params['asset_id'])) + if not asset.exists(): + return {'error': 'Asset not found'} + return { + 'asset': { + 'id': asset.id, 'name': asset.name, 'state': asset.state, + 'cost': asset.cost, 'book_value': asset.book_value, + 'total_depreciated': asset.total_depreciated, + 'method': asset.method, 'useful_life_years': asset.useful_life_years, + }, + 'depreciation_count': len(asset.depreciation_line_ids), + } + + +def fusion_compute_asset_schedule(env, params): + if 'fusion.asset.engine' not in env.registry: + return {'error': 'fusion_accounting_assets not installed'} + asset = env['fusion.asset'].browse(int(params['asset_id'])) + return env['fusion.asset.engine'].compute_depreciation_schedule( + asset, recompute=bool(params.get('recompute', False)), + ) + + +def fusion_dispose_asset(env, params): + if 'fusion.asset.engine' not in env.registry: + return {'error': 'fusion_accounting_assets not installed'} + from ..data_adapters import get_adapter + adapter = get_adapter(env, 'assets') + return adapter.dispose_asset( + asset_id=int(params['asset_id']), + sale_amount=float(params.get('sale_amount', 0)), + disposal_type=params.get('disposal_type', 'sale'), + ) + + +def fusion_suggest_asset_useful_life(env, params): + if 'fusion.asset.engine' not in env.registry: + return {'error': 'fusion_accounting_assets not installed'} + from ..data_adapters import get_adapter + adapter = get_adapter(env, 'assets') + return adapter.suggest_useful_life( + description=params.get('description', ''), + amount=float(params['amount']) if params.get('amount') else None, + partner_name=params.get('partner_name'), + ) + + +TOOLS = { + 'fusion_list_assets': fusion_list_assets, + 'fusion_get_asset_detail': fusion_get_asset_detail, + 'fusion_compute_asset_schedule': fusion_compute_asset_schedule, + 'fusion_dispose_asset': fusion_dispose_asset, + 'fusion_suggest_asset_useful_life': fusion_suggest_asset_useful_life, +} diff --git a/fusion_accounting_assets/CLAUDE.md b/fusion_accounting_assets/CLAUDE.md new file mode 100644 index 00000000..e7b109b8 --- /dev/null +++ b/fusion_accounting_assets/CLAUDE.md @@ -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 diff --git a/fusion_accounting_assets/README.md b/fusion_accounting_assets/README.md new file mode 100644 index 00000000..2815180b --- /dev/null +++ b/fusion_accounting_assets/README.md @@ -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 diff --git a/fusion_accounting_assets/UPGRADE_NOTES.md b/fusion_accounting_assets/UPGRADE_NOTES.md new file mode 100644 index 00000000..916420b1 --- /dev/null +++ b/fusion_accounting_assets/UPGRADE_NOTES.md @@ -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. diff --git a/fusion_accounting_assets/__init__.py b/fusion_accounting_assets/__init__.py new file mode 100644 index 00000000..9898e1c8 --- /dev/null +++ b/fusion_accounting_assets/__init__.py @@ -0,0 +1,5 @@ +from . import models +from . import services +from . import controllers +from . import wizards +from . import reports diff --git a/fusion_accounting_assets/__manifest__.py b/fusion_accounting_assets/__manifest__.py new file mode 100644 index 00000000..20bc21a1 --- /dev/null +++ b/fusion_accounting_assets/__manifest__.py @@ -0,0 +1,76 @@ +{ + 'name': 'Fusion Accounting Assets', + 'version': '19.0.1.0.36', + 'category': 'Accounting/Accounting', + 'summary': 'AI-augmented asset management with depreciation schedules.', + 'description': """ +Fusion Accounting Assets +======================== + +A Fusion-native replacement for Odoo Enterprise's account_asset module. + +CORE scope (Phase 3): +- 3 depreciation methods: straight-line, declining balance, units of production +- Asset lifecycle: draft -> running -> paused -> disposed +- Depreciation board with editable schedule +- Disposal (sale, scrap, donation) + partial sale wizards +- Daily cron for posting periodic depreciation + +AI augmentation: +- Anomaly detection on utilization vs expected +- AI-suggested useful life from invoice context (LLM) + +Coexists with Enterprise: when account_asset is installed, the Fusion +menu hides; the engine + AI tools remain available for the chat. +""", + 'author': 'Fusion Accounting', + 'license': 'LGPL-3', + 'depends': [ + 'fusion_accounting_core', + 'fusion_accounting_ai', + '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, + 'auto_install': False, + 'application': False, + 'icon': '/fusion_accounting_assets/static/description/icon.png', +} diff --git a/fusion_accounting_assets/controllers/__init__.py b/fusion_accounting_assets/controllers/__init__.py new file mode 100644 index 00000000..c671cd6b --- /dev/null +++ b/fusion_accounting_assets/controllers/__init__.py @@ -0,0 +1 @@ +from . import assets_controller diff --git a/fusion_accounting_assets/controllers/assets_controller.py b/fusion_accounting_assets/controllers/assets_controller.py new file mode 100644 index 00000000..5076f1e5 --- /dev/null +++ b/fusion_accounting_assets/controllers/assets_controller.py @@ -0,0 +1,175 @@ +"""HTTP controller: 8 JSON-RPC endpoints for the OWL asset dashboard. + +All endpoints route through fusion.asset.engine. V19 type='jsonrpc'. +""" + +import logging +from datetime import date, datetime + +from odoo import _, http +from odoo.exceptions import ValidationError +from odoo.http import request + +_logger = logging.getLogger(__name__) + + +def _parse_date(value): + if isinstance(value, date): + return value + if not value: + return None + return datetime.strptime(value, '%Y-%m-%d').date() + + +class FusionAssetsController(http.Controller): + + @http.route('/fusion/assets/list', type='jsonrpc', auth='user') + def list_assets(self, state=None, category_id=None, limit=50, offset=0, + company_id=None): + company_id = int(company_id) if company_id else request.env.company.id + Asset = request.env['fusion.asset'].sudo() + domain = [('company_id', '=', company_id)] + if state: + domain.append(('state', '=', state)) + if category_id: + domain.append(('category_id', '=', int(category_id))) + total = Asset.search_count(domain) + assets = Asset.search(domain, limit=int(limit), offset=int(offset), + order='acquisition_date desc') + return { + 'count': len(assets), + 'total': total, + 'assets': [{ + 'id': a.id, 'name': a.name, 'code': a.code or '', + 'state': a.state, 'cost': a.cost, 'salvage_value': a.salvage_value, + 'book_value': a.book_value, 'total_depreciated': a.total_depreciated, + 'method': a.method, 'useful_life_years': a.useful_life_years, + 'acquisition_date': str(a.acquisition_date), + 'in_service_date': str(a.in_service_date) if a.in_service_date else None, + 'category_id': a.category_id.id if a.category_id else None, + 'category_name': a.category_id.name if a.category_id else None, + 'currency_code': a.currency_id.name, + } for a in assets], + } + + @http.route('/fusion/assets/get_detail', type='jsonrpc', auth='user') + def get_detail(self, asset_id): + asset = request.env['fusion.asset'].browse(int(asset_id)) + if not asset.exists(): + raise ValidationError(_("Asset %s not found") % asset_id) + return { + 'asset': { + 'id': asset.id, 'name': asset.name, 'code': asset.code or '', + 'state': asset.state, 'cost': asset.cost, + 'salvage_value': asset.salvage_value, + 'book_value': asset.book_value, + 'total_depreciated': asset.total_depreciated, + 'method': asset.method, + 'useful_life_years': asset.useful_life_years, + 'declining_rate_pct': asset.declining_rate_pct, + 'total_units_expected': asset.total_units_expected, + 'units_used_to_date': asset.units_used_to_date, + 'prorate_convention': asset.prorate_convention, + 'acquisition_date': str(asset.acquisition_date), + 'in_service_date': str(asset.in_service_date) if asset.in_service_date else None, + 'disposed_date': str(asset.disposed_date) if asset.disposed_date else None, + 'category_id': asset.category_id.id if asset.category_id else None, + 'category_name': asset.category_id.name if asset.category_id else None, + 'currency_id': asset.currency_id.id, + 'currency_code': asset.currency_id.name, + }, + 'depreciation_lines': [{ + 'id': l.id, 'period_index': l.period_index, + 'scheduled_date': str(l.scheduled_date), + 'amount': l.amount, 'accumulated': l.accumulated, + 'book_value_at_end': l.book_value_at_end, + 'is_posted': l.is_posted, + 'posted_date': str(l.posted_date) if l.posted_date else None, + } for l in asset.depreciation_line_ids.sorted('period_index')], + 'anomalies': [{ + 'id': a.id, 'anomaly_type': a.anomaly_type, + 'severity': a.severity, 'detail': a.detail or '', + 'state': a.state, + } for a in request.env['fusion.asset.anomaly'].search([ + ('asset_id', '=', asset.id), ('state', 'in', ('new', 'acknowledged')) + ])], + } + + @http.route('/fusion/assets/compute_schedule', type='jsonrpc', auth='user') + def compute_schedule(self, asset_id, recompute=False): + asset = request.env['fusion.asset'].sudo().browse(int(asset_id)) + engine = request.env['fusion.asset.engine'].sudo() + return engine.compute_depreciation_schedule(asset, recompute=bool(recompute)) + + @http.route('/fusion/assets/post_depreciation', type='jsonrpc', auth='user') + def post_depreciation(self, asset_id, period_date=None): + asset = request.env['fusion.asset'].sudo().browse(int(asset_id)) + engine = request.env['fusion.asset.engine'].sudo() + return engine.post_depreciation_entry(asset, period_date=_parse_date(period_date)) + + @http.route('/fusion/assets/dispose', type='jsonrpc', auth='user') + def dispose(self, asset_id, sale_amount=0, sale_date=None, + sale_partner_id=None, disposal_type='sale'): + asset = request.env['fusion.asset'].sudo().browse(int(asset_id)) + engine = request.env['fusion.asset.engine'].sudo() + partner = None + if sale_partner_id: + partner = request.env['res.partner'].sudo().browse(int(sale_partner_id)) + return engine.dispose_asset( + asset, sale_amount=float(sale_amount), + sale_date=_parse_date(sale_date), + sale_partner=partner, disposal_type=disposal_type, + ) + + @http.route('/fusion/assets/get_anomalies', type='jsonrpc', auth='user') + def get_anomalies(self, asset_id=None, severity=None, state='new', limit=50, + company_id=None): + company_id = int(company_id) if company_id else request.env.company.id + Anomaly = request.env['fusion.asset.anomaly'].sudo() + domain = [('company_id', '=', company_id)] + if asset_id: + domain.append(('asset_id', '=', int(asset_id))) + if severity: + domain.append(('severity', '=', severity)) + if state: + domain.append(('state', '=', state)) + anomalies = Anomaly.search(domain, limit=int(limit), order='detected_at desc') + return { + 'count': len(anomalies), + 'anomalies': [{ + 'id': a.id, 'asset_id': a.asset_id.id, 'asset_name': a.asset_id.name, + 'anomaly_type': a.anomaly_type, 'severity': a.severity, + 'expected': a.expected, 'actual': a.actual, + 'variance_pct': a.variance_pct, 'detail': a.detail or '', + 'state': a.state, + 'detected_at': str(a.detected_at), + } for a in anomalies], + } + + @http.route('/fusion/assets/suggest_useful_life', type='jsonrpc', auth='user') + def suggest_useful_life(self, description, amount=None, partner_name=None): + from odoo.addons.fusion_accounting_assets.services.useful_life_predictor import ( + predict_useful_life, + ) + return predict_useful_life( + request.env, description=description, + amount=float(amount) if amount is not None else None, + partner_name=partner_name, + ) + + @http.route('/fusion/assets/get_partner_history', type='jsonrpc', auth='user') + def get_partner_history(self, partner_id, limit=20): + Asset = request.env['fusion.asset'].sudo() + assets = Asset.search([ + ('source_invoice_line_id.partner_id', '=', int(partner_id)), + ], limit=int(limit), order='acquisition_date desc') + return { + 'partner_id': int(partner_id), + 'count': len(assets), + 'assets': [{ + 'id': a.id, 'name': a.name, + 'cost': a.cost, 'book_value': a.book_value, + 'state': a.state, + 'acquisition_date': str(a.acquisition_date), + } for a in assets], + } diff --git a/fusion_accounting_assets/data/cron.xml b/fusion_accounting_assets/data/cron.xml new file mode 100644 index 00000000..6687d958 --- /dev/null +++ b/fusion_accounting_assets/data/cron.xml @@ -0,0 +1,34 @@ + + + + + Fusion Assets — Post Due Depreciation + + code + model._cron_post_due_depreciation() + 1 + days + + + + + Fusion Assets — Refresh Book Values MV + + code + model._cron_refresh_book_values_mv() + 1 + hours + + + + + Fusion Assets — Monthly Anomaly Scan + + code + model._cron_anomaly_scan() + 30 + days + + + + diff --git a/fusion_accounting_assets/data/sql/create_mv_asset_book_values.sql b/fusion_accounting_assets/data/sql/create_mv_asset_book_values.sql new file mode 100644 index 00000000..0f70db3d --- /dev/null +++ b/fusion_accounting_assets/data/sql/create_mv_asset_book_values.sql @@ -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; diff --git a/fusion_accounting_assets/models/__init__.py b/fusion_accounting_assets/models/__init__.py new file mode 100644 index 00000000..600dfe1b --- /dev/null +++ b/fusion_accounting_assets/models/__init__.py @@ -0,0 +1,10 @@ +from . import fusion_asset_category +from . import fusion_asset +from . import fusion_asset_depreciation_line +from . import fusion_asset_disposal +from . import fusion_asset_anomaly +from . import account_move +from . import fusion_asset_engine +from . import fusion_assets_cron +from . import fusion_asset_book_values_mv +from . import fusion_migration_wizard diff --git a/fusion_accounting_assets/models/account_move.py b/fusion_accounting_assets/models/account_move.py new file mode 100644 index 00000000..afd87b8a --- /dev/null +++ b/fusion_accounting_assets/models/account_move.py @@ -0,0 +1,34 @@ +"""Inherit account.move.line to link to fusion.asset records. + +Lets us trace assets back to their source invoice line. +""" + +from odoo import fields, models + + +class AccountMoveLine(models.Model): + _inherit = "account.move.line" + + fusion_asset_id = fields.Many2one( + 'fusion.asset', string='Created Asset', + copy=False, ondelete='set null', + help="Fusion asset record created from this invoice line.", + ) + + fusion_asset_count = fields.Integer(compute='_compute_fusion_asset_count') + + def _compute_fusion_asset_count(self): + for line in self: + line.fusion_asset_count = 1 if line.fusion_asset_id else 0 + + def action_open_fusion_asset(self): + self.ensure_one() + if not self.fusion_asset_id: + return + return { + 'type': 'ir.actions.act_window', + 'res_model': 'fusion.asset', + 'res_id': self.fusion_asset_id.id, + 'view_mode': 'form', + 'target': 'current', + } diff --git a/fusion_accounting_assets/models/fusion_asset.py b/fusion_accounting_assets/models/fusion_asset.py new file mode 100644 index 00000000..63d8c2a7 --- /dev/null +++ b/fusion_accounting_assets/models/fusion_asset.py @@ -0,0 +1,164 @@ +"""Fusion Asset model. + +Lifecycle: draft -> running -> (paused -> running)* -> disposed. +- draft: created, not yet running depreciation +- running: depreciation board active, periodic posts happen +- paused: depreciation suspended (e.g. asset out for repair) +- disposed: sold/scrapped/donated; no further depreciation +""" + +import logging + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + +_logger = logging.getLogger(__name__) + + +METHOD_SELECTION = [ + ('straight_line', 'Straight Line'), + ('declining_balance', 'Declining Balance'), + ('units_of_production', 'Units of Production'), +] + +PRORATE_SELECTION = [ + ('full_month', 'Full Month'), + ('days_365', 'Days / 365'), + ('days_period', 'Days in Period'), +] + +STATE_SELECTION = [ + ('draft', 'Draft'), + ('running', 'Running'), + ('paused', 'Paused'), + ('disposed', 'Disposed'), +] + + +class FusionAsset(models.Model): + _name = "fusion.asset" + _description = "Fusion Fixed Asset" + _order = "acquisition_date desc, id desc" + _inherit = ['mail.thread', 'mail.activity.mixin'] + + name = fields.Char(required=True, tracking=True) + code = fields.Char(help="Internal asset code (e.g. tag number).") + company_id = fields.Many2one( + 'res.company', required=True, + default=lambda self: self.env.company, + ) + category_id = fields.Many2one('fusion.asset.category', tracking=True) + state = fields.Selection( + STATE_SELECTION, default='draft', required=True, tracking=True, + ) + + cost = fields.Monetary( + required=True, tracking=True, + help="Original acquisition cost.", + ) + salvage_value = fields.Monetary( + default=0.0, tracking=True, + help="Estimated end-of-life value.", + ) + acquisition_date = fields.Date( + required=True, default=fields.Date.today, tracking=True, + ) + in_service_date = fields.Date( + tracking=True, + help="Date depreciation actually begins.", + ) + disposed_date = fields.Date(readonly=True, tracking=True) + currency_id = fields.Many2one( + 'res.currency', required=True, + default=lambda self: self.env.company.currency_id, + ) + + method = fields.Selection( + METHOD_SELECTION, required=True, default='straight_line', tracking=True, + ) + useful_life_years = fields.Integer( + default=5, tracking=True, + help="For straight_line / declining_balance.", + ) + declining_rate_pct = fields.Float( + default=20.0, + help="For declining_balance method, e.g. 20.0 = 20%/year.", + ) + total_units_expected = fields.Float( + help="For units_of_production method.", + ) + units_used_to_date = fields.Float( + default=0.0, + help="For units_of_production: track usage.", + ) + prorate_convention = fields.Selection( + PRORATE_SELECTION, default='days_period', required=True, + ) + + source_invoice_line_id = fields.Many2one( + 'account.move.line', string='Source Invoice Line', + help="The invoice line that originated this asset.", + ) + parent_id = fields.Many2one( + 'fusion.asset', help='For partial-sale child assets.', + ) + + depreciation_line_ids = fields.One2many( + 'fusion.asset.depreciation.line', 'asset_id', + string='Depreciation Lines', + ) + book_value = fields.Monetary(compute='_compute_book_value', store=True) + total_depreciated = fields.Monetary(compute='_compute_book_value', store=True) + last_posted_date = fields.Date(compute='_compute_last_posted_date', store=True) + + @api.depends('cost', 'depreciation_line_ids.amount', 'depreciation_line_ids.is_posted') + def _compute_book_value(self): + for asset in self: + posted = sum(l.amount for l in asset.depreciation_line_ids if l.is_posted) + asset.total_depreciated = posted + asset.book_value = asset.cost - posted + + @api.depends('depreciation_line_ids.is_posted', 'depreciation_line_ids.scheduled_date') + def _compute_last_posted_date(self): + for asset in self: + posted_dates = [ + l.scheduled_date for l in asset.depreciation_line_ids if l.is_posted + ] + asset.last_posted_date = max(posted_dates) if posted_dates else False + + def action_set_running(self): + for asset in self: + if asset.state != 'draft': + raise ValidationError(_("Only draft assets can be set running.")) + if not asset.in_service_date: + asset.in_service_date = fields.Date.today() + asset.state = 'running' + + def action_pause(self): + for asset in self: + if asset.state != 'running': + raise ValidationError(_("Only running assets can be paused.")) + asset.state = 'paused' + + def action_resume(self): + for asset in self: + if asset.state != 'paused': + raise ValidationError(_("Only paused assets can be resumed.")) + asset.state = 'running' + + def action_set_draft(self): + for asset in self: + if asset.state not in ('draft', 'paused'): + raise ValidationError( + _("Cannot reset to draft from %s.") % asset.state, + ) + asset.state = 'draft' + + _check_cost_positive = models.Constraint( + 'CHECK(cost >= 0)', + 'Asset cost must be non-negative.', + ) + _check_salvage_lte_cost = models.Constraint( + 'CHECK(salvage_value >= 0 AND salvage_value <= cost)', + 'Salvage value must be between 0 and cost.', + ) diff --git a/fusion_accounting_assets/models/fusion_asset_anomaly.py b/fusion_accounting_assets/models/fusion_asset_anomaly.py new file mode 100644 index 00000000..693ee6b9 --- /dev/null +++ b/fusion_accounting_assets/models/fusion_asset_anomaly.py @@ -0,0 +1,42 @@ +"""Persisted asset anomaly flags from the engine's variance detection.""" + +from odoo import fields, models + + +SEVERITY = [('low', 'Low'), ('medium', 'Medium'), ('high', 'High')] +ANOMALY_TYPES = [ + ('behind_schedule', 'Behind Schedule'), + ('ahead_of_schedule', 'Ahead of Schedule'), + ('low_utilization', 'Low Utilization'), +] + + +class FusionAssetAnomaly(models.Model): + _name = "fusion.asset.anomaly" + _description = "Flagged Asset Anomaly" + _order = "detected_at desc, severity desc" + + asset_id = fields.Many2one('fusion.asset', required=True, ondelete='cascade') + company_id = fields.Many2one(related='asset_id.company_id', store=True) + anomaly_type = fields.Selection(ANOMALY_TYPES, required=True) + severity = fields.Selection(SEVERITY, required=True) + expected = fields.Float() + actual = fields.Float() + variance_pct = fields.Float() + detail = fields.Text() + detected_at = fields.Datetime(default=fields.Datetime.now, required=True) + state = fields.Selection([ + ('new', 'New'), + ('acknowledged', 'Acknowledged'), + ('resolved', 'Resolved'), + ('dismissed', 'Dismissed'), + ], default='new', required=True) + + def action_acknowledge(self): + self.write({'state': 'acknowledged'}) + + def action_dismiss(self): + self.write({'state': 'dismissed'}) + + def action_resolve(self): + self.write({'state': 'resolved'}) diff --git a/fusion_accounting_assets/models/fusion_asset_book_values_mv.py b/fusion_accounting_assets/models/fusion_asset_book_values_mv.py new file mode 100644 index 00000000..8471ff38 --- /dev/null +++ b/fusion_accounting_assets/models/fusion_asset_book_values_mv.py @@ -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" + ) diff --git a/fusion_accounting_assets/models/fusion_asset_category.py b/fusion_accounting_assets/models/fusion_asset_category.py new file mode 100644 index 00000000..1f3ad7c1 --- /dev/null +++ b/fusion_accounting_assets/models/fusion_asset_category.py @@ -0,0 +1,53 @@ +"""Asset categories with default settings (used as templates).""" + +from odoo import api, fields, models + + +class FusionAssetCategory(models.Model): + _name = "fusion.asset.category" + _description = "Fusion Asset Category" + _order = "sequence, name" + + name = fields.Char(required=True, translate=True) + sequence = fields.Integer(default=10) + company_id = fields.Many2one( + 'res.company', default=lambda self: self.env.company, + ) + + method = fields.Selection([ + ('straight_line', 'Straight Line'), + ('declining_balance', 'Declining Balance'), + ('units_of_production', 'Units of Production'), + ], default='straight_line', required=True) + useful_life_years = fields.Integer(default=5) + declining_rate_pct = fields.Float(default=20.0) + salvage_value_pct = fields.Float( + default=0.0, + help="% of cost (used for new assets in this category).", + ) + prorate_convention = fields.Selection([ + ('full_month', 'Full Month'), + ('days_365', 'Days / 365'), + ('days_period', 'Days in Period'), + ], default='days_period', required=True) + + asset_account_id = fields.Many2one( + 'account.account', string='Asset Account', + domain="[('account_type', 'in', ('asset_fixed', 'asset_non_current'))]", + ) + depreciation_account_id = fields.Many2one( + 'account.account', string='Depreciation Account', + domain="[('account_type', '=', 'asset_fixed')]", + ) + expense_account_id = fields.Many2one( + 'account.account', string='Expense Account', + domain="[('account_type', '=', 'expense_depreciation')]", + ) + + asset_count = fields.Integer(compute='_compute_asset_count') + + def _compute_asset_count(self): + for cat in self: + cat.asset_count = self.env['fusion.asset'].search_count([ + ('category_id', '=', cat.id), + ]) diff --git a/fusion_accounting_assets/models/fusion_asset_depreciation_line.py b/fusion_accounting_assets/models/fusion_asset_depreciation_line.py new file mode 100644 index 00000000..e8fd0166 --- /dev/null +++ b/fusion_accounting_assets/models/fusion_asset_depreciation_line.py @@ -0,0 +1,42 @@ +"""Per-period depreciation board lines for an asset.""" + +from odoo import fields, models + + +class FusionAssetDepreciationLine(models.Model): + _name = "fusion.asset.depreciation.line" + _description = "Asset Depreciation Board Line" + _order = "asset_id, scheduled_date" + + asset_id = fields.Many2one('fusion.asset', required=True, ondelete='cascade') + company_id = fields.Many2one(related='asset_id.company_id', store=True) + currency_id = fields.Many2one(related='asset_id.currency_id', store=True) + + period_index = fields.Integer(required=True) + scheduled_date = fields.Date(required=True) + amount = fields.Monetary(required=True) + accumulated = fields.Monetary() + book_value_at_end = fields.Monetary() + + is_posted = fields.Boolean(default=False, copy=False) + posted_date = fields.Date(copy=False) + move_id = fields.Many2one( + 'account.move', copy=False, + help="Journal entry created when this line was posted.", + ) + + def action_post(self): + """Mark this line as posted (without creating the journal entry yet — + engine method post_depreciation_entry handles the actual entry creation).""" + for line in self: + if line.is_posted: + continue + line.write({ + 'is_posted': True, + 'posted_date': fields.Date.today(), + }) + + _unique_period_per_asset = models.Constraint( + 'UNIQUE(asset_id, period_index)', + 'A depreciation line for that period already exists.', + ) diff --git a/fusion_accounting_assets/models/fusion_asset_disposal.py b/fusion_accounting_assets/models/fusion_asset_disposal.py new file mode 100644 index 00000000..089d0098 --- /dev/null +++ b/fusion_accounting_assets/models/fusion_asset_disposal.py @@ -0,0 +1,56 @@ +"""Asset disposal records (sale, scrap, donation).""" + +from odoo import api, fields, models + + +DISPOSAL_TYPES = [ + ('sale', 'Sale'), + ('scrap', 'Scrap'), + ('donation', 'Donation'), + ('lost', 'Lost / Stolen'), +] + + +class FusionAssetDisposal(models.Model): + _name = "fusion.asset.disposal" + _description = "Asset Disposal Record" + _order = "disposal_date desc, id desc" + _inherit = ['mail.thread'] + + asset_id = fields.Many2one( + 'fusion.asset', required=True, ondelete='restrict', tracking=True, + ) + company_id = fields.Many2one(related='asset_id.company_id', store=True) + currency_id = fields.Many2one(related='asset_id.currency_id', store=True) + + disposal_type = fields.Selection( + DISPOSAL_TYPES, required=True, default='sale', tracking=True, + ) + disposal_date = fields.Date( + required=True, default=fields.Date.today, tracking=True, + ) + sale_amount = fields.Monetary( + default=0.0, tracking=True, + help="Cash received (for sale disposal type).", + ) + sale_partner_id = fields.Many2one('res.partner', tracking=True) + + book_value_at_disposal = fields.Monetary( + readonly=True, + help="Asset book value at disposal date.", + ) + gain_loss_amount = fields.Monetary(compute='_compute_gain_loss', store=True) + notes = fields.Text() + + move_id = fields.Many2one( + 'account.move', readonly=True, copy=False, + help="Journal entry created for this disposal.", + ) + + @api.depends('sale_amount', 'book_value_at_disposal', 'disposal_type') + def _compute_gain_loss(self): + for d in self: + if d.disposal_type == 'sale': + d.gain_loss_amount = d.sale_amount - d.book_value_at_disposal + else: + d.gain_loss_amount = -d.book_value_at_disposal diff --git a/fusion_accounting_assets/models/fusion_asset_engine.py b/fusion_accounting_assets/models/fusion_asset_engine.py new file mode 100644 index 00000000..dc5fce67 --- /dev/null +++ b/fusion_accounting_assets/models/fusion_asset_engine.py @@ -0,0 +1,398 @@ +"""The asset engine — orchestrator for all asset depreciation + lifecycle. + +7-method public API. No direct ORM writes to fusion.asset.depreciation.line +or account.move from anywhere else; everything routes through here for +consistent validation, audit, and side-effect handling. +""" + +import logging +from datetime import date, timedelta + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + +from ..services.depreciation_methods import ( + straight_line, + declining_balance, + units_of_production, +) + +_logger = logging.getLogger(__name__) + + +class FusionAssetEngine(models.AbstractModel): + _name = "fusion.asset.engine" + _description = "Fusion Asset Engine" + + # ============================================================ + # PUBLIC API (7 methods) + # ============================================================ + + @api.model + def compute_depreciation_schedule(self, asset, *, recompute: bool = False) -> dict: + """Compute (or re-compute) the depreciation board for an asset. + + If recompute=False and posted lines exist, ONLY un-posted future lines + are regenerated. If recompute=True, all unposted lines are wiped and + regenerated from scratch using current asset config. + """ + if not asset: + raise ValidationError(_("asset is required")) + asset.ensure_one() + + self._validate_asset_for_schedule(asset) + + Line = self.env['fusion.asset.depreciation.line'].sudo() + if recompute: + Line.search([ + ('asset_id', '=', asset.id), + ('is_posted', '=', False), + ]).unlink() + + existing_posted = Line.search([ + ('asset_id', '=', asset.id), + ('is_posted', '=', True), + ], order='period_index') + start_period = max([l.period_index for l in existing_posted], default=-1) + 1 + accumulated_so_far = sum(l.amount for l in existing_posted) + + steps = self._compute_steps(asset) + new_steps = steps[start_period:] + + base_date = asset.in_service_date or asset.acquisition_date + + # Accumulated baseline at the boundary between posted and to-be-created + # lines: subtract the accumulated value the algorithm itself reports at + # that boundary, then re-add the actually-posted total. This keeps the + # board's accumulated column monotonic when picking up mid-life. + baseline_offset = 0.0 + if start_period > 0 and start_period <= len(steps): + baseline_offset = steps[start_period - 1].accumulated_depreciation + + line_vals = [] + for s in new_steps: + scheduled_date = self._add_periods(base_date, s.period_index) + running_accumulated = round( + accumulated_so_far + s.accumulated_depreciation - baseline_offset, 2 + ) + line_vals.append({ + 'asset_id': asset.id, + 'period_index': s.period_index, + 'scheduled_date': scheduled_date, + 'amount': s.period_amount, + 'accumulated': running_accumulated, + 'book_value_at_end': s.book_value_at_end, + 'is_posted': False, + }) + if line_vals: + Line.create(line_vals) + + return { + 'asset_id': asset.id, + 'lines_created': len(line_vals), + 'total_lines': len(asset.depreciation_line_ids), + 'method': asset.method, + } + + @api.model + def post_depreciation_entry(self, asset, *, period_date: date = None) -> dict: + """Post the next-due un-posted depreciation line. + + If period_date provided, post all lines whose scheduled_date <= period_date. + Otherwise, post the single next un-posted line (the earliest one). + """ + asset.ensure_one() + if asset.state != 'running': + raise ValidationError( + _("Cannot post depreciation for asset in state %s") % asset.state + ) + + Line = self.env['fusion.asset.depreciation.line'].sudo() + domain = [('asset_id', '=', asset.id), ('is_posted', '=', False)] + if period_date: + domain.append(('scheduled_date', '<=', period_date)) + unposted = Line.search(domain, order='scheduled_date, period_index') + if not unposted: + return {'posted_count': 0, 'reason': 'no unposted lines due'} + + if not period_date: + unposted = unposted[:1] + + posted_ids = [] + for line in unposted: + self._create_journal_entry(asset, line) + line.action_post() + posted_ids.append(line.id) + + return {'posted_count': len(posted_ids), 'posted_line_ids': posted_ids} + + @api.model + def dispose_asset(self, asset, *, sale_amount: float = 0.0, + sale_date: date = None, sale_partner=None, + disposal_type: str = 'sale') -> dict: + """Dispose an asset (sale, scrap, donation, lost).""" + asset.ensure_one() + if asset.state == 'disposed': + raise ValidationError(_("Asset already disposed.")) + sale_date = sale_date or fields.Date.today() + + Line = self.env['fusion.asset.depreciation.line'].sudo() + future_unposted = Line.search([ + ('asset_id', '=', asset.id), + ('is_posted', '=', False), + ('scheduled_date', '>', sale_date), + ]) + future_unposted.unlink() + + asset.invalidate_recordset(['book_value', 'total_depreciated']) + book_value = asset.book_value + + Disposal = self.env['fusion.asset.disposal'].sudo() + partner_id = False + if sale_partner: + partner_id = sale_partner.id if hasattr(sale_partner, 'id') else sale_partner + disposal = Disposal.create({ + 'asset_id': asset.id, + 'disposal_type': disposal_type, + 'disposal_date': sale_date, + 'sale_amount': sale_amount, + 'sale_partner_id': partner_id, + 'book_value_at_disposal': book_value, + }) + + asset.write({ + 'state': 'disposed', + 'disposed_date': sale_date, + }) + + return { + 'asset_id': asset.id, + 'disposal_id': disposal.id, + 'gain_loss_amount': disposal.gain_loss_amount, + 'book_value_at_disposal': book_value, + } + + @api.model + def partial_sale(self, asset, *, sold_amount: float, sold_qty: float = None, + sale_date: date = None, sale_partner=None) -> dict: + """Partially dispose: split asset into two — sold child + remaining parent. + + sold_amount is cash received for the sold portion. + sold_qty is the ratio of original cost to attribute to the sold portion (0..1). + If sold_qty is None, defaults to sold_amount / cost. + """ + asset.ensure_one() + if asset.state == 'disposed': + raise ValidationError(_("Cannot partially sell a disposed asset.")) + if sold_qty is None: + sold_qty = sold_amount / asset.cost if asset.cost else 0 + if not (0 < sold_qty < 1): + raise ValidationError( + _("sold_qty must be strictly between 0 and 1; got %s") % sold_qty + ) + + sale_date = sale_date or fields.Date.today() + + Asset = self.env['fusion.asset'].sudo() + sold_cost = round(asset.cost * sold_qty, 2) + sold_salvage = round(asset.salvage_value * sold_qty, 2) + child_vals = { + 'name': f"{asset.name} (sold portion)", + 'parent_id': asset.id, + 'cost': sold_cost, + 'salvage_value': sold_salvage, + 'acquisition_date': asset.acquisition_date, + 'in_service_date': asset.in_service_date, + 'method': asset.method, + 'useful_life_years': asset.useful_life_years, + 'declining_rate_pct': asset.declining_rate_pct, + 'prorate_convention': asset.prorate_convention, + 'company_id': asset.company_id.id, + 'state': 'running', + } + if asset.category_id: + child_vals['category_id'] = asset.category_id.id + child = Asset.create(child_vals) + + new_cost = round(asset.cost - sold_cost, 2) + new_salvage = round(asset.salvage_value - sold_salvage, 2) + asset.write({ + 'cost': new_cost, + 'salvage_value': new_salvage, + }) + self.compute_depreciation_schedule(asset, recompute=True) + + result = self.dispose_asset( + child, sale_amount=sold_amount, sale_date=sale_date, + sale_partner=sale_partner, disposal_type='sale', + ) + return { + 'parent_asset_id': asset.id, + 'child_asset_id': child.id, + 'disposal_id': result['disposal_id'], + 'gain_loss_amount': result['gain_loss_amount'], + } + + @api.model + def pause_asset(self, asset, pause_date: date = None) -> dict: + """Pause depreciation. Wraps asset.action_pause for API symmetry and + to log the pause date for downstream auditing.""" + asset.ensure_one() + asset.action_pause() + return { + 'asset_id': asset.id, + 'pause_date': pause_date or fields.Date.today(), + 'state': 'paused', + } + + @api.model + def resume_asset(self, asset, resume_date: date = None) -> dict: + """Resume a paused asset.""" + asset.ensure_one() + asset.action_resume() + return { + 'asset_id': asset.id, + 'resume_date': resume_date or fields.Date.today(), + 'state': 'running', + } + + @api.model + def reverse_disposal(self, asset) -> dict: + """Reverse a disposal (rare — recovery from accidental sale entry).""" + asset.ensure_one() + if asset.state != 'disposed': + raise ValidationError(_("Asset is not disposed.")) + + Disposal = self.env['fusion.asset.disposal'].sudo() + last_disposal = Disposal.search( + [('asset_id', '=', asset.id)], + order='disposal_date desc, id desc', limit=1, + ) + if last_disposal and last_disposal.move_id: + try: + last_disposal.move_id.button_cancel() + except Exception as e: # noqa: BLE001 + _logger.warning("Could not cancel disposal move: %s", e) + if last_disposal: + last_disposal.unlink() + asset.write({'state': 'running', 'disposed_date': False}) + return {'asset_id': asset.id, 'state': 'running'} + + # ============================================================ + # PRIVATE HELPERS + # ============================================================ + + def _validate_asset_for_schedule(self, asset): + if asset.cost <= 0: + raise ValidationError(_("Asset cost must be > 0 to compute schedule.")) + if asset.method == 'units_of_production' and not asset.total_units_expected: + raise ValidationError(_( + "Units of Production assets need total_units_expected set." + )) + if asset.method in ('straight_line', 'declining_balance'): + if asset.useful_life_years < 1: + raise ValidationError(_("useful_life_years must be >= 1.")) + if asset.salvage_value > asset.cost: + raise ValidationError(_("Salvage value cannot exceed cost.")) + + def _compute_steps(self, asset) -> list: + """Dispatch to the appropriate depreciation method service.""" + if asset.method == 'straight_line': + return straight_line( + cost=asset.cost, + salvage_value=asset.salvage_value, + n_periods=asset.useful_life_years, + ) + if asset.method == 'declining_balance': + return declining_balance( + cost=asset.cost, + salvage_value=asset.salvage_value, + n_periods=asset.useful_life_years, + rate=asset.declining_rate_pct / 100.0, + ) + if asset.method == 'units_of_production': + # Phase 3 simple: assume even per-period units. Phase 3.5 can read + # from a per-period usage table populated by maintenance/IoT data. + if asset.useful_life_years: + per_period = asset.total_units_expected / asset.useful_life_years + periods = asset.useful_life_years + else: + per_period = asset.total_units_expected + periods = 1 + return units_of_production( + cost=asset.cost, + salvage_value=asset.salvage_value, + total_units_expected=asset.total_units_expected, + units_per_period=[per_period] * periods, + ) + return [] + + def _add_periods(self, base_date: date, n_periods: int) -> date: + """Add (n_periods + 1) yearly increments to base_date and step back one + day, giving the period-end date. + + Phase 3.5 can split this into monthly/quarterly variants when the asset + carries a sub-annual frequency. + """ + try: + return base_date.replace(year=base_date.year + n_periods + 1) - timedelta(days=1) + except ValueError: + return base_date.replace( + year=base_date.year + n_periods + 1, day=28, + ) - timedelta(days=1) + + def _create_journal_entry(self, asset, line): + """Create the journal entry for a depreciation line. + + Phase 3 keeps this minimal: requires the category to have both + depreciation_account_id and expense_account_id wired up. Without that, + the line is still posted (is_posted flag) but no move is created. + Phase 3.5 will add multi-currency, allocation rules, and analytic tags. + """ + category = asset.category_id + if not category or not (category.depreciation_account_id and category.expense_account_id): + _logger.debug( + "No accounts on category for asset %s; skipping journal entry", + asset.id, + ) + return None + Move = self.env['account.move'].sudo() + journal = self.env['account.journal'].search([ + ('type', '=', 'general'), + ('company_id', '=', asset.company_id.id), + ], limit=1) + if not journal: + _logger.warning( + "No general journal for company %s; skipping move creation", + asset.company_id.name, + ) + return None + try: + move = Move.create({ + 'date': line.scheduled_date, + 'journal_id': journal.id, + 'ref': f"Depreciation: {asset.name} (P{line.period_index + 1})", + 'line_ids': [ + (0, 0, { + 'name': f"Depreciation expense - {asset.name}", + 'account_id': category.expense_account_id.id, + 'debit': line.amount, + 'credit': 0, + }), + (0, 0, { + 'name': f"Accumulated depreciation - {asset.name}", + 'account_id': category.depreciation_account_id.id, + 'debit': 0, + 'credit': line.amount, + }), + ], + }) + move.action_post() + line.write({'move_id': move.id}) + return move + except Exception as e: # noqa: BLE001 + _logger.warning( + "Failed to create depreciation move for asset %s line %s: %s", + asset.id, line.id, e, + ) + return None diff --git a/fusion_accounting_assets/models/fusion_assets_cron.py b/fusion_accounting_assets/models/fusion_assets_cron.py new file mode 100644 index 00000000..57735afb --- /dev/null +++ b/fusion_accounting_assets/models/fusion_assets_cron.py @@ -0,0 +1,96 @@ +"""Cron handlers for fusion_accounting_assets. + +- _cron_post_due_depreciation: daily, post due depreciation lines for running assets +- _cron_anomaly_scan: monthly, scan for schedule variance and create anomaly records +""" + +import logging + +from odoo import api, fields, models + +from ..services.anomaly_detection import detect_schedule_variance + +_logger = logging.getLogger(__name__) + + +class FusionAssetsCron(models.AbstractModel): + _name = "fusion.assets.cron" + _description = "Fusion Assets Cron Handlers" + + @api.model + def _cron_post_due_depreciation(self): + """For each running asset, post any due un-posted depreciation lines.""" + today = fields.Date.today() + engine = self.env['fusion.asset.engine'] + Asset = self.env['fusion.asset'] + running_assets = Asset.search([('state', '=', 'running')]) + posted_total = 0 + for asset in running_assets: + try: + with self.env.cr.savepoint(): + result = engine.post_depreciation_entry(asset, period_date=today) + posted_total += result.get('posted_count', 0) + except Exception as e: # noqa: BLE001 + _logger.warning("Cron post failed for asset %s: %s", asset.id, e) + _logger.info( + "Cron: posted depreciation on %d lines across %d running assets", + posted_total, len(running_assets), + ) + # 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): + """For each running asset, compare expected accumulated depreciation + vs posted, and persist any variance flags.""" + Asset = self.env['fusion.asset'] + Anomaly = self.env['fusion.asset.anomaly'] + running_assets = Asset.search([('state', '=', 'running')]) + flagged = 0 + today = fields.Date.today() + for asset in running_assets: + try: + expected = sum( + l.amount for l in asset.depreciation_line_ids + if l.scheduled_date and l.scheduled_date <= today + ) + actual = asset.total_depreciated + anomaly = detect_schedule_variance( + asset_id=asset.id, asset_name=asset.name, + expected_accumulated=expected, actual_accumulated=actual, + ) + if anomaly is None: + continue + anomaly_dict = anomaly.to_dict() + existing = Anomaly.search([ + ('asset_id', '=', asset.id), + ('anomaly_type', '=', anomaly_dict['anomaly_type']), + ('state', 'in', ('new', 'acknowledged')), + ], limit=1) + if existing: + continue + Anomaly.create({ + 'asset_id': asset.id, + 'anomaly_type': anomaly_dict['anomaly_type'], + 'severity': anomaly_dict['severity'], + 'expected': anomaly_dict['expected'], + 'actual': anomaly_dict['actual'], + 'variance_pct': anomaly_dict['variance_pct'], + 'detail': anomaly_dict['detail'], + }) + flagged += 1 + except Exception as e: # noqa: BLE001 + _logger.warning("Cron anomaly scan failed for asset %s: %s", asset.id, e) + _logger.info( + "Cron: scanned %d assets, flagged %d anomalies", + len(running_assets), flagged, + ) diff --git a/fusion_accounting_assets/models/fusion_migration_wizard.py b/fusion_accounting_assets/models/fusion_migration_wizard.py new file mode 100644 index 00000000..c3f6f17a --- /dev/null +++ b/fusion_accounting_assets/models/fusion_migration_wizard.py @@ -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 diff --git a/fusion_accounting_assets/reports/__init__.py b/fusion_accounting_assets/reports/__init__.py new file mode 100644 index 00000000..9064facf --- /dev/null +++ b/fusion_accounting_assets/reports/__init__.py @@ -0,0 +1 @@ +from . import migration_audit_report diff --git a/fusion_accounting_assets/reports/migration_audit_report.py b/fusion_accounting_assets/reports/migration_audit_report.py new file mode 100644 index 00000000..04998f4c --- /dev/null +++ b/fusion_accounting_assets/reports/migration_audit_report.py @@ -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, + } diff --git a/fusion_accounting_assets/reports/migration_audit_report_action.xml b/fusion_accounting_assets/reports/migration_audit_report_action.xml new file mode 100644 index 00000000..b6c36996 --- /dev/null +++ b/fusion_accounting_assets/reports/migration_audit_report_action.xml @@ -0,0 +1,11 @@ + + + + Assets Migration Audit + fusion.migration.wizard + qweb-pdf + fusion_accounting_assets.migration_audit_template + fusion_accounting_assets.migration_audit_template + + + diff --git a/fusion_accounting_assets/reports/migration_audit_report_views.xml b/fusion_accounting_assets/reports/migration_audit_report_views.xml new file mode 100644 index 00000000..b1efa68d --- /dev/null +++ b/fusion_accounting_assets/reports/migration_audit_report_views.xml @@ -0,0 +1,49 @@ + + + + diff --git a/fusion_accounting_assets/security/ir.model.access.csv b/fusion_accounting_assets/security/ir.model.access.csv new file mode 100644 index 00000000..eaade7de --- /dev/null +++ b/fusion_accounting_assets/security/ir.model.access.csv @@ -0,0 +1,15 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_fusion_asset_user,fusion.asset.user,model_fusion_asset,base.group_user,1,0,0,0 +access_fusion_asset_admin,fusion.asset.admin,model_fusion_asset,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1 +access_fusion_asset_depreciation_line_user,fusion.asset.depreciation.line.user,model_fusion_asset_depreciation_line,base.group_user,1,0,0,0 +access_fusion_asset_depreciation_line_admin,fusion.asset.depreciation.line.admin,model_fusion_asset_depreciation_line,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1 +access_fusion_asset_category_user,fusion.asset.category.user,model_fusion_asset_category,base.group_user,1,0,0,0 +access_fusion_asset_category_admin,fusion.asset.category.admin,model_fusion_asset_category,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1 +access_fusion_asset_disposal_user,fusion.asset.disposal.user,model_fusion_asset_disposal,base.group_user,1,0,0,0 +access_fusion_asset_disposal_admin,fusion.asset.disposal.admin,model_fusion_asset_disposal,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1 +access_fusion_asset_anomaly_user,fusion.asset.anomaly.user,model_fusion_asset_anomaly,base.group_user,1,0,0,0 +access_fusion_asset_anomaly_admin,fusion.asset.anomaly.admin,model_fusion_asset_anomaly,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1 +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 diff --git a/fusion_accounting_assets/services/__init__.py b/fusion_accounting_assets/services/__init__.py new file mode 100644 index 00000000..3a238ec5 --- /dev/null +++ b/fusion_accounting_assets/services/__init__.py @@ -0,0 +1,6 @@ +from . import depreciation_methods +from . import prorate +from . import salvage_value +from . import anomaly_detection +from . import useful_life_prompt +from . import useful_life_predictor diff --git a/fusion_accounting_assets/services/anomaly_detection.py b/fusion_accounting_assets/services/anomaly_detection.py new file mode 100644 index 00000000..48ae85d9 --- /dev/null +++ b/fusion_accounting_assets/services/anomaly_detection.py @@ -0,0 +1,96 @@ +"""Asset utilization anomaly detection. + +Flags assets where actual usage / posted depreciation deviates significantly +from the expected schedule. Three signal types: +- behind_schedule: actual depreciation < expected by > threshold pct +- ahead_of_schedule: actual > expected (over-depreciated; scrap or recompute) +- low_utilization: units_used < expected_units_per_period (waste alert) +""" + +from dataclasses import dataclass + + +@dataclass +class AssetAnomaly: + asset_id: int + asset_name: str + anomaly_type: str + severity: str + expected: float + actual: float + variance_pct: float + detail: str + + def to_dict(self): + return { + 'asset_id': self.asset_id, + 'asset_name': self.asset_name, + 'anomaly_type': self.anomaly_type, + 'severity': self.severity, + 'expected': self.expected, + 'actual': self.actual, + 'variance_pct': self.variance_pct, + 'detail': self.detail, + } + + +DEFAULT_LOW_THRESHOLD_PCT = 10.0 +DEFAULT_MEDIUM_THRESHOLD_PCT = 25.0 +DEFAULT_HIGH_THRESHOLD_PCT = 50.0 + + +def detect_schedule_variance(*, asset_id: int, asset_name: str, + expected_accumulated: float, + actual_accumulated: float) -> AssetAnomaly | None: + """Compare expected accumulated depreciation vs actual posted.""" + if expected_accumulated <= 0: + return None + variance_amt = actual_accumulated - expected_accumulated + variance_pct = abs(variance_amt) / expected_accumulated * 100 + if variance_pct < DEFAULT_LOW_THRESHOLD_PCT: + return None + direction = 'ahead_of_schedule' if variance_amt > 0 else 'behind_schedule' + if variance_pct >= DEFAULT_HIGH_THRESHOLD_PCT: + severity = 'high' + elif variance_pct >= DEFAULT_MEDIUM_THRESHOLD_PCT: + severity = 'medium' + else: + severity = 'low' + detail = f"Posted ${actual_accumulated:,.2f} vs expected ${expected_accumulated:,.2f}" + return AssetAnomaly( + asset_id=asset_id, + asset_name=asset_name, + anomaly_type=direction, + severity=severity, + expected=expected_accumulated, + actual=actual_accumulated, + variance_pct=round(variance_pct, 1), + detail=detail, + ) + + +def detect_low_utilization(*, asset_id: int, asset_name: str, + expected_units: float, + actual_units: float) -> AssetAnomaly | None: + """For units-of-production assets: flag low actual usage.""" + if expected_units <= 0: + return None + if actual_units >= expected_units * 0.9: + return None + deficit_pct = (expected_units - actual_units) / expected_units * 100 + if deficit_pct >= 50: + severity = 'high' + elif deficit_pct >= 25: + severity = 'medium' + else: + severity = 'low' + return AssetAnomaly( + asset_id=asset_id, + asset_name=asset_name, + anomaly_type='low_utilization', + severity=severity, + expected=expected_units, + actual=actual_units, + variance_pct=round(deficit_pct, 1), + detail=f"Used {actual_units:.0f} of expected {expected_units:.0f} units", + ) diff --git a/fusion_accounting_assets/services/depreciation_methods.py b/fusion_accounting_assets/services/depreciation_methods.py new file mode 100644 index 00000000..6b9afa6b --- /dev/null +++ b/fusion_accounting_assets/services/depreciation_methods.py @@ -0,0 +1,116 @@ +"""Depreciation method primitives. + +Three methods supported: +- straight_line: equal periodic charge over useful_life +- declining_balance: % per period of remaining book value +- units_of_production: charge proportional to units used / total units expected + +All return a list of DepreciationStep dataclasses (period_index, period_amount, +accumulated_depreciation, book_value_at_end). Total depreciation always +sums to (cost - salvage_value), within 1-cent rounding tolerance. +""" + +from dataclasses import dataclass +from typing import Literal + + +Method = Literal['straight_line', 'declining_balance', 'units_of_production'] + + +@dataclass +class DepreciationStep: + period_index: int + period_amount: float + accumulated_depreciation: float + book_value_at_end: float + + +def straight_line(*, cost: float, salvage_value: float = 0.0, + n_periods: int) -> list[DepreciationStep]: + """Equal charge per period: (cost - salvage) / n_periods. + + Last period absorbs rounding so total == cost - salvage exactly. + """ + if n_periods < 1: + return [] + depreciable = cost - salvage_value + per_period = round(depreciable / n_periods, 2) + steps = [] + accumulated = 0.0 + for i in range(n_periods): + if i == n_periods - 1: + amount = round(depreciable - accumulated, 2) + else: + amount = per_period + accumulated = round(accumulated + amount, 2) + book = round(cost - accumulated, 2) + steps.append(DepreciationStep( + period_index=i, + period_amount=amount, + accumulated_depreciation=accumulated, + book_value_at_end=book, + )) + return steps + + +def declining_balance(*, cost: float, salvage_value: float = 0.0, + n_periods: int, rate: float) -> list[DepreciationStep]: + """Apply `rate` (e.g. 0.20 = 20%) to remaining book each period. + + Switches to straight-line when straight-line would deplete remaining book + faster (typical Odoo behavior). Last step caps at salvage_value. + """ + if n_periods < 1 or rate <= 0: + return [] + if rate >= 1: + # Pathological: 100%+ rate. Charge full depreciable amount in period 0. + depreciable = round(cost - salvage_value, 2) + return [DepreciationStep(0, depreciable, depreciable, round(salvage_value, 2))] + steps = [] + book = cost + accumulated = 0.0 + for i in range(n_periods): + remaining_periods = n_periods - i + db_amount = round(book * rate, 2) + sl_amount = round((book - salvage_value) / remaining_periods, 2) if remaining_periods else 0.0 + amount = max(db_amount, sl_amount) + if book - amount < salvage_value: + amount = round(book - salvage_value, 2) + accumulated = round(accumulated + amount, 2) + book = round(book - amount, 2) + steps.append(DepreciationStep( + period_index=i, + period_amount=amount, + accumulated_depreciation=accumulated, + book_value_at_end=book, + )) + if book <= salvage_value: + break + return steps + + +def units_of_production(*, cost: float, salvage_value: float = 0.0, + total_units_expected: float, + units_per_period: list[float]) -> list[DepreciationStep]: + """Charge per period = (units_used / total_expected) * (cost - salvage).""" + if total_units_expected <= 0: + return [] + depreciable = cost - salvage_value + per_unit = depreciable / total_units_expected + steps = [] + accumulated = 0.0 + for i, units in enumerate(units_per_period): + amount = round(units * per_unit, 2) + if accumulated + amount > depreciable: + amount = round(depreciable - accumulated, 2) + accumulated = round(accumulated + amount, 2) + book = round(cost - accumulated, 2) + steps.append(DepreciationStep( + period_index=i, + period_amount=amount, + accumulated_depreciation=accumulated, + book_value_at_end=book, + )) + if accumulated >= depreciable: + break + return steps diff --git a/fusion_accounting_assets/services/prorate.py b/fusion_accounting_assets/services/prorate.py new file mode 100644 index 00000000..1957aaff --- /dev/null +++ b/fusion_accounting_assets/services/prorate.py @@ -0,0 +1,34 @@ +"""Prorating helpers for first-period and last-period depreciation. + +When an asset starts mid-month, the first period charges only a fraction +of the full period_amount. Three conventions: +- 'full_month': always charge full month (no proration) +- 'days_365': pro-rate by actual days / 365 +- 'days_period': pro-rate by actual days in period / total days in period +""" + +from datetime import date +from typing import Literal + + +ProrateConvention = Literal['full_month', 'days_365', 'days_period'] + + +def prorate_factor(*, period_start: date, period_end: date, + asset_start: date, + convention: ProrateConvention = 'days_period') -> float: + """Return a 0..1 factor for how much of `period`'s depreciation + applies to an asset that started on `asset_start`.""" + if convention == 'full_month': + return 1.0 + if asset_start <= period_start: + return 1.0 + if asset_start > period_end: + return 0.0 + actual_days = (period_end - asset_start).days + 1 + if convention == 'days_365': + return actual_days / 365.0 + if convention == 'days_period': + period_days = (period_end - period_start).days + 1 + return actual_days / period_days + raise ValueError(f"Unknown convention: {convention}") diff --git a/fusion_accounting_assets/services/salvage_value.py b/fusion_accounting_assets/services/salvage_value.py new file mode 100644 index 00000000..ff82ea78 --- /dev/null +++ b/fusion_accounting_assets/services/salvage_value.py @@ -0,0 +1,38 @@ +"""Salvage value (scrap value) calculation helpers. + +Most clients use straight % of cost; some use fixed dollar amounts. +""" + +from dataclasses import dataclass +from typing import Literal + + +SalvageMethod = Literal['percentage', 'fixed', 'zero'] + + +@dataclass +class SalvageConfig: + method: SalvageMethod + value: float = 0.0 + + +def compute_salvage_value(*, cost: float, config: SalvageConfig) -> float: + """Compute end-of-life salvage value.""" + if config.method == 'zero': + return 0.0 + if config.method == 'percentage': + return round(cost * config.value / 100, 2) + if config.method == 'fixed': + return round(config.value, 2) + raise ValueError(f"Unknown salvage method: {config.method}") + + +def remaining_useful_life_value(*, current_book: float, salvage: float, + periods_used: int, total_periods: int) -> float: + """Estimate remaining value if asset is sold/scrapped now.""" + if total_periods <= 0: + return current_book + if periods_used >= total_periods: + return salvage + remaining_pct = (total_periods - periods_used) / total_periods + return round(salvage + (current_book - salvage) * remaining_pct, 2) diff --git a/fusion_accounting_assets/services/useful_life_predictor.py b/fusion_accounting_assets/services/useful_life_predictor.py new file mode 100644 index 00000000..561e82d4 --- /dev/null +++ b/fusion_accounting_assets/services/useful_life_predictor.py @@ -0,0 +1,94 @@ +"""AI-suggested useful life from invoice context. + +Wraps useful_life_prompt + an LLMProvider. Returns a dict per the prompt's +output contract. Templated fallback when no provider configured. +""" + +import json +import logging +import re + +_logger = logging.getLogger(__name__) + + +# Templated fallback rules: (regex, years, method, rationale) +FALLBACK_RULES = [ + (r'\b(computer|laptop|monitor|server|workstation)\b', 4, 'straight_line', 'Computer hardware'), + (r'\b(furniture|desk|chair|cabinet)\b', 7, 'straight_line', 'Furniture'), + (r'\b(vehicle|truck|car|van)\b', 5, 'declining_balance', 'Vehicle (CRA Class 10)'), + (r'\b(building|warehouse)\b', 30, 'straight_line', 'Building'), + (r'\b(software|license)\b', 4, 'straight_line', 'Software license'), + (r'\b(equipment|machinery|machine)\b', 10, 'straight_line', 'Manufacturing equipment'), + (r'\b(leasehold improvement)\b', 5, 'straight_line', 'Leasehold improvements'), +] +FALLBACK_DEFAULT = (5, 'straight_line', 'Generic fixed asset (default)') + + +def predict_useful_life(env, *, description: str, amount: float = None, + partner_name: str = None, provider=None) -> dict: + """Suggest useful life + method via LLM, with templated fallback.""" + if provider is None: + provider = _get_provider(env) + if provider is None: + return _templated_fallback(description) + + try: + from .useful_life_prompt import build_prompt + system, user = build_prompt( + description=description, amount=amount, partner_name=partner_name, + ) + response = provider.complete( + system=system, + messages=[{'role': 'user', 'content': user}], + max_tokens=400, temperature=0.1, + ) + content = response.get('content') if isinstance(response, dict) else response + parsed = json.loads(content) + for key in ('useful_life_years', 'depreciation_method', 'rationale'): + if key not in parsed: + raise ValueError(f"Missing key: {key}") + parsed.setdefault('confidence', 0.7) + return parsed + except Exception as e: + _logger.warning("Useful life LLM prediction failed (%s); falling back", e) + return _templated_fallback(description) + + +def _templated_fallback(description: str) -> dict: + """Pattern-match keyword rules. Always returns a usable dict.""" + desc_lower = description.lower() if description else '' + for pattern, years, method, rationale in FALLBACK_RULES: + if re.search(pattern, desc_lower): + return { + 'useful_life_years': years, + 'depreciation_method': method, + 'rationale': rationale, + 'confidence': 0.5, + } + years, method, rationale = FALLBACK_DEFAULT + return { + 'useful_life_years': years, + 'depreciation_method': method, + 'rationale': rationale, + 'confidence': 0.3, + } + + +def _get_provider(env): + """Look up provider for 'asset_useful_life' feature.""" + param = env['ir.config_parameter'].sudo() + name = param.get_param('fusion_accounting.provider.asset_useful_life') + if not name: + name = param.get_param('fusion_accounting.provider.default') + if not name: + return None + try: + from odoo.addons.fusion_accounting_ai.services.adapters.openai_adapter import OpenAIAdapter + from odoo.addons.fusion_accounting_ai.services.adapters.claude import ClaudeAdapter + except ImportError: + return None + if name.startswith('openai'): + return OpenAIAdapter(env) + elif name.startswith('claude'): + return ClaudeAdapter(env) + return None diff --git a/fusion_accounting_assets/services/useful_life_prompt.py b/fusion_accounting_assets/services/useful_life_prompt.py new file mode 100644 index 00000000..cb2da3fa --- /dev/null +++ b/fusion_accounting_assets/services/useful_life_prompt.py @@ -0,0 +1,48 @@ +"""LLM prompt builder for AI-suggested useful life from invoice description. + +Output contract: +{ + "useful_life_years": , + "depreciation_method": "straight_line" | "declining_balance" | "units_of_production", + "rationale": "", + "confidence": +} +""" + + +SYSTEM_PROMPT = """You are an experienced accountant. Given an invoice line +description for a fixed asset, suggest the appropriate useful life in years +and depreciation method based on common accounting standards (IFRS / GAAP / CRA). + +Respond ONLY with valid JSON of this exact shape: +{ + "useful_life_years": , + "depreciation_method": "straight_line" | "declining_balance" | "units_of_production", + "rationale": "", + "confidence": +} + +Common useful-life conventions: +- Furniture: 7 years, straight-line +- Office equipment: 5 years, straight-line +- Computers: 3-4 years, straight-line or declining +- Vehicles: 5 years, declining-balance (CRA Class 10 30%) +- Buildings: 25-40 years, straight-line +- Manufacturing equipment: 10-15 years, units of production if measurable +- Software (licenses): 3-5 years, straight-line +- Leasehold improvements: lesser of lease term or useful life + +Do NOT include markdown code fences. Do NOT include any prose outside the JSON.""" + + +def build_prompt(*, description: str, amount: float = None, + partner_name: str = None) -> tuple[str, str]: + """Return (system, user) prompt tuple.""" + parts = [f"INVOICE LINE: {description}"] + if amount is not None: + parts.append(f"AMOUNT: ${amount:,.2f}") + if partner_name: + parts.append(f"VENDOR: {partner_name}") + parts.append("") + parts.append("Suggest the useful life and depreciation method per the system prompt.") + return (SYSTEM_PROMPT, "\n".join(parts)) diff --git a/fusion_accounting_assets/static/description/icon.png b/fusion_accounting_assets/static/description/icon.png new file mode 100644 index 00000000..6773c627 Binary files /dev/null and b/fusion_accounting_assets/static/description/icon.png differ diff --git a/fusion_accounting_assets/static/src/components/ai_useful_life_panel/ai_useful_life_panel.js b/fusion_accounting_assets/static/src/components/ai_useful_life_panel/ai_useful_life_panel.js new file mode 100644 index 00000000..84763365 --- /dev/null +++ b/fusion_accounting_assets/static/src/components/ai_useful_life_panel/ai_useful_life_panel.js @@ -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); + } + } +} diff --git a/fusion_accounting_assets/static/src/components/ai_useful_life_panel/ai_useful_life_panel.xml b/fusion_accounting_assets/static/src/components/ai_useful_life_panel/ai_useful_life_panel.xml new file mode 100644 index 00000000..8b651bbe --- /dev/null +++ b/fusion_accounting_assets/static/src/components/ai_useful_life_panel/ai_useful_life_panel.xml @@ -0,0 +1,38 @@ + + + + +
+
AI Suggest Useful Life
+
+ + +
+
+ + +
+ + +
+
Suggested life: years
+
Method:
+
+ + (confidence: %) +
+ +
+
+
+ +
diff --git a/fusion_accounting_assets/static/src/components/anomaly_strip/anomaly_strip.js b/fusion_accounting_assets/static/src/components/anomaly_strip/anomaly_strip.js new file mode 100644 index 00000000..5b6362f6 --- /dev/null +++ b/fusion_accounting_assets/static/src/components/anomaly_strip/anomaly_strip.js @@ -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); + } +} diff --git a/fusion_accounting_assets/static/src/components/anomaly_strip/anomaly_strip.xml b/fusion_accounting_assets/static/src/components/anomaly_strip/anomaly_strip.xml new file mode 100644 index 00000000..0b8578c6 --- /dev/null +++ b/fusion_accounting_assets/static/src/components/anomaly_strip/anomaly_strip.xml @@ -0,0 +1,19 @@ + + + + +
+ + + + + : + % + + + + +
+
+ +
diff --git a/fusion_accounting_assets/static/src/components/asset_card/asset_card.js b/fusion_accounting_assets/static/src/components/asset_card/asset_card.js new file mode 100644 index 00000000..00a64168 --- /dev/null +++ b/fusion_accounting_assets/static/src/components/asset_card/asset_card.js @@ -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 }, + }; +} diff --git a/fusion_accounting_assets/static/src/components/asset_card/asset_card.xml b/fusion_accounting_assets/static/src/components/asset_card/asset_card.xml new file mode 100644 index 00000000..c6515dd7 --- /dev/null +++ b/fusion_accounting_assets/static/src/components/asset_card/asset_card.xml @@ -0,0 +1,40 @@ + + + + +
+
+
+ + + [] + +
+
+ +
+
+
+
+ Cost: + $ +
+
+ Book Value: + $ +
+
+ Method: + +
+
+ Category: + +
+
+
+
+ +
diff --git a/fusion_accounting_assets/static/src/components/asset_detail_panel/asset_detail_panel.js b/fusion_accounting_assets/static/src/components/asset_detail_panel/asset_detail_panel.js new file mode 100644 index 00000000..26be2378 --- /dev/null +++ b/fusion_accounting_assets/static/src/components/asset_detail_panel/asset_detail_panel.js @@ -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 }); + } +} diff --git a/fusion_accounting_assets/static/src/components/asset_detail_panel/asset_detail_panel.xml b/fusion_accounting_assets/static/src/components/asset_detail_panel/asset_detail_panel.xml new file mode 100644 index 00000000..c3ba9af9 --- /dev/null +++ b/fusion_accounting_assets/static/src/components/asset_detail_panel/asset_detail_panel.xml @@ -0,0 +1,46 @@ + + + + +
+

+
+ [] +
+
+
State:
+
Cost: $
+
Salvage: $
+
Book Value: $
+
Total Depreciated: $
+
Method:
+
Useful Life: years
+
+ +
+ + + + +
+ +

Depreciation Schedule

+ + +
+

Active Anomalies

+
+ : +
+
+
+
+ +
diff --git a/fusion_accounting_assets/static/src/components/depreciation_board/depreciation_board.js b/fusion_accounting_assets/static/src/components/depreciation_board/depreciation_board.js new file mode 100644 index 00000000..90f8c1ea --- /dev/null +++ b/fusion_accounting_assets/static/src/components/depreciation_board/depreciation_board.js @@ -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 ""; + } +} diff --git a/fusion_accounting_assets/static/src/components/depreciation_board/depreciation_board.xml b/fusion_accounting_assets/static/src/components/depreciation_board/depreciation_board.xml new file mode 100644 index 00000000..c6770468 --- /dev/null +++ b/fusion_accounting_assets/static/src/components/depreciation_board/depreciation_board.xml @@ -0,0 +1,35 @@ + + + + +
+ + + + + + + + + + + + + + + + + + + + + +
#DateAmountAccumulatedBook ValueStatus
$$$ + Posted + Pending +
+
+
+ +
diff --git a/fusion_accounting_assets/static/src/components/disposal_dialog/disposal_dialog.js b/fusion_accounting_assets/static/src/components/disposal_dialog/disposal_dialog.js new file mode 100644 index 00000000..0c8a8563 --- /dev/null +++ b/fusion_accounting_assets/static/src/components/disposal_dialog/disposal_dialog.js @@ -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 + } + } +} diff --git a/fusion_accounting_assets/static/src/components/disposal_dialog/disposal_dialog.xml b/fusion_accounting_assets/static/src/components/disposal_dialog/disposal_dialog.xml new file mode 100644 index 00000000..a3913562 --- /dev/null +++ b/fusion_accounting_assets/static/src/components/disposal_dialog/disposal_dialog.xml @@ -0,0 +1,45 @@ + + + + + + + + diff --git a/fusion_accounting_assets/static/src/scss/_variables.scss b/fusion_accounting_assets/static/src/scss/_variables.scss new file mode 100644 index 00000000..c25f8d20 --- /dev/null +++ b/fusion_accounting_assets/static/src/scss/_variables.scss @@ -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; diff --git a/fusion_accounting_assets/static/src/scss/assets.scss b/fusion_accounting_assets/static/src/scss/assets.scss new file mode 100644 index 00000000..bd60b796 --- /dev/null +++ b/fusion_accounting_assets/static/src/scss/assets.scss @@ -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; } +} diff --git a/fusion_accounting_assets/static/src/scss/dark_mode.scss b/fusion_accounting_assets/static/src/scss/dark_mode.scss new file mode 100644 index 00000000..2de63446 --- /dev/null +++ b/fusion_accounting_assets/static/src/scss/dark_mode.scss @@ -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); } + } +} diff --git a/fusion_accounting_assets/static/src/services/assets_service.js b/fusion_accounting_assets/static/src/services/assets_service.js new file mode 100644 index 00000000..ceab62c0 --- /dev/null +++ b/fusion_accounting_assets/static/src/services/assets_service.js @@ -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); diff --git a/fusion_accounting_assets/static/src/tours/assets_tours.js b/fusion_accounting_assets/static/src/tours/assets_tours.js new file mode 100644 index 00000000..b5b4547d --- /dev/null +++ b/fusion_accounting_assets/static/src/tours/assets_tours.js @@ -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", + }, + ], +}); diff --git a/fusion_accounting_assets/static/src/views/asset_dashboard/asset_dashboard.js b/fusion_accounting_assets/static/src/views/asset_dashboard/asset_dashboard.js new file mode 100644 index 00000000..7ff4048a --- /dev/null +++ b/fusion_accounting_assets/static/src/views/asset_dashboard/asset_dashboard.js @@ -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); + } +} diff --git a/fusion_accounting_assets/static/src/views/asset_dashboard/asset_dashboard.xml b/fusion_accounting_assets/static/src/views/asset_dashboard/asset_dashboard.xml new file mode 100644 index 00000000..12b6f71f --- /dev/null +++ b/fusion_accounting_assets/static/src/views/asset_dashboard/asset_dashboard.xml @@ -0,0 +1,56 @@ + + + + +
+
+
+

Asset Management

+
+ of assets +
+
+
+
Cost: $
+
Book Value: $
+
+
+ +
+ + + + + +
+ + + +
+
+
Loading...
+
No assets found.
+
+ +
+
+
+ +
Select an asset to see details.
+
+
+
+
+ +
diff --git a/fusion_accounting_assets/static/src/views/asset_dashboard/asset_dashboard_view.js b/fusion_accounting_assets/static/src/views/asset_dashboard/asset_dashboard_view.js new file mode 100644 index 00000000..1194f080 --- /dev/null +++ b/fusion_accounting_assets/static/src/views/asset_dashboard/asset_dashboard_view.js @@ -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); diff --git a/fusion_accounting_assets/tests/__init__.py b/fusion_accounting_assets/tests/__init__.py new file mode 100644 index 00000000..e1b37dce --- /dev/null +++ b/fusion_accounting_assets/tests/__init__.py @@ -0,0 +1,31 @@ +from . import test_depreciation_methods +from . import test_prorate +from . import test_salvage_value +from . import test_asset_anomaly_detection +from . import test_useful_life_predictor +from . import test_fusion_asset +from . import test_fusion_asset_depreciation_line +from . import test_fusion_asset_category +from . import test_fusion_asset_disposal +from . import test_fusion_asset_anomaly +from . import test_account_move_inherit +from . import test_fusion_asset_engine +from . import test_engine_integration +from . import test_assets_controller +from . import test_assets_adapter +from . import test_asset_tools +from . import test_assets_cron +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 diff --git a/fusion_accounting_assets/tests/test_account_move_inherit.py b/fusion_accounting_assets/tests/test_account_move_inherit.py new file mode 100644 index 00000000..92ef79b6 --- /dev/null +++ b/fusion_accounting_assets/tests/test_account_move_inherit.py @@ -0,0 +1,47 @@ +from datetime import date + +from odoo.tests.common import TransactionCase +from odoo.tests import tagged + + +@tagged('post_install', '-at_install') +class TestAccountMoveLineFusionAsset(TransactionCase): + + def setUp(self): + super().setUp() + self.asset = self.env['fusion.asset'].create({ + 'name': 'Asset From Invoice', + 'cost': 8000, + 'acquisition_date': date(2026, 1, 1), + }) + self.partner = self.env['res.partner'].create({'name': 'Vendor X'}) + product = self.env['product.product'].create({'name': 'Test Asset Item'}) + bill = self.env['account.move'].create({ + 'move_type': 'in_invoice', + 'partner_id': self.partner.id, + 'invoice_date': date(2026, 1, 1), + 'invoice_line_ids': [(0, 0, { + 'product_id': product.id, + 'name': 'Test asset purchase', + 'quantity': 1, + 'price_unit': 8000, + })], + }) + self.invoice_line = bill.invoice_line_ids[0] + + def test_line_starts_without_asset_link(self): + self.assertFalse(self.invoice_line.fusion_asset_id) + self.assertEqual(self.invoice_line.fusion_asset_count, 0) + + def test_link_invoice_line_to_asset(self): + self.invoice_line.fusion_asset_id = self.asset + self.assertEqual(self.invoice_line.fusion_asset_id, self.asset) + self.invoice_line.invalidate_recordset(['fusion_asset_count']) + self.assertEqual(self.invoice_line.fusion_asset_count, 1) + + def test_action_open_fusion_asset_returns_window_action(self): + self.invoice_line.fusion_asset_id = self.asset + action = self.invoice_line.action_open_fusion_asset() + self.assertEqual(action['res_model'], 'fusion.asset') + self.assertEqual(action['res_id'], self.asset.id) + self.assertEqual(action['view_mode'], 'form') diff --git a/fusion_accounting_assets/tests/test_asset_anomaly_detection.py b/fusion_accounting_assets/tests/test_asset_anomaly_detection.py new file mode 100644 index 00000000..20ecd2c9 --- /dev/null +++ b/fusion_accounting_assets/tests/test_asset_anomaly_detection.py @@ -0,0 +1,71 @@ +from odoo.tests.common import TransactionCase +from odoo.tests import tagged +from odoo.addons.fusion_accounting_assets.services.anomaly_detection import ( + detect_schedule_variance, detect_low_utilization, AssetAnomaly, +) + + +@tagged('post_install', '-at_install') +class TestAssetAnomalyDetection(TransactionCase): + + def test_schedule_variance_within_threshold_returns_none(self): + # 5% variance < 10% threshold + result = detect_schedule_variance( + asset_id=1, asset_name='Truck', expected_accumulated=10000, + actual_accumulated=10500, + ) + self.assertIsNone(result) + + def test_schedule_variance_behind_schedule_low_severity(self): + # 15% behind: low severity, behind_schedule + result = detect_schedule_variance( + asset_id=1, asset_name='Truck', expected_accumulated=10000, + actual_accumulated=8500, + ) + self.assertIsNotNone(result) + self.assertEqual(result.anomaly_type, 'behind_schedule') + self.assertEqual(result.severity, 'low') + + def test_schedule_variance_ahead_high_severity(self): + # 60% ahead: high severity + result = detect_schedule_variance( + asset_id=2, asset_name='Server', expected_accumulated=10000, + actual_accumulated=16000, + ) + self.assertIsNotNone(result) + self.assertEqual(result.anomaly_type, 'ahead_of_schedule') + self.assertEqual(result.severity, 'high') + + def test_schedule_variance_zero_expected_returns_none(self): + result = detect_schedule_variance( + asset_id=1, asset_name='Truck', expected_accumulated=0, + actual_accumulated=500, + ) + self.assertIsNone(result) + + def test_low_utilization_flags_when_underused(self): + # 60% deficit -> high severity + result = detect_low_utilization( + asset_id=3, asset_name='Mill', expected_units=1000, actual_units=400, + ) + self.assertIsNotNone(result) + self.assertEqual(result.anomaly_type, 'low_utilization') + self.assertEqual(result.severity, 'high') + + def test_low_utilization_within_tolerance_returns_none(self): + # 95% used: within 10% tolerance + result = detect_low_utilization( + asset_id=3, asset_name='Mill', expected_units=1000, actual_units=950, + ) + self.assertIsNone(result) + + def test_anomaly_to_dict_round_trip(self): + anomaly = AssetAnomaly( + asset_id=1, asset_name='X', anomaly_type='behind_schedule', + severity='medium', expected=100.0, actual=70.0, variance_pct=30.0, + detail='example', + ) + d = anomaly.to_dict() + self.assertEqual(d['asset_id'], 1) + self.assertEqual(d['anomaly_type'], 'behind_schedule') + self.assertEqual(d['severity'], 'medium') diff --git a/fusion_accounting_assets/tests/test_asset_book_values_mv.py b/fusion_accounting_assets/tests/test_asset_book_values_mv.py new file mode 100644 index 00000000..e21fe161 --- /dev/null +++ b/fusion_accounting_assets/tests/test_asset_book_values_mv.py @@ -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) diff --git a/fusion_accounting_assets/tests/test_asset_tools.py b/fusion_accounting_assets/tests/test_asset_tools.py new file mode 100644 index 00000000..45f48f16 --- /dev/null +++ b/fusion_accounting_assets/tests/test_asset_tools.py @@ -0,0 +1,56 @@ +"""Tests for the 5 fusion-asset AI tools.""" + +from datetime import date + +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + +from odoo.addons.fusion_accounting_ai.services.tools import asset_management as tools + + +@tagged('post_install', '-at_install') +class TestFusionAssetTools(TransactionCase): + + def test_fusion_list_assets(self): + self.env['fusion.asset'].create({ + 'name': 'Tool Test', 'cost': 1000, + 'acquisition_date': date(2026, 1, 1), + 'method': 'straight_line', 'useful_life_years': 4, + }) + result = tools.fusion_list_assets(self.env, {'company_id': self.env.company.id}) + self.assertGreaterEqual(result.get('count', 0), 1) + + def test_fusion_get_asset_detail(self): + asset = self.env['fusion.asset'].create({ + 'name': 'Detail Test', 'cost': 1500, + 'acquisition_date': date(2026, 1, 1), + 'method': 'straight_line', 'useful_life_years': 4, + }) + result = tools.fusion_get_asset_detail(self.env, {'asset_id': asset.id}) + self.assertEqual(result['asset']['name'], 'Detail Test') + + def test_fusion_compute_schedule(self): + asset = self.env['fusion.asset'].create({ + 'name': 'Schedule Test', 'cost': 2000, + 'acquisition_date': date(2026, 1, 1), + 'method': 'straight_line', 'useful_life_years': 4, + }) + result = tools.fusion_compute_asset_schedule(self.env, {'asset_id': asset.id}) + self.assertEqual(result['lines_created'], 4) + + def test_fusion_suggest_useful_life(self): + self.env['ir.config_parameter'].sudo().search([ + ('key', 'in', ['fusion_accounting.provider.asset_useful_life', + 'fusion_accounting.provider.default']) + ]).unlink() + result = tools.fusion_suggest_asset_useful_life(self.env, { + 'description': 'desk', + }) + self.assertEqual(result['useful_life_years'], 7) + + def test_tools_registered_in_dispatch(self): + from odoo.addons.fusion_accounting_ai.services.tools import TOOL_DISPATCH + for tool_name in ['fusion_list_assets', 'fusion_get_asset_detail', + 'fusion_compute_asset_schedule', 'fusion_dispose_asset', + 'fusion_suggest_asset_useful_life']: + self.assertIn(tool_name, TOOL_DISPATCH) diff --git a/fusion_accounting_assets/tests/test_assets_adapter.py b/fusion_accounting_assets/tests/test_assets_adapter.py new file mode 100644 index 00000000..d58096b5 --- /dev/null +++ b/fusion_accounting_assets/tests/test_assets_adapter.py @@ -0,0 +1,40 @@ +"""AssetsAdapter wiring tests — fusion-mode dispatch.""" + +from datetime import date + +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + +from odoo.addons.fusion_accounting_ai.services.data_adapters.assets import ( + AssetsAdapter, +) + + +@tagged('post_install', '-at_install') +class TestAssetsAdapter(TransactionCase): + + def setUp(self): + super().setUp() + self.adapter = AssetsAdapter(self.env) + + def test_list_assets_via_fusion(self): + self.env['fusion.asset'].create({ + 'name': 'Adapter Test', 'cost': 1000, + 'acquisition_date': date(2026, 1, 1), + 'method': 'straight_line', 'useful_life_years': 4, + }) + result = self.adapter.list_assets_via_fusion(company_id=self.env.company.id) + self.assertGreaterEqual(result['count'], 1) + + def test_suggest_useful_life_via_fusion_uses_templated_fallback(self): + self.env['ir.config_parameter'].sudo().search([ + ('key', 'in', ['fusion_accounting.provider.asset_useful_life', + 'fusion_accounting.provider.default']) + ]).unlink() + result = self.adapter.suggest_useful_life_via_fusion(description='laptop') + self.assertEqual(result['useful_life_years'], 4) + self.assertEqual(result['depreciation_method'], 'straight_line') + + def test_dispose_asset_via_community_returns_error(self): + result = self.adapter.dispose_asset_via_community(asset_id=1, sale_amount=100) + self.assertIn('error', result) diff --git a/fusion_accounting_assets/tests/test_assets_controller.py b/fusion_accounting_assets/tests/test_assets_controller.py new file mode 100644 index 00000000..fca98963 --- /dev/null +++ b/fusion_accounting_assets/tests/test_assets_controller.py @@ -0,0 +1,103 @@ +"""Controller tests using HttpCase.""" + +import json +from datetime import date + +from odoo.tests import tagged +from odoo.tests.common import HttpCase, new_test_user + + +@tagged('post_install', '-at_install') +class TestAssetsController(HttpCase): + + def setUp(self): + super().setUp() + self.user = new_test_user( + self.env, login='assets_test_user', + groups='base.group_user,account.group_account_invoice', + ) + + def _jsonrpc(self, endpoint, params): + self.authenticate('assets_test_user', 'assets_test_user') + url = f'/fusion/assets/{endpoint}' + body = {'jsonrpc': '2.0', 'method': 'call', 'params': params, 'id': 1} + response = self.url_open( + url, data=json.dumps(body), + headers={'Content-Type': 'application/json'}, + ) + self.assertEqual( + response.status_code, 200, + f"{endpoint} returned {response.status_code}: {response.text[:300]}", + ) + result = response.json() + if 'error' in result: + self.fail(f"{endpoint} errored: {result['error']}") + return result.get('result', {}) + + def test_list_returns_dict(self): + result = self._jsonrpc('list', {'company_id': self.env.company.id}) + self.assertIn('assets', result) + self.assertIn('total', result) + + def test_get_detail_returns_asset(self): + asset = self.env['fusion.asset'].create({ + 'name': 'Ctrl Test Asset', 'cost': 5000, + 'acquisition_date': date(2026, 1, 1), + 'method': 'straight_line', 'useful_life_years': 5, + }) + result = self._jsonrpc('get_detail', {'asset_id': asset.id}) + self.assertEqual(result['asset']['id'], asset.id) + self.assertIn('depreciation_lines', result) + + def test_compute_schedule_creates_lines(self): + asset = self.env['fusion.asset'].create({ + 'name': 'CompTest', 'cost': 4000, + 'acquisition_date': date(2026, 1, 1), + 'method': 'straight_line', 'useful_life_years': 4, + }) + result = self._jsonrpc('compute_schedule', {'asset_id': asset.id}) + self.assertEqual(result['lines_created'], 4) + + def test_post_depreciation_after_running(self): + asset = self.env['fusion.asset'].create({ + 'name': 'PostTest', 'cost': 3000, + 'acquisition_date': date(2026, 1, 1), + 'in_service_date': date(2026, 1, 1), + 'method': 'straight_line', 'useful_life_years': 3, + }) + self.env['fusion.asset.engine'].compute_depreciation_schedule(asset) + asset.action_set_running() + result = self._jsonrpc('post_depreciation', {'asset_id': asset.id}) + self.assertEqual(result['posted_count'], 1) + + def test_dispose_marks_asset_disposed(self): + asset = self.env['fusion.asset'].create({ + 'name': 'DispTest', 'cost': 6000, + 'acquisition_date': date(2026, 1, 1), + 'in_service_date': date(2026, 1, 1), + 'method': 'straight_line', 'useful_life_years': 3, + }) + self.env['fusion.asset.engine'].compute_depreciation_schedule(asset) + asset.action_set_running() + result = self._jsonrpc('dispose', { + 'asset_id': asset.id, 'sale_amount': 4000, + 'sale_date': '2027-06-01', 'disposal_type': 'sale', + }) + self.assertIn('disposal_id', result) + asset.invalidate_recordset(['state']) + self.assertEqual(asset.state, 'disposed') + + def test_get_anomalies_returns_list(self): + result = self._jsonrpc('get_anomalies', {'company_id': self.env.company.id}) + self.assertIn('anomalies', result) + + def test_suggest_useful_life_returns_dict(self): + result = self._jsonrpc('suggest_useful_life', {'description': 'Dell laptop'}) + self.assertIn('useful_life_years', result) + self.assertIn('depreciation_method', result) + self.assertEqual(result['useful_life_years'], 4) + + def test_get_partner_history(self): + partner = self.env['res.partner'].create({'name': 'History Test Partner'}) + result = self._jsonrpc('get_partner_history', {'partner_id': partner.id}) + self.assertEqual(result['partner_id'], partner.id) diff --git a/fusion_accounting_assets/tests/test_assets_cron.py b/fusion_accounting_assets/tests/test_assets_cron.py new file mode 100644 index 00000000..fafaa804 --- /dev/null +++ b/fusion_accounting_assets/tests/test_assets_cron.py @@ -0,0 +1,28 @@ +"""Cron handler smoke tests.""" + +from datetime import date + +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + + +@tagged('post_install', '-at_install') +class TestFusionAssetsCron(TransactionCase): + + def setUp(self): + super().setUp() + self.cron = self.env['fusion.assets.cron'] + self.asset = self.env['fusion.asset'].create({ + 'name': 'Cron Test', 'cost': 4000, + 'acquisition_date': date(2026, 1, 1), + 'in_service_date': date(2026, 1, 1), + 'method': 'straight_line', 'useful_life_years': 4, + }) + self.env['fusion.asset.engine'].compute_depreciation_schedule(self.asset) + self.asset.action_set_running() + + def test_cron_post_due_depreciation_runs(self): + self.cron._cron_post_due_depreciation() + + def test_cron_anomaly_scan_runs(self): + self.cron._cron_anomaly_scan() diff --git a/fusion_accounting_assets/tests/test_assets_tours.py b/fusion_accounting_assets/tests/test_assets_tours.py new file mode 100644 index 00000000..62c2b07c --- /dev/null +++ b/fusion_accounting_assets/tests/test_assets_tours.py @@ -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") diff --git a/fusion_accounting_assets/tests/test_audit_report.py b/fusion_accounting_assets/tests/test_audit_report.py new file mode 100644 index 00000000..e359525d --- /dev/null +++ b/fusion_accounting_assets/tests/test_audit_report.py @@ -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}") diff --git a/fusion_accounting_assets/tests/test_coexistence.py b/fusion_accounting_assets/tests/test_coexistence.py new file mode 100644 index 00000000..56c77d6b --- /dev/null +++ b/fusion_accounting_assets/tests/test_coexistence.py @@ -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) diff --git a/fusion_accounting_assets/tests/test_create_asset_wizard.py b/fusion_accounting_assets/tests/test_create_asset_wizard.py new file mode 100644 index 00000000..e6f67fb4 --- /dev/null +++ b/fusion_accounting_assets/tests/test_create_asset_wizard.py @@ -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) diff --git a/fusion_accounting_assets/tests/test_depreciation_methods.py b/fusion_accounting_assets/tests/test_depreciation_methods.py new file mode 100644 index 00000000..5571d11e --- /dev/null +++ b/fusion_accounting_assets/tests/test_depreciation_methods.py @@ -0,0 +1,88 @@ +from odoo.tests.common import TransactionCase +from odoo.tests import tagged +from odoo.addons.fusion_accounting_assets.services.depreciation_methods import ( + straight_line, declining_balance, units_of_production, +) + + +@tagged('post_install', '-at_install') +class TestStraightLine(TransactionCase): + + def test_total_equals_cost_minus_salvage(self): + steps = straight_line(cost=10000, salvage_value=1000, n_periods=5) + total = sum(s.period_amount for s in steps) + self.assertAlmostEqual(total, 9000, places=2) + + def test_per_period_equal_except_last(self): + steps = straight_line(cost=10000, salvage_value=0, n_periods=4) + self.assertEqual([s.period_amount for s in steps], [2500.0] * 4) + + def test_last_period_absorbs_rounding(self): + steps = straight_line(cost=10000, salvage_value=0, n_periods=3) + total = sum(s.period_amount for s in steps) + self.assertAlmostEqual(total, 10000, places=2) + + def test_zero_periods_returns_empty(self): + self.assertEqual(straight_line(cost=10000, n_periods=0), []) + + def test_book_value_decreasing(self): + steps = straight_line(cost=10000, salvage_value=1000, n_periods=5) + for i in range(1, len(steps)): + self.assertLess(steps[i].book_value_at_end, steps[i - 1].book_value_at_end) + + +@tagged('post_install', '-at_install') +class TestDecliningBalance(TransactionCase): + + def test_total_does_not_exceed_depreciable(self): + steps = declining_balance(cost=10000, salvage_value=1000, n_periods=10, rate=0.20) + total = sum(s.period_amount for s in steps) + self.assertLessEqual(total, 9000.01) + + def test_does_not_go_below_salvage(self): + steps = declining_balance(cost=10000, salvage_value=1000, n_periods=10, rate=0.50) + for s in steps: + self.assertGreaterEqual(s.book_value_at_end, 999.99) + + def test_zero_rate_returns_empty(self): + self.assertEqual(declining_balance(cost=10000, n_periods=5, rate=0), []) + + def test_pathological_100pct_rate_one_period(self): + steps = declining_balance(cost=10000, salvage_value=500, n_periods=10, rate=1.0) + self.assertEqual(len(steps), 1) + self.assertAlmostEqual(steps[0].period_amount, 9500, places=2) + + +@tagged('post_install', '-at_install') +class TestUnitsOfProduction(TransactionCase): + + def test_total_proportional_to_units_used(self): + steps = units_of_production( + cost=20000, salvage_value=2000, + total_units_expected=10000, + units_per_period=[1000, 2000, 3000, 4000], + ) + total = sum(s.period_amount for s in steps) + self.assertAlmostEqual(total, 18000, places=1) + + def test_partial_use_partial_depreciation(self): + steps = units_of_production( + cost=10000, salvage_value=0, + total_units_expected=1000, + units_per_period=[200], + ) + self.assertAlmostEqual(steps[0].period_amount, 2000, places=2) + + def test_zero_total_units_returns_empty(self): + self.assertEqual( + units_of_production(cost=10000, total_units_expected=0, units_per_period=[100]), + [], + ) + + def test_does_not_overshoot_salvage(self): + steps = units_of_production( + cost=10000, salvage_value=1000, + total_units_expected=1000, + units_per_period=[2000], + ) + self.assertAlmostEqual(steps[0].period_amount, 9000, places=2) diff --git a/fusion_accounting_assets/tests/test_depreciation_run_wizard.py b/fusion_accounting_assets/tests/test_depreciation_run_wizard.py new file mode 100644 index 00000000..6a55d7b4 --- /dev/null +++ b/fusion_accounting_assets/tests/test_depreciation_run_wizard.py @@ -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') diff --git a/fusion_accounting_assets/tests/test_disposal_wizard.py b/fusion_accounting_assets/tests/test_disposal_wizard.py new file mode 100644 index 00000000..23258ce8 --- /dev/null +++ b/fusion_accounting_assets/tests/test_disposal_wizard.py @@ -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, + ) diff --git a/fusion_accounting_assets/tests/test_engine_integration.py b/fusion_accounting_assets/tests/test_engine_integration.py new file mode 100644 index 00000000..2627033a --- /dev/null +++ b/fusion_accounting_assets/tests/test_engine_integration.py @@ -0,0 +1,151 @@ +"""End-to-end engine integration tests. + +Each test creates a complete realistic asset (with category and accounts), +runs the engine through a full lifecycle, and asserts both the model state +and the journal entries (where category accounts are configured). +""" + +from datetime import date + +from odoo.tests.common import TransactionCase +from odoo.tests import tagged +from odoo.exceptions import ValidationError + + +@tagged('post_install', '-at_install', 'integration') +class TestAssetEngineIntegration(TransactionCase): + + def setUp(self): + super().setUp() + self.engine = self.env['fusion.asset.engine'] + Account = self.env['account.account'] + company_id = self.env.company.id + self.expense_account = Account.search([ + ('account_type', '=', 'expense_depreciation'), + ('company_ids', 'in', company_id), + ], limit=1) + if not self.expense_account: + self.expense_account = Account.create({ + 'name': 'Test Depreciation Expense', + 'code': '7180', + 'account_type': 'expense_depreciation', + 'company_ids': [(6, 0, [company_id])], + }) + self.dep_account = Account.search([ + ('account_type', '=', 'asset_fixed'), + ('company_ids', 'in', company_id), + ], limit=1) + if not self.dep_account: + self.dep_account = Account.create({ + 'name': 'Test Accumulated Depreciation', + 'code': '1690', + 'account_type': 'asset_fixed', + 'company_ids': [(6, 0, [company_id])], + }) + self.category = self.env['fusion.asset.category'].create({ + 'name': 'Test Category', + 'method': 'straight_line', + 'useful_life_years': 5, + 'asset_account_id': self.dep_account.id, + 'depreciation_account_id': self.dep_account.id, + 'expense_account_id': self.expense_account.id, + }) + + def _make_asset(self, **kwargs): + defaults = { + 'name': 'Integration Asset', + 'cost': 12000, + 'salvage_value': 0, + 'acquisition_date': date(2026, 1, 1), + 'in_service_date': date(2026, 1, 1), + 'method': 'straight_line', + 'useful_life_years': 4, + 'category_id': self.category.id, + } + defaults.update(kwargs) + return self.env['fusion.asset'].create(defaults) + + def test_full_lifecycle_straight_line(self): + asset = self._make_asset() + self.engine.compute_depreciation_schedule(asset) + self.assertEqual(len(asset.depreciation_line_ids), 4) + self.assertAlmostEqual( + sum(asset.depreciation_line_ids.mapped('amount')), 12000, places=2, + ) + + asset.action_set_running() + for _i in range(2): + result = self.engine.post_depreciation_entry(asset) + self.assertEqual(result['posted_count'], 1) + asset.invalidate_recordset(['book_value', 'total_depreciated']) + self.assertAlmostEqual(asset.total_depreciated, 6000, places=2) + + def test_post_creates_journal_entry_when_accounts_configured(self): + asset = self._make_asset() + self.engine.compute_depreciation_schedule(asset) + asset.action_set_running() + self.engine.post_depreciation_entry(asset) + first = asset.depreciation_line_ids.sorted('period_index')[0] + self.assertTrue(first.move_id, "Expected journal entry on posted line") + moves = first.move_id + self.assertAlmostEqual( + sum(moves.line_ids.mapped('debit')), + sum(moves.line_ids.mapped('credit')), + places=2, + ) + + def test_dispose_caps_future_lines(self): + asset = self._make_asset() + self.engine.compute_depreciation_schedule(asset) + asset.action_set_running() + self.engine.post_depreciation_entry(asset) + self.engine.dispose_asset( + asset, sale_amount=5000, sale_date=date(2027, 6, 1), + ) + self.assertEqual(asset.state, 'disposed') + unposted = asset.depreciation_line_ids.filtered(lambda l: not l.is_posted) + for line in unposted: + self.assertLessEqual(line.scheduled_date, date(2027, 6, 1)) + + def test_dispose_records_correct_book_value(self): + asset = self._make_asset() + self.engine.compute_depreciation_schedule(asset) + asset.action_set_running() + for _i in range(2): + self.engine.post_depreciation_entry(asset) + result = self.engine.dispose_asset( + asset, sale_amount=8000, sale_date=date(2028, 6, 1), + ) + # Book value at disposal = cost - accumulated = 12000 - 6000 = 6000. + self.assertAlmostEqual(result['book_value_at_disposal'], 6000, places=2) + # Gain = 8000 - 6000 = 2000. + self.assertAlmostEqual(result['gain_loss_amount'], 2000, places=2) + + def test_partial_sale_30pct(self): + asset = self._make_asset(cost=10000, salvage_value=0) + self.engine.compute_depreciation_schedule(asset) + asset.action_set_running() + result = self.engine.partial_sale( + asset, sold_amount=3500, sold_qty=0.3, + sale_date=date(2027, 1, 1), + ) + asset.invalidate_recordset(['cost']) + self.assertAlmostEqual(asset.cost, 7000, places=2) + child = self.env['fusion.asset'].browse(result['child_asset_id']) + self.assertAlmostEqual(child.cost, 3000, places=2) + self.assertEqual(child.state, 'disposed') + # Child has no posted depreciation; book_value at disposal = 3000. + # Gain = 3500 - 3000 = 500. + self.assertAlmostEqual(result['gain_loss_amount'], 500, places=0) + + def test_pause_then_resume_lifecycle(self): + asset = self._make_asset() + self.engine.compute_depreciation_schedule(asset) + asset.action_set_running() + self.engine.post_depreciation_entry(asset) + self.engine.pause_asset(asset) + with self.assertRaises(ValidationError): + self.engine.post_depreciation_entry(asset) + self.engine.resume_asset(asset) + result = self.engine.post_depreciation_entry(asset) + self.assertEqual(result['posted_count'], 1) diff --git a/fusion_accounting_assets/tests/test_engine_property.py b/fusion_accounting_assets/tests/test_engine_property.py new file mode 100644 index 00000000..e211b4a1 --- /dev/null +++ b/fusion_accounting_assets/tests/test_engine_property.py @@ -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) diff --git a/fusion_accounting_assets/tests/test_fusion_asset.py b/fusion_accounting_assets/tests/test_fusion_asset.py new file mode 100644 index 00000000..4fe5e08a --- /dev/null +++ b/fusion_accounting_assets/tests/test_fusion_asset.py @@ -0,0 +1,59 @@ +from datetime import date + +from odoo.tests.common import TransactionCase +from odoo.tests import tagged +from odoo.exceptions import ValidationError + + +@tagged('post_install', '-at_install') +class TestFusionAsset(TransactionCase): + + def setUp(self): + super().setUp() + self.asset_vals = { + 'name': 'Test Asset', + 'cost': 10000, + 'salvage_value': 1000, + 'acquisition_date': date(2026, 1, 1), + 'method': 'straight_line', + 'useful_life_years': 5, + } + + def test_create_minimal(self): + a = self.env['fusion.asset'].create(self.asset_vals) + self.assertEqual(a.state, 'draft') + self.assertEqual(a.book_value, 10000) + + def test_state_transitions_draft_to_running(self): + a = self.env['fusion.asset'].create(self.asset_vals) + a.action_set_running() + self.assertEqual(a.state, 'running') + self.assertTrue(a.in_service_date) + + def test_pause_resume(self): + a = self.env['fusion.asset'].create(self.asset_vals) + a.action_set_running() + a.action_pause() + self.assertEqual(a.state, 'paused') + a.action_resume() + self.assertEqual(a.state, 'running') + + def test_cannot_pause_from_draft(self): + a = self.env['fusion.asset'].create(self.asset_vals) + with self.assertRaises(ValidationError): + a.action_pause() + + def test_negative_cost_rejected(self): + with self.assertRaises(Exception): + self.env['fusion.asset'].create({**self.asset_vals, 'cost': -100}) + + def test_salvage_exceeds_cost_rejected(self): + with self.assertRaises(Exception): + self.env['fusion.asset'].create( + {**self.asset_vals, 'cost': 1000, 'salvage_value': 5000}, + ) + + def test_book_value_starts_at_cost(self): + a = self.env['fusion.asset'].create(self.asset_vals) + self.assertEqual(a.book_value, a.cost) + self.assertEqual(a.total_depreciated, 0) diff --git a/fusion_accounting_assets/tests/test_fusion_asset_anomaly.py b/fusion_accounting_assets/tests/test_fusion_asset_anomaly.py new file mode 100644 index 00000000..b4d928eb --- /dev/null +++ b/fusion_accounting_assets/tests/test_fusion_asset_anomaly.py @@ -0,0 +1,49 @@ +from datetime import date + +from odoo.tests.common import TransactionCase +from odoo.tests import tagged + + +@tagged('post_install', '-at_install') +class TestFusionAssetAnomaly(TransactionCase): + + def setUp(self): + super().setUp() + self.asset = self.env['fusion.asset'].create({ + 'name': 'Watched Asset', + 'cost': 5000, + 'acquisition_date': date(2026, 1, 1), + }) + + def _make_anomaly(self, **kw): + vals = { + 'asset_id': self.asset.id, + 'anomaly_type': 'behind_schedule', + 'severity': 'medium', + 'expected': 1000.0, + 'actual': 700.0, + 'variance_pct': -30.0, + } + vals.update(kw) + return self.env['fusion.asset.anomaly'].create(vals) + + def test_create_defaults_state_new(self): + a = self._make_anomaly() + self.assertEqual(a.state, 'new') + self.assertTrue(a.detected_at) + self.assertEqual(a.company_id, self.asset.company_id) + + def test_acknowledge_transitions(self): + a = self._make_anomaly() + a.action_acknowledge() + self.assertEqual(a.state, 'acknowledged') + + def test_dismiss_transitions(self): + a = self._make_anomaly() + a.action_dismiss() + self.assertEqual(a.state, 'dismissed') + + def test_resolve_transitions(self): + a = self._make_anomaly(anomaly_type='low_utilization', severity='high') + a.action_resolve() + self.assertEqual(a.state, 'resolved') diff --git a/fusion_accounting_assets/tests/test_fusion_asset_category.py b/fusion_accounting_assets/tests/test_fusion_asset_category.py new file mode 100644 index 00000000..a7bc36c8 --- /dev/null +++ b/fusion_accounting_assets/tests/test_fusion_asset_category.py @@ -0,0 +1,35 @@ +from datetime import date + +from odoo.tests.common import TransactionCase +from odoo.tests import tagged + + +@tagged('post_install', '-at_install') +class TestFusionAssetCategory(TransactionCase): + + def test_create_with_defaults(self): + cat = self.env['fusion.asset.category'].create({'name': 'Computers'}) + self.assertEqual(cat.method, 'straight_line') + self.assertEqual(cat.useful_life_years, 5) + self.assertEqual(cat.prorate_convention, 'days_period') + self.assertEqual(cat.asset_count, 0) + + def test_asset_count_reflects_linked_assets(self): + cat = self.env['fusion.asset.category'].create({'name': 'Vehicles'}) + for i in range(3): + self.env['fusion.asset'].create({ + 'name': f'Truck {i}', + 'cost': 50000, + 'acquisition_date': date(2026, 1, 1), + 'method': 'declining_balance', + 'category_id': cat.id, + }) + cat.invalidate_recordset(['asset_count']) + self.assertEqual(cat.asset_count, 3) + + def test_method_must_be_in_selection(self): + with self.assertRaises(Exception): + self.env['fusion.asset.category'].create({ + 'name': 'Bogus', + 'method': 'not_a_method', + }) diff --git a/fusion_accounting_assets/tests/test_fusion_asset_depreciation_line.py b/fusion_accounting_assets/tests/test_fusion_asset_depreciation_line.py new file mode 100644 index 00000000..bf401510 --- /dev/null +++ b/fusion_accounting_assets/tests/test_fusion_asset_depreciation_line.py @@ -0,0 +1,62 @@ +from datetime import date + +from odoo.tests.common import TransactionCase +from odoo.tests import tagged + + +@tagged('post_install', '-at_install') +class TestFusionAssetDepreciationLine(TransactionCase): + + def setUp(self): + super().setUp() + self.asset = self.env['fusion.asset'].create({ + 'name': 'Asset for Lines', + 'cost': 12000, + 'salvage_value': 0, + 'acquisition_date': date(2026, 1, 1), + 'method': 'straight_line', + 'useful_life_years': 1, + }) + + def _make_line(self, period_index, amount=1000.0, scheduled_date=None): + return self.env['fusion.asset.depreciation.line'].create({ + 'asset_id': self.asset.id, + 'period_index': period_index, + 'scheduled_date': scheduled_date or date(2026, period_index, 28), + 'amount': amount, + }) + + def test_create_line_defaults_unposted(self): + line = self._make_line(1) + self.assertFalse(line.is_posted) + self.assertFalse(line.posted_date) + self.assertFalse(line.move_id) + self.assertEqual(line.company_id, self.asset.company_id) + self.assertEqual(line.currency_id, self.asset.currency_id) + + def test_action_post_marks_line_posted(self): + line = self._make_line(2) + line.action_post() + self.assertTrue(line.is_posted) + self.assertTrue(line.posted_date) + + def test_action_post_idempotent_keeps_first_date(self): + line = self._make_line(3) + line.action_post() + first_date = line.posted_date + line.action_post() + self.assertEqual(line.posted_date, first_date) + + def test_unique_period_per_asset(self): + self._make_line(4) + with self.assertRaises(Exception): + self._make_line(4) + + def test_book_value_reflects_posted_lines_only(self): + l1 = self._make_line(5, amount=1000) + self._make_line(6, amount=1500) + self.assertEqual(self.asset.book_value, 12000) + l1.action_post() + self.asset.invalidate_recordset(['book_value', 'total_depreciated']) + self.assertEqual(self.asset.total_depreciated, 1000) + self.assertEqual(self.asset.book_value, 11000) diff --git a/fusion_accounting_assets/tests/test_fusion_asset_disposal.py b/fusion_accounting_assets/tests/test_fusion_asset_disposal.py new file mode 100644 index 00000000..86bbf99f --- /dev/null +++ b/fusion_accounting_assets/tests/test_fusion_asset_disposal.py @@ -0,0 +1,56 @@ +from datetime import date + +from odoo.tests.common import TransactionCase +from odoo.tests import tagged + + +@tagged('post_install', '-at_install') +class TestFusionAssetDisposal(TransactionCase): + + def setUp(self): + super().setUp() + self.asset = self.env['fusion.asset'].create({ + 'name': 'Disposable Asset', + 'cost': 10000, + 'salvage_value': 0, + 'acquisition_date': date(2026, 1, 1), + 'method': 'straight_line', + 'useful_life_years': 5, + }) + + def test_create_minimal_sale(self): + d = self.env['fusion.asset.disposal'].create({ + 'asset_id': self.asset.id, + 'disposal_type': 'sale', + 'sale_amount': 7000, + 'book_value_at_disposal': 6000, + }) + self.assertEqual(d.gain_loss_amount, 1000) + self.assertEqual(d.company_id, self.asset.company_id) + + def test_sale_at_loss(self): + d = self.env['fusion.asset.disposal'].create({ + 'asset_id': self.asset.id, + 'disposal_type': 'sale', + 'sale_amount': 4000, + 'book_value_at_disposal': 6000, + }) + self.assertEqual(d.gain_loss_amount, -2000) + + def test_scrap_full_loss(self): + d = self.env['fusion.asset.disposal'].create({ + 'asset_id': self.asset.id, + 'disposal_type': 'scrap', + 'sale_amount': 0, + 'book_value_at_disposal': 6000, + }) + self.assertEqual(d.gain_loss_amount, -6000) + + def test_donation_ignores_sale_amount(self): + d = self.env['fusion.asset.disposal'].create({ + 'asset_id': self.asset.id, + 'disposal_type': 'donation', + 'sale_amount': 999, + 'book_value_at_disposal': 6000, + }) + self.assertEqual(d.gain_loss_amount, -6000) diff --git a/fusion_accounting_assets/tests/test_fusion_asset_engine.py b/fusion_accounting_assets/tests/test_fusion_asset_engine.py new file mode 100644 index 00000000..ebce108a --- /dev/null +++ b/fusion_accounting_assets/tests/test_fusion_asset_engine.py @@ -0,0 +1,115 @@ +from datetime import date + +from odoo.tests.common import TransactionCase +from odoo.tests import tagged +from odoo.exceptions import ValidationError + + +@tagged('post_install', '-at_install') +class TestFusionAssetEngine(TransactionCase): + + def setUp(self): + super().setUp() + self.engine = self.env['fusion.asset.engine'] + self.asset = self.env['fusion.asset'].create({ + 'name': 'Test Engine Asset', + 'cost': 10000, + 'salvage_value': 1000, + 'acquisition_date': date(2026, 1, 1), + 'in_service_date': date(2026, 1, 1), + 'method': 'straight_line', + 'useful_life_years': 5, + }) + + def test_engine_model_exists(self): + self.assertIn('fusion.asset.engine', self.env.registry) + + def test_compute_schedule_straight_line(self): + result = self.engine.compute_depreciation_schedule(self.asset) + self.assertEqual(result['lines_created'], 5) + lines = self.asset.depreciation_line_ids + self.assertEqual(len(lines), 5) + # Total depreciation should equal cost - salvage = 9000 + total = sum(lines.mapped('amount')) + self.assertAlmostEqual(total, 9000, places=2) + + def test_compute_schedule_declining_balance(self): + self.asset.write({'method': 'declining_balance', 'declining_rate_pct': 30.0}) + self.engine.compute_depreciation_schedule(self.asset) + lines = self.asset.depreciation_line_ids + self.assertGreater(len(lines), 0) + # First-period amount should be cost * rate = 10000 * 0.3 = 3000 + first = lines.sorted('period_index')[0] + self.assertAlmostEqual(first.amount, 3000, places=2) + + def test_compute_schedule_recompute_wipes_unposted(self): + self.engine.compute_depreciation_schedule(self.asset) + self.asset.write({'useful_life_years': 8}) + self.engine.compute_depreciation_schedule(self.asset, recompute=True) + self.assertEqual(len(self.asset.depreciation_line_ids), 8) + + def test_compute_schedule_validates_zero_cost(self): + # Bypass DB constraint with sudo + the constraint allows cost >= 0, + # but engine validation requires cost > 0. + bad = self.env['fusion.asset'].create({ + 'name': 'Zero', + 'cost': 0, + 'acquisition_date': date(2026, 1, 1), + 'method': 'straight_line', + 'useful_life_years': 5, + }) + with self.assertRaises(ValidationError): + self.engine.compute_depreciation_schedule(bad) + + def test_post_depreciation_entry_marks_line_posted(self): + self.engine.compute_depreciation_schedule(self.asset) + self.asset.action_set_running() + result = self.engine.post_depreciation_entry(self.asset) + self.assertEqual(result['posted_count'], 1) + first_line = self.asset.depreciation_line_ids.sorted('period_index')[0] + self.assertTrue(first_line.is_posted) + + def test_post_depreciation_only_after_running(self): + self.engine.compute_depreciation_schedule(self.asset) + # asset is still in 'draft' state + with self.assertRaises(ValidationError): + self.engine.post_depreciation_entry(self.asset) + + def test_dispose_asset_creates_disposal_record(self): + self.engine.compute_depreciation_schedule(self.asset) + self.asset.action_set_running() + result = self.engine.dispose_asset( + self.asset, sale_amount=5000, sale_date=date(2027, 6, 1), + ) + self.assertEqual(self.asset.state, 'disposed') + self.assertIn('disposal_id', result) + self.assertEqual(result['book_value_at_disposal'], self.asset.book_value) + + def test_partial_sale_creates_child_and_disposes(self): + self.engine.compute_depreciation_schedule(self.asset) + self.asset.action_set_running() + original_cost = self.asset.cost + result = self.engine.partial_sale( + self.asset, sold_amount=3000, sold_qty=0.3, + sale_date=date(2027, 6, 1), + ) + self.assertIn('parent_asset_id', result) + self.assertIn('child_asset_id', result) + self.asset.invalidate_recordset(['cost']) + expected_remaining = round(original_cost * 0.7, 2) + self.assertAlmostEqual(self.asset.cost, expected_remaining, places=2) + + def test_pause_resume_round_trip(self): + self.asset.action_set_running() + self.engine.pause_asset(self.asset) + self.assertEqual(self.asset.state, 'paused') + self.engine.resume_asset(self.asset) + self.assertEqual(self.asset.state, 'running') + + def test_reverse_disposal_restores_running_state(self): + self.engine.compute_depreciation_schedule(self.asset) + self.asset.action_set_running() + self.engine.dispose_asset(self.asset, sale_amount=5000) + self.assertEqual(self.asset.state, 'disposed') + self.engine.reverse_disposal(self.asset) + self.assertEqual(self.asset.state, 'running') diff --git a/fusion_accounting_assets/tests/test_local_llm_compat.py b/fusion_accounting_assets/tests/test_local_llm_compat.py new file mode 100644 index 00000000..eec86053 --- /dev/null +++ b/fusion_accounting_assets/tests/test_local_llm_compat.py @@ -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) diff --git a/fusion_accounting_assets/tests/test_method_integration.py b/fusion_accounting_assets/tests/test_method_integration.py new file mode 100644 index 00000000..907c8c7d --- /dev/null +++ b/fusion_accounting_assets/tests/test_method_integration.py @@ -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) diff --git a/fusion_accounting_assets/tests/test_migration_round_trip.py b/fusion_accounting_assets/tests/test_migration_round_trip.py new file mode 100644 index 00000000..95dae785 --- /dev/null +++ b/fusion_accounting_assets/tests/test_migration_round_trip.py @@ -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']) diff --git a/fusion_accounting_assets/tests/test_partial_sale_wizard.py b/fusion_accounting_assets/tests/test_partial_sale_wizard.py new file mode 100644 index 00000000..5c16117d --- /dev/null +++ b/fusion_accounting_assets/tests/test_partial_sale_wizard.py @@ -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) diff --git a/fusion_accounting_assets/tests/test_perf_controller.py b/fusion_accounting_assets/tests/test_perf_controller.py new file mode 100644 index 00000000..7cbd6eff --- /dev/null +++ b/fusion_accounting_assets/tests/test_perf_controller.py @@ -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) diff --git a/fusion_accounting_assets/tests/test_performance_benchmarks.py b/fusion_accounting_assets/tests/test_performance_benchmarks.py new file mode 100644 index 00000000..5eb267e0 --- /dev/null +++ b/fusion_accounting_assets/tests/test_performance_benchmarks.py @@ -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) diff --git a/fusion_accounting_assets/tests/test_prorate.py b/fusion_accounting_assets/tests/test_prorate.py new file mode 100644 index 00000000..f0e9792a --- /dev/null +++ b/fusion_accounting_assets/tests/test_prorate.py @@ -0,0 +1,65 @@ +from datetime import date + +from odoo.tests.common import TransactionCase +from odoo.tests import tagged +from odoo.addons.fusion_accounting_assets.services.prorate import prorate_factor + + +@tagged('post_install', '-at_install') +class TestProrate(TransactionCase): + + def test_full_month_convention_always_one(self): + f = prorate_factor( + period_start=date(2026, 1, 1), + period_end=date(2026, 1, 31), + asset_start=date(2026, 1, 15), + convention='full_month', + ) + self.assertEqual(f, 1.0) + + def test_asset_starts_before_period_full_factor(self): + f = prorate_factor( + period_start=date(2026, 1, 1), + period_end=date(2026, 1, 31), + asset_start=date(2025, 12, 1), + convention='days_period', + ) + self.assertEqual(f, 1.0) + + def test_asset_starts_after_period_zero_factor(self): + f = prorate_factor( + period_start=date(2026, 1, 1), + period_end=date(2026, 1, 31), + asset_start=date(2026, 2, 5), + convention='days_period', + ) + self.assertEqual(f, 0.0) + + def test_days_period_mid_month(self): + # Jan 16 -> Jan 31 inclusive = 16 days; period = 31 days + f = prorate_factor( + period_start=date(2026, 1, 1), + period_end=date(2026, 1, 31), + asset_start=date(2026, 1, 16), + convention='days_period', + ) + self.assertAlmostEqual(f, 16 / 31, places=5) + + def test_days_365_mid_month(self): + # 16 days / 365 + f = prorate_factor( + period_start=date(2026, 1, 1), + period_end=date(2026, 1, 31), + asset_start=date(2026, 1, 16), + convention='days_365', + ) + self.assertAlmostEqual(f, 16 / 365.0, places=5) + + def test_unknown_convention_raises(self): + with self.assertRaises(ValueError): + prorate_factor( + period_start=date(2026, 1, 1), + period_end=date(2026, 1, 31), + asset_start=date(2026, 1, 15), + convention='bogus', # type: ignore[arg-type] + ) diff --git a/fusion_accounting_assets/tests/test_salvage_value.py b/fusion_accounting_assets/tests/test_salvage_value.py new file mode 100644 index 00000000..5e6e6efb --- /dev/null +++ b/fusion_accounting_assets/tests/test_salvage_value.py @@ -0,0 +1,45 @@ +from odoo.tests.common import TransactionCase +from odoo.tests import tagged +from odoo.addons.fusion_accounting_assets.services.salvage_value import ( + SalvageConfig, compute_salvage_value, remaining_useful_life_value, +) + + +@tagged('post_install', '-at_install') +class TestSalvageValue(TransactionCase): + + def test_zero_method_returns_zero(self): + v = compute_salvage_value(cost=10000, config=SalvageConfig(method='zero')) + self.assertEqual(v, 0.0) + + def test_percentage_method(self): + v = compute_salvage_value( + cost=10000, config=SalvageConfig(method='percentage', value=10), + ) + self.assertAlmostEqual(v, 1000.0, places=2) + + def test_fixed_method(self): + v = compute_salvage_value( + cost=10000, config=SalvageConfig(method='fixed', value=750), + ) + self.assertAlmostEqual(v, 750.0, places=2) + + def test_unknown_method_raises(self): + with self.assertRaises(ValueError): + compute_salvage_value( + cost=10000, + config=SalvageConfig(method='bogus', value=0), # type: ignore[arg-type] + ) + + def test_remaining_useful_life_value_midway(self): + # Halfway through life; current book 6000, salvage 1000 -> 1000 + 5000*0.5 = 3500 + v = remaining_useful_life_value( + current_book=6000, salvage=1000, periods_used=5, total_periods=10, + ) + self.assertAlmostEqual(v, 3500.0, places=2) + + def test_remaining_useful_life_value_at_end_returns_salvage(self): + v = remaining_useful_life_value( + current_book=1200, salvage=1000, periods_used=10, total_periods=10, + ) + self.assertEqual(v, 1000.0) diff --git a/fusion_accounting_assets/tests/test_useful_life_predictor.py b/fusion_accounting_assets/tests/test_useful_life_predictor.py new file mode 100644 index 00000000..a549c391 --- /dev/null +++ b/fusion_accounting_assets/tests/test_useful_life_predictor.py @@ -0,0 +1,61 @@ +from odoo.tests.common import TransactionCase +from odoo.tests import tagged +from odoo.addons.fusion_accounting_assets.services.useful_life_predictor import ( + predict_useful_life, +) +from odoo.addons.fusion_accounting_assets.services.useful_life_prompt import ( + SYSTEM_PROMPT, build_prompt, +) + + +@tagged('post_install', '-at_install') +class TestUsefulLifePredictor(TransactionCase): + + def setUp(self): + super().setUp() + # Ensure no provider configured for these fallback tests. + self.env['ir.config_parameter'].sudo().search([ + ('key', 'in', [ + 'fusion_accounting.provider.asset_useful_life', + 'fusion_accounting.provider.default', + ]) + ]).unlink() + + def test_fallback_computer(self): + result = predict_useful_life(self.env, description="Dell laptop") + self.assertEqual(result['useful_life_years'], 4) + self.assertEqual(result['depreciation_method'], 'straight_line') + + def test_fallback_furniture(self): + result = predict_useful_life(self.env, description="office desk") + self.assertEqual(result['useful_life_years'], 7) + + def test_fallback_vehicle_uses_declining(self): + result = predict_useful_life(self.env, description="Ford F-150 truck") + self.assertEqual(result['useful_life_years'], 5) + self.assertEqual(result['depreciation_method'], 'declining_balance') + + def test_fallback_default_for_unknown(self): + result = predict_useful_life(self.env, description="mystery widget") + self.assertEqual(result['useful_life_years'], 5) + self.assertEqual(result['confidence'], 0.3) + + def test_returns_dict_with_required_keys(self): + result = predict_useful_life(self.env, description="server") + for key in ('useful_life_years', 'depreciation_method', 'rationale', 'confidence'): + self.assertIn(key, result) + + +@tagged('post_install', '-at_install') +class TestUsefulLifePrompt(TransactionCase): + + def test_system_prompt_requires_json(self): + self.assertIn('JSON', SYSTEM_PROMPT) + + def test_build_prompt_returns_tuple(self): + result = build_prompt(description='test') + self.assertEqual(len(result), 2) + + def test_user_prompt_includes_amount(self): + _, user = build_prompt(description='laptop', amount=2000) + self.assertIn('2,000', user) diff --git a/fusion_accounting_assets/views/menu_views.xml b/fusion_accounting_assets/views/menu_views.xml new file mode 100644 index 00000000..b2a8be08 --- /dev/null +++ b/fusion_accounting_assets/views/menu_views.xml @@ -0,0 +1,68 @@ + + + + + + + + Assets + fusion.asset + list,form + +

+ Manage your fixed assets +

+

+ Track depreciation, post periodic entries, dispose assets at end-of-life. + AI augmentation: anomaly detection + suggested useful life. +

+
+
+ + + + + + Asset Categories + fusion.asset.category + list,form + + + + + + + Asset Anomalies + fusion.asset.anomaly + list,form + + + + + + +
diff --git a/fusion_accounting_assets/wizards/__init__.py b/fusion_accounting_assets/wizards/__init__.py new file mode 100644 index 00000000..dbad0030 --- /dev/null +++ b/fusion_accounting_assets/wizards/__init__.py @@ -0,0 +1,4 @@ +from . import create_asset_wizard +from . import disposal_wizard +from . import partial_sale_wizard +from . import depreciation_run_wizard diff --git a/fusion_accounting_assets/wizards/create_asset_wizard.py b/fusion_accounting_assets/wizards/create_asset_wizard.py new file mode 100644 index 00000000..20dc5dd6 --- /dev/null +++ b/fusion_accounting_assets/wizards/create_asset_wizard.py @@ -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', + } diff --git a/fusion_accounting_assets/wizards/create_asset_wizard_views.xml b/fusion_accounting_assets/wizards/create_asset_wizard_views.xml new file mode 100644 index 00000000..05bbed19 --- /dev/null +++ b/fusion_accounting_assets/wizards/create_asset_wizard_views.xml @@ -0,0 +1,54 @@ + + + + fusion.create.asset.wizard.form + fusion.create.asset.wizard + +
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+ + + Create Asset from Invoice + fusion.create.asset.wizard + form + new + + list + +
diff --git a/fusion_accounting_assets/wizards/depreciation_run_wizard.py b/fusion_accounting_assets/wizards/depreciation_run_wizard.py new file mode 100644 index 00000000..6c463384 --- /dev/null +++ b/fusion_accounting_assets/wizards/depreciation_run_wizard.py @@ -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', + } diff --git a/fusion_accounting_assets/wizards/depreciation_run_wizard_views.xml b/fusion_accounting_assets/wizards/depreciation_run_wizard_views.xml new file mode 100644 index 00000000..bc01e874 --- /dev/null +++ b/fusion_accounting_assets/wizards/depreciation_run_wizard_views.xml @@ -0,0 +1,36 @@ + + + + fusion.depreciation.run.wizard.form + fusion.depreciation.run.wizard + +
+ + + + + + + + + + + + +
+
+ +
+
+ + + Run Depreciation + fusion.depreciation.run.wizard + form + new + +
diff --git a/fusion_accounting_assets/wizards/disposal_wizard.py b/fusion_accounting_assets/wizards/disposal_wizard.py new file mode 100644 index 00000000..6444f1b7 --- /dev/null +++ b/fusion_accounting_assets/wizards/disposal_wizard.py @@ -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', + } diff --git a/fusion_accounting_assets/wizards/disposal_wizard_views.xml b/fusion_accounting_assets/wizards/disposal_wizard_views.xml new file mode 100644 index 00000000..91b8125a --- /dev/null +++ b/fusion_accounting_assets/wizards/disposal_wizard_views.xml @@ -0,0 +1,39 @@ + + + + fusion.disposal.wizard.form + fusion.disposal.wizard + +
+ + + + + + + + + + + + + + +
+
+
+
+
+ + + Dispose Asset + fusion.disposal.wizard + form + new + + form,list + +
diff --git a/fusion_accounting_assets/wizards/partial_sale_wizard.py b/fusion_accounting_assets/wizards/partial_sale_wizard.py new file mode 100644 index 00000000..4524b4c7 --- /dev/null +++ b/fusion_accounting_assets/wizards/partial_sale_wizard.py @@ -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', + } diff --git a/fusion_accounting_assets/wizards/partial_sale_wizard_views.xml b/fusion_accounting_assets/wizards/partial_sale_wizard_views.xml new file mode 100644 index 00000000..8848d5b1 --- /dev/null +++ b/fusion_accounting_assets/wizards/partial_sale_wizard_views.xml @@ -0,0 +1,38 @@ + + + + fusion.partial.sale.wizard.form + fusion.partial.sale.wizard + +
+ + + + + + + + + + + + + + + +
+
+
+
+
+ + + Partial Sale + fusion.partial.sale.wizard + form + new + +