Compare commits
75 Commits
fusion_acc
...
de6d8fda3e
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de6d8fda3e | ||
|
|
9092a78be2 | ||
|
|
79cd0216ff | ||
|
|
3e8b7b1e82 | ||
|
|
345c971d59 | ||
|
|
54922a0b32 | ||
|
|
38a6e375e6 | ||
|
|
8659f51935 | ||
|
|
5c89763191 | ||
|
|
b68d1b1c66 | ||
|
|
0439d81675 | ||
|
|
70e4404d9b | ||
|
|
bc7ba27d77 | ||
|
|
19cbed5b37 | ||
|
|
b7c171f983 | ||
|
|
bece120ee3 | ||
|
|
3e73ca0eb7 | ||
|
|
99b6990dd6 | ||
|
|
fdfaf7e779 | ||
|
|
848aa0f0e5 | ||
|
|
5a864e4b48 | ||
|
|
0618ca7773 | ||
|
|
6a53da6002 | ||
|
|
3c7a1c8cea | ||
|
|
1c773bb5e4 | ||
|
|
5994a1b96b | ||
|
|
e17e7f9e4c | ||
|
|
8de4beb46a | ||
|
|
7d7bd93345 | ||
|
|
23b988c401 | ||
|
|
d1661f3a33 | ||
|
|
8b6dd3aa63 | ||
|
|
4677fae891 | ||
|
|
1918e03485 | ||
|
|
6d020f6419 | ||
|
|
b33e12e587 | ||
|
|
1ffa86b532 | ||
|
|
1f94927f12 | ||
|
|
97640a5ac8 | ||
|
|
9db7271bdf | ||
|
|
0f575dd523 | ||
|
|
16db299145 | ||
|
|
144e90a379 | ||
|
|
118f0d9d16 | ||
|
|
15cf4e129f | ||
|
|
5cdd3e756d | ||
|
|
c20e0888e1 | ||
|
|
22b277c6b8 | ||
|
|
17053b1603 | ||
|
|
a4728d7ae7 | ||
|
|
b78e6dc842 | ||
|
|
5963aba0a8 | ||
|
|
f160a9eeec | ||
|
|
ba95d927c0 | ||
|
|
96ac0131b0 | ||
|
|
cabf51add7 | ||
|
|
0eee14f69a | ||
|
|
9d3b8f7484 | ||
|
|
50f736d8a7 | ||
|
|
e14ad21689 | ||
|
|
0a9ed635e8 | ||
|
|
a93162cb70 | ||
|
|
a90a349fbc | ||
|
|
6d90789967 | ||
|
|
5c3e7a3cf3 | ||
|
|
e01a2a0e35 | ||
|
|
8fc864623b | ||
|
|
11837ed4f5 | ||
|
|
050d3d06a7 | ||
|
|
41336b179f | ||
|
|
f979bc686d | ||
|
|
7fa54d8fc9 | ||
|
|
c7ecd90982 | ||
|
|
2804168d9e | ||
|
|
6e964c230f |
167
fusion_accounting/PHASE_2_PLAN.md
Normal file
167
fusion_accounting/PHASE_2_PLAN.md
Normal file
@@ -0,0 +1,167 @@
|
||||
# Phase 2 — Fusion Accounting Reports Implementation Plan
|
||||
|
||||
**Module:** `fusion_accounting_reports`
|
||||
**Branch:** `fusion_accounting/phase-2-reports`
|
||||
**Pre-phase tag:** `fusion_accounting/pre-phase-2`
|
||||
**Estimated tasks:** 46
|
||||
**Reference:** `/Users/gurpreet/Github/RePackaged-Odoo/accounting/account_reports/`
|
||||
|
||||
## Goal
|
||||
|
||||
Replace Odoo Enterprise's `account_reports` module with a Fusion-native financial reports engine. CORE scope: P&L (income statement), balance sheet, trial balance, general ledger with drill-down. AI augmentation: anomaly detection (variance vs prior period) + AI-generated commentary. Coexists with Enterprise (Enterprise wins by default; Fusion menu shows when Enterprise absent).
|
||||
|
||||
## Architecture (HYBRID engine)
|
||||
|
||||
```
|
||||
fusion.report.engine (AbstractModel) ← shared primitives
|
||||
├── compute_pnl(period, comparison=None)
|
||||
├── compute_balance_sheet(date_to, comparison=None)
|
||||
├── compute_trial_balance(period)
|
||||
├── compute_gl(period, account_ids=None)
|
||||
├── drill_down(report_type, line_id, period)
|
||||
└── _walk_account_hierarchy(root_account_ids)
|
||||
|
||||
services/ ← pure-Python
|
||||
├── date_periods.py → fiscal-period math, comparison-period derivation
|
||||
├── account_hierarchy.py → recursive account tree walk + roll-ups
|
||||
├── totaling.py → balance/credit/debit aggregation rules
|
||||
├── currency_conversion.py → multi-currency revaluation at report date
|
||||
├── anomaly_detection.py → variance vs prior-period statistical flags
|
||||
└── commentary_generator.py → LLM prompt + parse for narrative
|
||||
|
||||
models/
|
||||
├── fusion_report.py → report definition (metadata, line specs)
|
||||
├── fusion_report_engine.py → AbstractModel orchestrator
|
||||
├── fusion_report_pnl.py → P&L definition + execute
|
||||
├── fusion_report_balance_sheet.py
|
||||
├── fusion_report_trial_balance.py
|
||||
├── fusion_report_general_ledger.py
|
||||
├── fusion_report_anomaly.py → persisted flagged variances
|
||||
├── fusion_report_commentary.py → cached AI narratives
|
||||
└── fusion_unreconciled_gl_mv.py → MV for fast GL listing on large DBs
|
||||
|
||||
controllers/bank_rec_controller.py ← 8 JSON-RPC endpoints
|
||||
├── /fusion/reports/run → execute one report
|
||||
├── /fusion/reports/drill_down → drill into a report line
|
||||
├── /fusion/reports/get_anomalies → list flagged variances
|
||||
├── /fusion/reports/get_commentary → fetch / regenerate narrative
|
||||
├── /fusion/reports/compare_periods → side-by-side comparison
|
||||
├── /fusion/reports/export_pdf → PDF export
|
||||
├── /fusion/reports/export_xlsx → XLSX export
|
||||
└── /fusion/reports/list_available → list all report types
|
||||
|
||||
static/src/
|
||||
├── scss/ ← report-specific design tokens
|
||||
├── services/reports_service.js ← reactive state + RPC wrappers
|
||||
├── views/reports_viewer/ ← top-level OWL controller
|
||||
└── components/ ← report_table, drill_down_dialog,
|
||||
period_filter, ai_commentary_panel,
|
||||
anomaly_strip
|
||||
```
|
||||
|
||||
## Coexistence
|
||||
|
||||
Same pattern as Phase 1: `group_fusion_show_when_enterprise_absent` from `fusion_accounting_core`. Reports menu only visible when `account_reports` is NOT installed. Engine + AI tools always available.
|
||||
|
||||
## Tasks (46 total)
|
||||
|
||||
### Group 1: Foundation (tasks 1-2)
|
||||
1. Safety net (tag pre-phase-2, branch phase-2-reports) — **DONE**
|
||||
2. Plan doc + module skeleton
|
||||
|
||||
### Group 2: Engine primitives — TDD layered (tasks 3-8)
|
||||
3. `services/date_periods.py` (fiscal periods, comparison derivation)
|
||||
4. `services/currency_conversion.py` + `services/account_hierarchy.py` + `services/totaling.py`
|
||||
5. `models/fusion_report.py` (report definition model)
|
||||
6. `services/line_resolver.py` (compute report rows from definition)
|
||||
7. `services/drill_down_resolver.py`
|
||||
8. `models/fusion_report_engine.py` (5-method API: compute_pnl, compute_balance_sheet, compute_trial_balance, compute_gl, drill_down)
|
||||
|
||||
### Group 3: Per-report models (tasks 9-12)
|
||||
9. P&L (income statement)
|
||||
10. Balance sheet
|
||||
11. Trial balance
|
||||
12. General ledger
|
||||
|
||||
### Group 4: AI features (tasks 13-17)
|
||||
13. Anomaly detection service (variance vs prior period)
|
||||
14. AI commentary service
|
||||
15. Commentary prompt + LLMProvider integration
|
||||
16. `fusion.report.commentary` persisted model
|
||||
17. `fusion.report.anomaly` persisted model
|
||||
|
||||
### Group 5: Backend wiring (tasks 18-20)
|
||||
18. JSON-RPC controller (8 endpoints)
|
||||
19. ReportsAdapter `_via_fusion` paths
|
||||
20. 5 new AI tools
|
||||
|
||||
### Group 6: Tests + perf (tasks 21-25)
|
||||
21. Property-based tests (totals balance invariant)
|
||||
22. Integration tests — P&L correctness vs known fixtures
|
||||
23. Integration tests — balance sheet + trial balance
|
||||
24. Materialized view for GL
|
||||
25. Cron jobs (anomaly scan + commentary refresh)
|
||||
|
||||
### Group 7: Frontend (tasks 26-33)
|
||||
26. SCSS tokens + main report stylesheet
|
||||
27. `reports_service.js`
|
||||
28. `report_viewer` component (top-level)
|
||||
29. `report_table` component (rows, totals, drill chevrons)
|
||||
30. `drill_down_dialog`
|
||||
31. `period_filter` (date range + comparison toggle)
|
||||
32. `ai_commentary_panel` (Fusion-only)
|
||||
33. `anomaly_strip` (Fusion-only)
|
||||
|
||||
### Group 8: Export + wizards (tasks 34-36)
|
||||
34. PDF export (QWeb template per report)
|
||||
35. XLSX export wizard
|
||||
36. Period selection + comparison wizard
|
||||
|
||||
### Group 9: Migration + coexistence (tasks 37-39)
|
||||
37. Migration wizard inheritance (cache existing definitions)
|
||||
38. Menu + window actions with coexistence group filter
|
||||
39. Coexistence test
|
||||
|
||||
### Group 10: Final tests + polish (tasks 40-46)
|
||||
40. 5 OWL tour tests
|
||||
41. Performance benchmarks
|
||||
42. Optimize if benchmarks fail (conditional)
|
||||
43. Local LLM compat test for commentary
|
||||
44. Update meta-module manifest
|
||||
45. CLAUDE.md, UPGRADE_NOTES.md, README.md
|
||||
46. End-to-end smoke + tag phase-2-complete + push
|
||||
|
||||
## Performance Targets (P95)
|
||||
|
||||
- `engine.compute_pnl` (1 year, 500 accounts): <2s
|
||||
- `engine.compute_balance_sheet`: <2s
|
||||
- `engine.compute_trial_balance`: <1s
|
||||
- `engine.compute_gl` (1 month, all accounts): <3s
|
||||
- `engine.drill_down` (1 line): <500ms
|
||||
- Controller `run` endpoint: <2.5s
|
||||
|
||||
## V19 Conventions (from Phase 1 lessons)
|
||||
|
||||
- `models.Constraint` not `_sql_constraints`
|
||||
- No `@api.depends('id')` on stored compute fields
|
||||
- `@route(type='jsonrpc')` not `type='json'`
|
||||
- `ir.cron` has no `numbercall` field
|
||||
- `res.groups.user_ids` not `users`
|
||||
- `ir.ui.menu.group_ids` not `groups_id`
|
||||
- `res.users.all_group_ids` for searches
|
||||
- `models.Constraint` for unique-keys
|
||||
- Prefer `env.flush_all()` before MV REFRESH
|
||||
|
||||
## Test Targets
|
||||
|
||||
Match Phase 1's test pyramid:
|
||||
- Unit (services pure-Python)
|
||||
- Integration (engine end-to-end with factories)
|
||||
- Property-based (Hypothesis, totals balance invariant)
|
||||
- Controller (HttpCase JSON-RPC)
|
||||
- MV correctness
|
||||
- Performance benchmarks (tagged 'benchmark')
|
||||
- OWL tours (tagged 'tour')
|
||||
- Local LLM smoke (tagged 'local_llm', skips when no LLM)
|
||||
|
||||
Phase 1 final: 157 tests passing. Phase 2 target: ~120-150 additional.
|
||||
165
fusion_accounting/PHASE_3_PLAN.md
Normal file
165
fusion_accounting/PHASE_3_PLAN.md
Normal file
@@ -0,0 +1,165 @@
|
||||
# Phase 3 — Fusion Accounting Assets Implementation Plan
|
||||
|
||||
**Module:** `fusion_accounting_assets`
|
||||
**Branch:** `fusion_accounting/phase-3-assets`
|
||||
**Pre-phase tag:** `fusion_accounting/pre-phase-3`
|
||||
**Estimated tasks:** ~50
|
||||
**Reference:** `/Users/gurpreet/Github/RePackaged-Odoo/accounting/account_asset/` (~2258 LOC Python)
|
||||
|
||||
## Goal
|
||||
|
||||
Replace Odoo Enterprise's `account_asset` module — asset management with depreciation schedules, disposal, partial sale, and reporting. CORE scope: 3 depreciation methods (straight-line, declining balance, units of production), full asset lifecycle, depreciation board, disposal/sale wizards. AI augmentation: utilization anomaly detection + AI-suggested useful life from invoice context. Coexists with Enterprise.
|
||||
|
||||
## Architecture (HYBRID engine, Phase 1+2 pattern)
|
||||
|
||||
```
|
||||
fusion.asset.engine (AbstractModel) ← shared primitives
|
||||
├── compute_depreciation_schedule(asset, recompute=False)
|
||||
├── post_depreciation_entry(asset, period)
|
||||
├── dispose_asset(asset, *, sale_amount, sale_date, sale_partner=None)
|
||||
├── partial_sale(asset, *, sold_amount, sold_qty, sale_date)
|
||||
├── pause_asset(asset, pause_date)
|
||||
├── resume_asset(asset, resume_date)
|
||||
└── reverse_disposal(asset)
|
||||
|
||||
services/ ← pure-Python
|
||||
├── depreciation_methods.py → straight_line, declining_balance, units_of_production
|
||||
├── prorate.py → first/last period prorating (calendar/365/etc.)
|
||||
├── salvage_value.py → end-of-life value math
|
||||
├── anomaly_detection.py → utilization variance vs expected
|
||||
├── useful_life_predictor.py → LLM-suggested useful life from invoice description
|
||||
└── useful_life_prompt.py → provider-agnostic LLM prompt
|
||||
|
||||
models/
|
||||
├── fusion_asset.py → main fusion.asset model
|
||||
├── fusion_asset_depreciation_line.py → depreciation board lines
|
||||
├── fusion_asset_category.py → categories with default settings
|
||||
├── fusion_asset_disposal.py → disposal records
|
||||
├── fusion_asset_anomaly.py → flagged utilization issues
|
||||
├── fusion_asset_engine.py → AbstractModel orchestrator
|
||||
└── account_move.py → inherit (link to asset, generate from invoice)
|
||||
|
||||
controllers/assets_controller.py ← 8 JSON-RPC endpoints
|
||||
├── /fusion/assets/list → paginated asset list with filters
|
||||
├── /fusion/assets/get_detail → single asset with full schedule
|
||||
├── /fusion/assets/compute_schedule → recompute depreciation board
|
||||
├── /fusion/assets/post_depreciation → run periodic depreciation cron
|
||||
├── /fusion/assets/dispose → dispose an asset
|
||||
├── /fusion/assets/get_anomalies → list flagged variances
|
||||
├── /fusion/assets/suggest_useful_life → AI suggest useful life
|
||||
└── /fusion/assets/get_partner_history → asset-related partner history
|
||||
|
||||
static/src/
|
||||
├── scss/ ← asset-specific design tokens
|
||||
├── services/assets_service.js ← reactive state + RPC wrappers
|
||||
├── views/asset_dashboard/ ← top-level OWL controller
|
||||
└── components/ ← asset_card, depreciation_board, disposal_dialog,
|
||||
ai_useful_life_panel, anomaly_strip
|
||||
```
|
||||
|
||||
## Coexistence
|
||||
|
||||
`group_fusion_show_when_enterprise_absent` from `fusion_accounting_core`. Asset menu only visible when `account_asset` NOT installed. Engine + AI tools always available.
|
||||
|
||||
## Tasks (50 total)
|
||||
|
||||
### Group 1: Foundation (1-2)
|
||||
1. Safety net (DONE)
|
||||
2. Plan doc + module skeleton
|
||||
|
||||
### Group 2: Pure-Python services TDD (3-7)
|
||||
3. `services/depreciation_methods.py` — straight_line + declining_balance + units_of_production (TDD)
|
||||
4. `services/prorate.py` — first/last period prorating
|
||||
5. `services/salvage_value.py` — end-of-life math
|
||||
6. `services/anomaly_detection.py` — utilization variance
|
||||
7. `services/useful_life_predictor.py` + `useful_life_prompt.py` — LLM integration
|
||||
|
||||
### Group 3: Persisted models (8-13)
|
||||
8. `models/fusion_asset.py` — main asset model with state machine
|
||||
9. `models/fusion_asset_depreciation_line.py` — depreciation board lines
|
||||
10. `models/fusion_asset_category.py` — categories with defaults
|
||||
11. `models/fusion_asset_disposal.py` — disposal records
|
||||
12. `models/fusion_asset_anomaly.py` — flagged anomalies
|
||||
13. `models/account_move.py` (inherit) — link asset to invoice
|
||||
|
||||
### Group 4: Engine (14-15)
|
||||
14. `models/fusion_asset_engine.py` — 7-method API
|
||||
15. Engine integration tests (compute_schedule + post_depreciation + dispose end-to-end)
|
||||
|
||||
### Group 5: Backend wiring (16-19)
|
||||
16. JSON-RPC controller (8 endpoints)
|
||||
17. AssetsAdapter wiring `_via_fusion` paths
|
||||
18. 5 new AI tools
|
||||
19. Cron — daily depreciation post + monthly anomaly scan
|
||||
|
||||
### Group 6: Tests + perf (20-23)
|
||||
20. Property-based tests (Hypothesis: schedule sums == cost - salvage)
|
||||
21. Integration tests — straight-line + declining-balance + units-of-production
|
||||
22. Materialized view for asset book values (perf)
|
||||
23. Performance benchmarks
|
||||
|
||||
### Group 7: Frontend OWL (24-31)
|
||||
24. SCSS tokens + main asset stylesheet (light + dark)
|
||||
25. `assets_service.js` (reactive state + RPC wrappers)
|
||||
26. `asset_dashboard` (top-level kanban + summary)
|
||||
27. `asset_card` (one asset summary card)
|
||||
28. `asset_detail_panel` (right-side: schedule, history, AI suggestions)
|
||||
29. `depreciation_board` (table view of schedule with edit chevrons)
|
||||
30. `disposal_dialog` (sale/scrap wizard)
|
||||
31. Fusion-only: `ai_useful_life_panel` + `anomaly_strip`
|
||||
|
||||
### Group 8: Wizards (32-35)
|
||||
32. Asset creation wizard (from invoice line)
|
||||
33. Disposal wizard (sale, scrap, donation)
|
||||
34. Partial sale wizard
|
||||
35. Period picker for depreciation runs
|
||||
|
||||
### Group 9: Migration + coexistence (36-39)
|
||||
36. Migration wizard inheritance — backfill from account.asset rows
|
||||
37. Audit report PDF (per-company asset count, total NBV, etc.)
|
||||
38. Menu + window action with coexistence group filter
|
||||
39. Coexistence test
|
||||
|
||||
### Group 10: Final tests + polish (40-50)
|
||||
40. 5 OWL tour tests
|
||||
41. Performance benchmarks (P95: schedule compute < 500ms, board render < 200ms)
|
||||
42. Optimize if benchmarks fail (conditional)
|
||||
43. Local LLM compat test for useful_life_predictor
|
||||
44. Update meta-module manifest
|
||||
45. CLAUDE.md, UPGRADE_NOTES.md, README.md
|
||||
46. End-to-end smoke + tag phase-3-complete + push
|
||||
47-50. Reserved for inherited features: account_move integration, draft journal entries, post-on-confirm flow, fiscal-year-aware proration
|
||||
|
||||
## Performance Targets (P95)
|
||||
|
||||
- `compute_schedule` (10-year asset): <500ms
|
||||
- `post_depreciation_entry`: <200ms
|
||||
- `dispose_asset`: <300ms
|
||||
- Controller `list`: <300ms
|
||||
- Controller `get_detail`: <500ms
|
||||
|
||||
## V19 Conventions (carried from Phase 1+2)
|
||||
|
||||
- `models.Constraint` not `_sql_constraints`
|
||||
- No `@api.depends('id')` on stored compute fields
|
||||
- `@route(type='jsonrpc')` not `type='json'`
|
||||
- `ir.cron` has no `numbercall` field
|
||||
- `res.groups.user_ids` not `users`
|
||||
- `ir.ui.menu.group_ids` not `groups_id`
|
||||
- `models.Constraint` for unique-keys
|
||||
- `env.flush_all()` before MV REFRESH
|
||||
- REFRESH MATERIALIZED VIEW CONCURRENTLY needs autocommit cursor
|
||||
|
||||
## Test Targets
|
||||
|
||||
Match Phase 1+2 test pyramid:
|
||||
- Unit (pure-Python services)
|
||||
- Integration (engine end-to-end)
|
||||
- Property-based (Hypothesis: schedule total invariants)
|
||||
- Controller (HttpCase JSON-RPC)
|
||||
- MV correctness
|
||||
- Performance benchmarks (tagged 'benchmark')
|
||||
- OWL tours (tagged 'tour')
|
||||
- Local LLM smoke (tagged 'local_llm')
|
||||
|
||||
Phase 1+2 final: 287 tests. Phase 3 target: ~140-180 additional → ~430-470 total.
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
'name': 'Fusion Accounting',
|
||||
'version': '19.0.1.0.1',
|
||||
'version': '19.0.1.0.2',
|
||||
'category': 'Accounting/Accounting',
|
||||
'sequence': 25,
|
||||
'summary': 'Meta-module that installs the full Fusion Accounting suite (core, AI, migration; bank rec, reports, etc. as later sub-modules ship).',
|
||||
@@ -14,9 +14,9 @@ Currently installs:
|
||||
- fusion_accounting_ai AI Co-Pilot (Claude/GPT)
|
||||
- fusion_accounting_migration Transitional Enterprise->Fusion data migration
|
||||
- fusion_accounting_bank_rec AI-assisted bank reconciliation (Phase 1)
|
||||
- fusion_accounting_reports AI-augmented financial reports (Phase 2)
|
||||
|
||||
Future sub-modules (added per the roadmap as each Phase ships):
|
||||
- fusion_accounting_reports (Phase 2)
|
||||
- fusion_accounting_dashboard (Phase 3)
|
||||
- fusion_accounting_followup (Phase 5)
|
||||
- fusion_accounting_assets (Phase 6)
|
||||
@@ -34,6 +34,7 @@ Built by Nexa Systems Inc.
|
||||
'fusion_accounting_ai',
|
||||
'fusion_accounting_migration',
|
||||
'fusion_accounting_bank_rec',
|
||||
'fusion_accounting_reports',
|
||||
],
|
||||
'data': [],
|
||||
'installable': True,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -16,7 +16,12 @@ _logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ReportsAdapter(DataAdapter):
|
||||
FUSION_MODEL = 'fusion.account.report'
|
||||
# Phase 2 wires fusion.report.engine as the FUSION-mode backend for
|
||||
# the new report_type-shaped methods (run_fusion_report, get_anomalies,
|
||||
# get_commentary). The legacy ref_id-shaped run_report / export_report
|
||||
# methods continue to defer to community when in FUSION mode (their
|
||||
# original behavior), so this rename does not change their results.
|
||||
FUSION_MODEL = 'fusion.report.engine'
|
||||
ENTERPRISE_MODULE = 'account_reports'
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -167,4 +172,159 @@ class ReportsAdapter(DataAdapter):
|
||||
}
|
||||
|
||||
|
||||
# ==================================================================
|
||||
# Phase 2 (Task 19): fusion.report.engine-routed report methods
|
||||
#
|
||||
# These coexist with the legacy ref_id-shaped run_report/export_report
|
||||
# API. New callers (financial_reports AI tools, OWL widget) use the
|
||||
# *_fusion_report methods below; those route through the engine when
|
||||
# fusion_accounting_reports is installed.
|
||||
# ==================================================================
|
||||
|
||||
# ------------------ run_fusion_report --------------------------
|
||||
|
||||
def run_fusion_report(self, report_type, date_from, date_to,
|
||||
comparison='none', company_id=None):
|
||||
return self._dispatch(
|
||||
'run_fusion_report',
|
||||
report_type=report_type,
|
||||
date_from=date_from, date_to=date_to,
|
||||
comparison=comparison, company_id=company_id,
|
||||
)
|
||||
|
||||
def run_fusion_report_via_fusion(self, report_type, date_from, date_to,
|
||||
comparison='none', company_id=None):
|
||||
if 'fusion.report.engine' not in self.env.registry:
|
||||
return {'rows': [], 'error': 'fusion.report.engine not installed'}
|
||||
from datetime import datetime
|
||||
from odoo.addons.fusion_accounting_reports.services.date_periods import (
|
||||
Period,
|
||||
)
|
||||
df = (datetime.strptime(date_from, '%Y-%m-%d').date()
|
||||
if isinstance(date_from, str) else date_from)
|
||||
dt = (datetime.strptime(date_to, '%Y-%m-%d').date()
|
||||
if isinstance(date_to, str) else date_to)
|
||||
period = Period(date_from=df, date_to=dt, label=f"{df} - {dt}")
|
||||
engine = self.env['fusion.report.engine']
|
||||
company_id = company_id or self.env.company.id
|
||||
if report_type == 'pnl':
|
||||
return engine.compute_pnl(
|
||||
period, comparison=comparison, company_id=company_id,
|
||||
)
|
||||
if report_type == 'balance_sheet':
|
||||
return engine.compute_balance_sheet(
|
||||
dt, comparison=comparison, company_id=company_id,
|
||||
)
|
||||
if report_type == 'trial_balance':
|
||||
return engine.compute_trial_balance(
|
||||
period, company_id=company_id,
|
||||
)
|
||||
if report_type == 'general_ledger':
|
||||
return engine.compute_gl(period, company_id=company_id)
|
||||
return {'rows': [], 'error': f'unknown report_type {report_type}'}
|
||||
|
||||
def run_fusion_report_via_enterprise(self, report_type, date_from, date_to,
|
||||
comparison='none', company_id=None):
|
||||
# Enterprise's account_reports has its own UI; we don't proxy from
|
||||
# Python. Callers should use the Enterprise menus or the legacy
|
||||
# run_report(ref_id=...) method instead.
|
||||
return {
|
||||
'rows': [],
|
||||
'error': 'Enterprise reports must be run from the Enterprise UI',
|
||||
}
|
||||
|
||||
def run_fusion_report_via_community(self, report_type, date_from, date_to,
|
||||
comparison='none', company_id=None):
|
||||
return {
|
||||
'rows': [],
|
||||
'error': 'No fusion reports engine available in pure Community',
|
||||
}
|
||||
|
||||
# ------------------ get_anomalies ------------------------------
|
||||
|
||||
def get_anomalies(self, report_type, date_from, date_to,
|
||||
comparison='previous_year', company_id=None):
|
||||
return self._dispatch(
|
||||
'get_anomalies',
|
||||
report_type=report_type,
|
||||
date_from=date_from, date_to=date_to,
|
||||
comparison=comparison, company_id=company_id,
|
||||
)
|
||||
|
||||
def get_anomalies_via_fusion(self, report_type, date_from, date_to,
|
||||
comparison='previous_year', company_id=None):
|
||||
if 'fusion.report.engine' not in self.env.registry:
|
||||
return {'anomalies': []}
|
||||
from odoo.addons.fusion_accounting_reports.services.anomaly_detection import (
|
||||
detect,
|
||||
)
|
||||
report = self.run_fusion_report_via_fusion(
|
||||
report_type=report_type,
|
||||
date_from=date_from, date_to=date_to,
|
||||
comparison=comparison, company_id=company_id,
|
||||
)
|
||||
if 'error' in report:
|
||||
return {'anomalies': []}
|
||||
return {'anomalies': detect(report)}
|
||||
|
||||
def get_anomalies_via_enterprise(self, report_type, date_from, date_to,
|
||||
comparison='previous_year', company_id=None):
|
||||
return {'anomalies': []}
|
||||
|
||||
def get_anomalies_via_community(self, report_type, date_from, date_to,
|
||||
comparison='previous_year', company_id=None):
|
||||
return {'anomalies': []}
|
||||
|
||||
# ------------------ get_commentary -----------------------------
|
||||
|
||||
def get_commentary(self, report_type, date_from, date_to,
|
||||
comparison='none', company_id=None):
|
||||
return self._dispatch(
|
||||
'get_commentary',
|
||||
report_type=report_type,
|
||||
date_from=date_from, date_to=date_to,
|
||||
comparison=comparison, company_id=company_id,
|
||||
)
|
||||
|
||||
def get_commentary_via_fusion(self, report_type, date_from, date_to,
|
||||
comparison='none', company_id=None):
|
||||
empty = {
|
||||
'summary': '', 'highlights': [],
|
||||
'concerns': [], 'next_actions': [],
|
||||
}
|
||||
if 'fusion.report.engine' not in self.env.registry:
|
||||
return empty
|
||||
from odoo.addons.fusion_accounting_reports.services.anomaly_detection import (
|
||||
detect,
|
||||
)
|
||||
from odoo.addons.fusion_accounting_reports.services.commentary_generator import (
|
||||
generate_commentary,
|
||||
)
|
||||
report = self.run_fusion_report_via_fusion(
|
||||
report_type=report_type,
|
||||
date_from=date_from, date_to=date_to,
|
||||
comparison=comparison, company_id=company_id,
|
||||
)
|
||||
if 'error' in report:
|
||||
return empty
|
||||
anomalies = detect(report)
|
||||
return generate_commentary(
|
||||
self.env, report_result=report, anomalies=anomalies,
|
||||
)
|
||||
|
||||
def get_commentary_via_enterprise(self, report_type, date_from, date_to,
|
||||
comparison='none', company_id=None):
|
||||
return {
|
||||
'summary': '', 'highlights': [],
|
||||
'concerns': [], 'next_actions': [],
|
||||
}
|
||||
|
||||
def get_commentary_via_community(self, report_type, date_from, date_to,
|
||||
comparison='none', company_id=None):
|
||||
return {
|
||||
'summary': '', 'highlights': [],
|
||||
'concerns': [], 'next_actions': [],
|
||||
}
|
||||
|
||||
|
||||
register_adapter('reports', ReportsAdapter)
|
||||
|
||||
@@ -9,11 +9,14 @@ from .inventory import TOOLS as INVENTORY_TOOLS
|
||||
from .adp import TOOLS as ADP_TOOLS
|
||||
from .reporting import TOOLS as REPORTING_TOOLS
|
||||
from .audit import TOOLS as AUDIT_TOOLS
|
||||
from .financial_reports import TOOLS as FINANCIAL_REPORTS_TOOLS
|
||||
from .asset_management import TOOLS as ASSET_MANAGEMENT_TOOLS
|
||||
|
||||
TOOL_DISPATCH = {}
|
||||
for tools_dict in [
|
||||
BANK_RECON_TOOLS, HST_TOOLS, AR_TOOLS, AP_TOOLS, JOURNAL_TOOLS,
|
||||
MONTH_END_TOOLS, PAYROLL_TOOLS, INVENTORY_TOOLS, ADP_TOOLS,
|
||||
REPORTING_TOOLS, AUDIT_TOOLS,
|
||||
REPORTING_TOOLS, AUDIT_TOOLS, FINANCIAL_REPORTS_TOOLS,
|
||||
ASSET_MANAGEMENT_TOOLS,
|
||||
]:
|
||||
TOOL_DISPATCH.update(tools_dict)
|
||||
|
||||
77
fusion_accounting_ai/services/tools/asset_management.py
Normal file
77
fusion_accounting_ai/services/tools/asset_management.py
Normal file
@@ -0,0 +1,77 @@
|
||||
"""Fusion-engine-routed AI tools for asset management."""
|
||||
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def fusion_list_assets(env, params):
|
||||
if 'fusion.asset.engine' not in env.registry:
|
||||
return {'error': 'fusion_accounting_assets not installed'}
|
||||
from ..data_adapters import get_adapter
|
||||
adapter = get_adapter(env, 'assets')
|
||||
return adapter.list_assets(
|
||||
state=params.get('state'),
|
||||
limit=int(params.get('limit', 50)),
|
||||
company_id=int(params['company_id']) if params.get('company_id') else env.company.id,
|
||||
)
|
||||
|
||||
|
||||
def fusion_get_asset_detail(env, params):
|
||||
if 'fusion.asset.engine' not in env.registry:
|
||||
return {'error': 'fusion_accounting_assets not installed'}
|
||||
Asset = env['fusion.asset']
|
||||
asset = Asset.browse(int(params['asset_id']))
|
||||
if not asset.exists():
|
||||
return {'error': 'Asset not found'}
|
||||
return {
|
||||
'asset': {
|
||||
'id': asset.id, 'name': asset.name, 'state': asset.state,
|
||||
'cost': asset.cost, 'book_value': asset.book_value,
|
||||
'total_depreciated': asset.total_depreciated,
|
||||
'method': asset.method, 'useful_life_years': asset.useful_life_years,
|
||||
},
|
||||
'depreciation_count': len(asset.depreciation_line_ids),
|
||||
}
|
||||
|
||||
|
||||
def fusion_compute_asset_schedule(env, params):
|
||||
if 'fusion.asset.engine' not in env.registry:
|
||||
return {'error': 'fusion_accounting_assets not installed'}
|
||||
asset = env['fusion.asset'].browse(int(params['asset_id']))
|
||||
return env['fusion.asset.engine'].compute_depreciation_schedule(
|
||||
asset, recompute=bool(params.get('recompute', False)),
|
||||
)
|
||||
|
||||
|
||||
def fusion_dispose_asset(env, params):
|
||||
if 'fusion.asset.engine' not in env.registry:
|
||||
return {'error': 'fusion_accounting_assets not installed'}
|
||||
from ..data_adapters import get_adapter
|
||||
adapter = get_adapter(env, 'assets')
|
||||
return adapter.dispose_asset(
|
||||
asset_id=int(params['asset_id']),
|
||||
sale_amount=float(params.get('sale_amount', 0)),
|
||||
disposal_type=params.get('disposal_type', 'sale'),
|
||||
)
|
||||
|
||||
|
||||
def fusion_suggest_asset_useful_life(env, params):
|
||||
if 'fusion.asset.engine' not in env.registry:
|
||||
return {'error': 'fusion_accounting_assets not installed'}
|
||||
from ..data_adapters import get_adapter
|
||||
adapter = get_adapter(env, 'assets')
|
||||
return adapter.suggest_useful_life(
|
||||
description=params.get('description', ''),
|
||||
amount=float(params['amount']) if params.get('amount') else None,
|
||||
partner_name=params.get('partner_name'),
|
||||
)
|
||||
|
||||
|
||||
TOOLS = {
|
||||
'fusion_list_assets': fusion_list_assets,
|
||||
'fusion_get_asset_detail': fusion_get_asset_detail,
|
||||
'fusion_compute_asset_schedule': fusion_compute_asset_schedule,
|
||||
'fusion_dispose_asset': fusion_dispose_asset,
|
||||
'fusion_suggest_asset_useful_life': fusion_suggest_asset_useful_life,
|
||||
}
|
||||
127
fusion_accounting_ai/services/tools/financial_reports.py
Normal file
127
fusion_accounting_ai/services/tools/financial_reports.py
Normal file
@@ -0,0 +1,127 @@
|
||||
"""Fusion-engine-routed AI tools for financial reports.
|
||||
|
||||
These 5 tools route through ReportsAdapter's Phase-2 methods
|
||||
(run_fusion_report / get_anomalies / get_commentary), which in turn
|
||||
call fusion.report.engine when fusion_accounting_reports is installed.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _company_id(env, params):
|
||||
raw = params.get('company_id')
|
||||
return int(raw) if raw else env.company.id
|
||||
|
||||
|
||||
def fusion_run_report(env, params):
|
||||
"""Run a fusion financial report.
|
||||
|
||||
Params: report_type (pnl|balance_sheet|trial_balance|general_ledger),
|
||||
date_from, date_to, comparison (none|previous_period|previous_year),
|
||||
optional company_id.
|
||||
"""
|
||||
if 'fusion.report.engine' not in env.registry:
|
||||
return {'error': 'fusion_accounting_reports not installed'}
|
||||
from ..data_adapters import get_adapter
|
||||
adapter = get_adapter(env, 'reports')
|
||||
result = adapter.run_fusion_report(
|
||||
report_type=params.get('report_type'),
|
||||
date_from=params.get('date_from'),
|
||||
date_to=params.get('date_to'),
|
||||
comparison=params.get('comparison', 'none'),
|
||||
company_id=_company_id(env, params),
|
||||
)
|
||||
rows = result.get('rows', [])
|
||||
return {
|
||||
'report_type': params.get('report_type'),
|
||||
'period': result.get('period'),
|
||||
'comparison_period': result.get('comparison_period'),
|
||||
'row_count': len(rows),
|
||||
'rows': rows,
|
||||
}
|
||||
|
||||
|
||||
def fusion_get_anomalies(env, params):
|
||||
"""Detect variance anomalies in a report."""
|
||||
if 'fusion.report.engine' not in env.registry:
|
||||
return {'error': 'fusion_accounting_reports not installed'}
|
||||
from ..data_adapters import get_adapter
|
||||
adapter = get_adapter(env, 'reports')
|
||||
result = adapter.get_anomalies(
|
||||
report_type=params.get('report_type'),
|
||||
date_from=params.get('date_from'),
|
||||
date_to=params.get('date_to'),
|
||||
comparison=params.get('comparison', 'previous_year'),
|
||||
company_id=_company_id(env, params),
|
||||
)
|
||||
anomalies = result.get('anomalies', [])
|
||||
return {'count': len(anomalies), 'anomalies': anomalies}
|
||||
|
||||
|
||||
def fusion_generate_commentary(env, params):
|
||||
"""Generate AI commentary for a report."""
|
||||
if 'fusion.report.engine' not in env.registry:
|
||||
return {'error': 'fusion_accounting_reports not installed'}
|
||||
from ..data_adapters import get_adapter
|
||||
adapter = get_adapter(env, 'reports')
|
||||
result = adapter.get_commentary(
|
||||
report_type=params.get('report_type'),
|
||||
date_from=params.get('date_from'),
|
||||
date_to=params.get('date_to'),
|
||||
comparison=params.get('comparison', 'none'),
|
||||
company_id=_company_id(env, params),
|
||||
)
|
||||
return {
|
||||
'summary': result.get('summary', ''),
|
||||
'highlights': result.get('highlights', []),
|
||||
'concerns': result.get('concerns', []),
|
||||
'next_actions': result.get('next_actions', []),
|
||||
}
|
||||
|
||||
|
||||
def fusion_drill_down_report_line(env, params):
|
||||
"""Drill from a report line into the underlying journal items."""
|
||||
if 'fusion.report.engine' not in env.registry:
|
||||
return {'error': 'fusion_accounting_reports not installed'}
|
||||
from datetime import datetime
|
||||
|
||||
from odoo.addons.fusion_accounting_reports.services.date_periods import (
|
||||
Period,
|
||||
)
|
||||
date_from = params['date_from']
|
||||
date_to = params['date_to']
|
||||
if isinstance(date_from, str):
|
||||
date_from = datetime.strptime(date_from, '%Y-%m-%d').date()
|
||||
if isinstance(date_to, str):
|
||||
date_to = datetime.strptime(date_to, '%Y-%m-%d').date()
|
||||
period = Period(date_from=date_from, date_to=date_to, label='drill')
|
||||
engine = env['fusion.report.engine']
|
||||
rows = engine.drill_down(
|
||||
account_id=int(params['account_id']),
|
||||
period=period,
|
||||
company_id=_company_id(env, params),
|
||||
)
|
||||
return {'count': len(rows), 'rows': rows}
|
||||
|
||||
|
||||
def fusion_compare_periods(env, params):
|
||||
"""Run a report with period comparison side-by-side.
|
||||
|
||||
Defaults comparison to 'previous_year' so callers get a comparison
|
||||
column without specifying it explicitly.
|
||||
"""
|
||||
return fusion_run_report(env, {
|
||||
**params,
|
||||
'comparison': params.get('comparison', 'previous_year'),
|
||||
})
|
||||
|
||||
|
||||
TOOLS = {
|
||||
'fusion_run_report': fusion_run_report,
|
||||
'fusion_get_anomalies': fusion_get_anomalies,
|
||||
'fusion_generate_commentary': fusion_generate_commentary,
|
||||
'fusion_drill_down_report_line': fusion_drill_down_report_line,
|
||||
'fusion_compare_periods': fusion_compare_periods,
|
||||
}
|
||||
3
fusion_accounting_assets/__init__.py
Normal file
3
fusion_accounting_assets/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from . import models
|
||||
from . import services
|
||||
from . import controllers
|
||||
46
fusion_accounting_assets/__manifest__.py
Normal file
46
fusion_accounting_assets/__manifest__.py
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
'name': 'Fusion Accounting Assets',
|
||||
'version': '19.0.1.0.17',
|
||||
'category': 'Accounting/Accounting',
|
||||
'summary': 'AI-augmented asset management with depreciation schedules.',
|
||||
'description': """
|
||||
Fusion Accounting Assets
|
||||
========================
|
||||
|
||||
A Fusion-native replacement for Odoo Enterprise's account_asset module.
|
||||
|
||||
CORE scope (Phase 3):
|
||||
- 3 depreciation methods: straight-line, declining balance, units of production
|
||||
- Asset lifecycle: draft -> running -> paused -> disposed
|
||||
- Depreciation board with editable schedule
|
||||
- Disposal (sale, scrap, donation) + partial sale wizards
|
||||
- Daily cron for posting periodic depreciation
|
||||
|
||||
AI augmentation:
|
||||
- Anomaly detection on utilization vs expected
|
||||
- AI-suggested useful life from invoice context (LLM)
|
||||
|
||||
Coexists with Enterprise: when account_asset is installed, the Fusion
|
||||
menu hides; the engine + AI tools remain available for the chat.
|
||||
""",
|
||||
'author': 'Fusion Accounting',
|
||||
'license': 'LGPL-3',
|
||||
'depends': [
|
||||
'fusion_accounting_core',
|
||||
'fusion_accounting_ai',
|
||||
'account',
|
||||
'mail',
|
||||
],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'data/cron.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
],
|
||||
},
|
||||
'installable': True,
|
||||
'auto_install': False,
|
||||
'application': False,
|
||||
'icon': '/fusion_accounting_assets/static/description/icon.png',
|
||||
}
|
||||
1
fusion_accounting_assets/controllers/__init__.py
Normal file
1
fusion_accounting_assets/controllers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import assets_controller
|
||||
175
fusion_accounting_assets/controllers/assets_controller.py
Normal file
175
fusion_accounting_assets/controllers/assets_controller.py
Normal file
@@ -0,0 +1,175 @@
|
||||
"""HTTP controller: 8 JSON-RPC endpoints for the OWL asset dashboard.
|
||||
|
||||
All endpoints route through fusion.asset.engine. V19 type='jsonrpc'.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import date, datetime
|
||||
|
||||
from odoo import _, http
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.http import request
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _parse_date(value):
|
||||
if isinstance(value, date):
|
||||
return value
|
||||
if not value:
|
||||
return None
|
||||
return datetime.strptime(value, '%Y-%m-%d').date()
|
||||
|
||||
|
||||
class FusionAssetsController(http.Controller):
|
||||
|
||||
@http.route('/fusion/assets/list', type='jsonrpc', auth='user')
|
||||
def list_assets(self, state=None, category_id=None, limit=50, offset=0,
|
||||
company_id=None):
|
||||
company_id = int(company_id) if company_id else request.env.company.id
|
||||
Asset = request.env['fusion.asset'].sudo()
|
||||
domain = [('company_id', '=', company_id)]
|
||||
if state:
|
||||
domain.append(('state', '=', state))
|
||||
if category_id:
|
||||
domain.append(('category_id', '=', int(category_id)))
|
||||
total = Asset.search_count(domain)
|
||||
assets = Asset.search(domain, limit=int(limit), offset=int(offset),
|
||||
order='acquisition_date desc')
|
||||
return {
|
||||
'count': len(assets),
|
||||
'total': total,
|
||||
'assets': [{
|
||||
'id': a.id, 'name': a.name, 'code': a.code or '',
|
||||
'state': a.state, 'cost': a.cost, 'salvage_value': a.salvage_value,
|
||||
'book_value': a.book_value, 'total_depreciated': a.total_depreciated,
|
||||
'method': a.method, 'useful_life_years': a.useful_life_years,
|
||||
'acquisition_date': str(a.acquisition_date),
|
||||
'in_service_date': str(a.in_service_date) if a.in_service_date else None,
|
||||
'category_id': a.category_id.id if a.category_id else None,
|
||||
'category_name': a.category_id.name if a.category_id else None,
|
||||
'currency_code': a.currency_id.name,
|
||||
} for a in assets],
|
||||
}
|
||||
|
||||
@http.route('/fusion/assets/get_detail', type='jsonrpc', auth='user')
|
||||
def get_detail(self, asset_id):
|
||||
asset = request.env['fusion.asset'].browse(int(asset_id))
|
||||
if not asset.exists():
|
||||
raise ValidationError(_("Asset %s not found") % asset_id)
|
||||
return {
|
||||
'asset': {
|
||||
'id': asset.id, 'name': asset.name, 'code': asset.code or '',
|
||||
'state': asset.state, 'cost': asset.cost,
|
||||
'salvage_value': asset.salvage_value,
|
||||
'book_value': asset.book_value,
|
||||
'total_depreciated': asset.total_depreciated,
|
||||
'method': asset.method,
|
||||
'useful_life_years': asset.useful_life_years,
|
||||
'declining_rate_pct': asset.declining_rate_pct,
|
||||
'total_units_expected': asset.total_units_expected,
|
||||
'units_used_to_date': asset.units_used_to_date,
|
||||
'prorate_convention': asset.prorate_convention,
|
||||
'acquisition_date': str(asset.acquisition_date),
|
||||
'in_service_date': str(asset.in_service_date) if asset.in_service_date else None,
|
||||
'disposed_date': str(asset.disposed_date) if asset.disposed_date else None,
|
||||
'category_id': asset.category_id.id if asset.category_id else None,
|
||||
'category_name': asset.category_id.name if asset.category_id else None,
|
||||
'currency_id': asset.currency_id.id,
|
||||
'currency_code': asset.currency_id.name,
|
||||
},
|
||||
'depreciation_lines': [{
|
||||
'id': l.id, 'period_index': l.period_index,
|
||||
'scheduled_date': str(l.scheduled_date),
|
||||
'amount': l.amount, 'accumulated': l.accumulated,
|
||||
'book_value_at_end': l.book_value_at_end,
|
||||
'is_posted': l.is_posted,
|
||||
'posted_date': str(l.posted_date) if l.posted_date else None,
|
||||
} for l in asset.depreciation_line_ids.sorted('period_index')],
|
||||
'anomalies': [{
|
||||
'id': a.id, 'anomaly_type': a.anomaly_type,
|
||||
'severity': a.severity, 'detail': a.detail or '',
|
||||
'state': a.state,
|
||||
} for a in request.env['fusion.asset.anomaly'].search([
|
||||
('asset_id', '=', asset.id), ('state', 'in', ('new', 'acknowledged'))
|
||||
])],
|
||||
}
|
||||
|
||||
@http.route('/fusion/assets/compute_schedule', type='jsonrpc', auth='user')
|
||||
def compute_schedule(self, asset_id, recompute=False):
|
||||
asset = request.env['fusion.asset'].sudo().browse(int(asset_id))
|
||||
engine = request.env['fusion.asset.engine'].sudo()
|
||||
return engine.compute_depreciation_schedule(asset, recompute=bool(recompute))
|
||||
|
||||
@http.route('/fusion/assets/post_depreciation', type='jsonrpc', auth='user')
|
||||
def post_depreciation(self, asset_id, period_date=None):
|
||||
asset = request.env['fusion.asset'].sudo().browse(int(asset_id))
|
||||
engine = request.env['fusion.asset.engine'].sudo()
|
||||
return engine.post_depreciation_entry(asset, period_date=_parse_date(period_date))
|
||||
|
||||
@http.route('/fusion/assets/dispose', type='jsonrpc', auth='user')
|
||||
def dispose(self, asset_id, sale_amount=0, sale_date=None,
|
||||
sale_partner_id=None, disposal_type='sale'):
|
||||
asset = request.env['fusion.asset'].sudo().browse(int(asset_id))
|
||||
engine = request.env['fusion.asset.engine'].sudo()
|
||||
partner = None
|
||||
if sale_partner_id:
|
||||
partner = request.env['res.partner'].sudo().browse(int(sale_partner_id))
|
||||
return engine.dispose_asset(
|
||||
asset, sale_amount=float(sale_amount),
|
||||
sale_date=_parse_date(sale_date),
|
||||
sale_partner=partner, disposal_type=disposal_type,
|
||||
)
|
||||
|
||||
@http.route('/fusion/assets/get_anomalies', type='jsonrpc', auth='user')
|
||||
def get_anomalies(self, asset_id=None, severity=None, state='new', limit=50,
|
||||
company_id=None):
|
||||
company_id = int(company_id) if company_id else request.env.company.id
|
||||
Anomaly = request.env['fusion.asset.anomaly'].sudo()
|
||||
domain = [('company_id', '=', company_id)]
|
||||
if asset_id:
|
||||
domain.append(('asset_id', '=', int(asset_id)))
|
||||
if severity:
|
||||
domain.append(('severity', '=', severity))
|
||||
if state:
|
||||
domain.append(('state', '=', state))
|
||||
anomalies = Anomaly.search(domain, limit=int(limit), order='detected_at desc')
|
||||
return {
|
||||
'count': len(anomalies),
|
||||
'anomalies': [{
|
||||
'id': a.id, 'asset_id': a.asset_id.id, 'asset_name': a.asset_id.name,
|
||||
'anomaly_type': a.anomaly_type, 'severity': a.severity,
|
||||
'expected': a.expected, 'actual': a.actual,
|
||||
'variance_pct': a.variance_pct, 'detail': a.detail or '',
|
||||
'state': a.state,
|
||||
'detected_at': str(a.detected_at),
|
||||
} for a in anomalies],
|
||||
}
|
||||
|
||||
@http.route('/fusion/assets/suggest_useful_life', type='jsonrpc', auth='user')
|
||||
def suggest_useful_life(self, description, amount=None, partner_name=None):
|
||||
from odoo.addons.fusion_accounting_assets.services.useful_life_predictor import (
|
||||
predict_useful_life,
|
||||
)
|
||||
return predict_useful_life(
|
||||
request.env, description=description,
|
||||
amount=float(amount) if amount is not None else None,
|
||||
partner_name=partner_name,
|
||||
)
|
||||
|
||||
@http.route('/fusion/assets/get_partner_history', type='jsonrpc', auth='user')
|
||||
def get_partner_history(self, partner_id, limit=20):
|
||||
Asset = request.env['fusion.asset'].sudo()
|
||||
assets = Asset.search([
|
||||
('source_invoice_line_id.partner_id', '=', int(partner_id)),
|
||||
], limit=int(limit), order='acquisition_date desc')
|
||||
return {
|
||||
'partner_id': int(partner_id),
|
||||
'count': len(assets),
|
||||
'assets': [{
|
||||
'id': a.id, 'name': a.name,
|
||||
'cost': a.cost, 'book_value': a.book_value,
|
||||
'state': a.state,
|
||||
'acquisition_date': str(a.acquisition_date),
|
||||
} for a in assets],
|
||||
}
|
||||
24
fusion_accounting_assets/data/cron.xml
Normal file
24
fusion_accounting_assets/data/cron.xml
Normal file
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
|
||||
<record id="cron_fusion_assets_post_depreciation" model="ir.cron">
|
||||
<field name="name">Fusion Assets — Post Due Depreciation</field>
|
||||
<field name="model_id" ref="model_fusion_assets_cron"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_post_due_depreciation()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="cron_fusion_assets_anomaly_scan" model="ir.cron">
|
||||
<field name="name">Fusion Assets — Monthly Anomaly Scan</field>
|
||||
<field name="model_id" ref="model_fusion_assets_cron"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_anomaly_scan()</field>
|
||||
<field name="interval_number">30</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
8
fusion_accounting_assets/models/__init__.py
Normal file
8
fusion_accounting_assets/models/__init__.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from . import fusion_asset_category
|
||||
from . import fusion_asset
|
||||
from . import fusion_asset_depreciation_line
|
||||
from . import fusion_asset_disposal
|
||||
from . import fusion_asset_anomaly
|
||||
from . import account_move
|
||||
from . import fusion_asset_engine
|
||||
from . import fusion_assets_cron
|
||||
34
fusion_accounting_assets/models/account_move.py
Normal file
34
fusion_accounting_assets/models/account_move.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""Inherit account.move.line to link to fusion.asset records.
|
||||
|
||||
Lets us trace assets back to their source invoice line.
|
||||
"""
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class AccountMoveLine(models.Model):
|
||||
_inherit = "account.move.line"
|
||||
|
||||
fusion_asset_id = fields.Many2one(
|
||||
'fusion.asset', string='Created Asset',
|
||||
copy=False, ondelete='set null',
|
||||
help="Fusion asset record created from this invoice line.",
|
||||
)
|
||||
|
||||
fusion_asset_count = fields.Integer(compute='_compute_fusion_asset_count')
|
||||
|
||||
def _compute_fusion_asset_count(self):
|
||||
for line in self:
|
||||
line.fusion_asset_count = 1 if line.fusion_asset_id else 0
|
||||
|
||||
def action_open_fusion_asset(self):
|
||||
self.ensure_one()
|
||||
if not self.fusion_asset_id:
|
||||
return
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'fusion.asset',
|
||||
'res_id': self.fusion_asset_id.id,
|
||||
'view_mode': 'form',
|
||||
'target': 'current',
|
||||
}
|
||||
164
fusion_accounting_assets/models/fusion_asset.py
Normal file
164
fusion_accounting_assets/models/fusion_asset.py
Normal file
@@ -0,0 +1,164 @@
|
||||
"""Fusion Asset model.
|
||||
|
||||
Lifecycle: draft -> running -> (paused -> running)* -> disposed.
|
||||
- draft: created, not yet running depreciation
|
||||
- running: depreciation board active, periodic posts happen
|
||||
- paused: depreciation suspended (e.g. asset out for repair)
|
||||
- disposed: sold/scrapped/donated; no further depreciation
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
METHOD_SELECTION = [
|
||||
('straight_line', 'Straight Line'),
|
||||
('declining_balance', 'Declining Balance'),
|
||||
('units_of_production', 'Units of Production'),
|
||||
]
|
||||
|
||||
PRORATE_SELECTION = [
|
||||
('full_month', 'Full Month'),
|
||||
('days_365', 'Days / 365'),
|
||||
('days_period', 'Days in Period'),
|
||||
]
|
||||
|
||||
STATE_SELECTION = [
|
||||
('draft', 'Draft'),
|
||||
('running', 'Running'),
|
||||
('paused', 'Paused'),
|
||||
('disposed', 'Disposed'),
|
||||
]
|
||||
|
||||
|
||||
class FusionAsset(models.Model):
|
||||
_name = "fusion.asset"
|
||||
_description = "Fusion Fixed Asset"
|
||||
_order = "acquisition_date desc, id desc"
|
||||
_inherit = ['mail.thread', 'mail.activity.mixin']
|
||||
|
||||
name = fields.Char(required=True, tracking=True)
|
||||
code = fields.Char(help="Internal asset code (e.g. tag number).")
|
||||
company_id = fields.Many2one(
|
||||
'res.company', required=True,
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
category_id = fields.Many2one('fusion.asset.category', tracking=True)
|
||||
state = fields.Selection(
|
||||
STATE_SELECTION, default='draft', required=True, tracking=True,
|
||||
)
|
||||
|
||||
cost = fields.Monetary(
|
||||
required=True, tracking=True,
|
||||
help="Original acquisition cost.",
|
||||
)
|
||||
salvage_value = fields.Monetary(
|
||||
default=0.0, tracking=True,
|
||||
help="Estimated end-of-life value.",
|
||||
)
|
||||
acquisition_date = fields.Date(
|
||||
required=True, default=fields.Date.today, tracking=True,
|
||||
)
|
||||
in_service_date = fields.Date(
|
||||
tracking=True,
|
||||
help="Date depreciation actually begins.",
|
||||
)
|
||||
disposed_date = fields.Date(readonly=True, tracking=True)
|
||||
currency_id = fields.Many2one(
|
||||
'res.currency', required=True,
|
||||
default=lambda self: self.env.company.currency_id,
|
||||
)
|
||||
|
||||
method = fields.Selection(
|
||||
METHOD_SELECTION, required=True, default='straight_line', tracking=True,
|
||||
)
|
||||
useful_life_years = fields.Integer(
|
||||
default=5, tracking=True,
|
||||
help="For straight_line / declining_balance.",
|
||||
)
|
||||
declining_rate_pct = fields.Float(
|
||||
default=20.0,
|
||||
help="For declining_balance method, e.g. 20.0 = 20%/year.",
|
||||
)
|
||||
total_units_expected = fields.Float(
|
||||
help="For units_of_production method.",
|
||||
)
|
||||
units_used_to_date = fields.Float(
|
||||
default=0.0,
|
||||
help="For units_of_production: track usage.",
|
||||
)
|
||||
prorate_convention = fields.Selection(
|
||||
PRORATE_SELECTION, default='days_period', required=True,
|
||||
)
|
||||
|
||||
source_invoice_line_id = fields.Many2one(
|
||||
'account.move.line', string='Source Invoice Line',
|
||||
help="The invoice line that originated this asset.",
|
||||
)
|
||||
parent_id = fields.Many2one(
|
||||
'fusion.asset', help='For partial-sale child assets.',
|
||||
)
|
||||
|
||||
depreciation_line_ids = fields.One2many(
|
||||
'fusion.asset.depreciation.line', 'asset_id',
|
||||
string='Depreciation Lines',
|
||||
)
|
||||
book_value = fields.Monetary(compute='_compute_book_value', store=True)
|
||||
total_depreciated = fields.Monetary(compute='_compute_book_value', store=True)
|
||||
last_posted_date = fields.Date(compute='_compute_last_posted_date', store=True)
|
||||
|
||||
@api.depends('cost', 'depreciation_line_ids.amount', 'depreciation_line_ids.is_posted')
|
||||
def _compute_book_value(self):
|
||||
for asset in self:
|
||||
posted = sum(l.amount for l in asset.depreciation_line_ids if l.is_posted)
|
||||
asset.total_depreciated = posted
|
||||
asset.book_value = asset.cost - posted
|
||||
|
||||
@api.depends('depreciation_line_ids.is_posted', 'depreciation_line_ids.scheduled_date')
|
||||
def _compute_last_posted_date(self):
|
||||
for asset in self:
|
||||
posted_dates = [
|
||||
l.scheduled_date for l in asset.depreciation_line_ids if l.is_posted
|
||||
]
|
||||
asset.last_posted_date = max(posted_dates) if posted_dates else False
|
||||
|
||||
def action_set_running(self):
|
||||
for asset in self:
|
||||
if asset.state != 'draft':
|
||||
raise ValidationError(_("Only draft assets can be set running."))
|
||||
if not asset.in_service_date:
|
||||
asset.in_service_date = fields.Date.today()
|
||||
asset.state = 'running'
|
||||
|
||||
def action_pause(self):
|
||||
for asset in self:
|
||||
if asset.state != 'running':
|
||||
raise ValidationError(_("Only running assets can be paused."))
|
||||
asset.state = 'paused'
|
||||
|
||||
def action_resume(self):
|
||||
for asset in self:
|
||||
if asset.state != 'paused':
|
||||
raise ValidationError(_("Only paused assets can be resumed."))
|
||||
asset.state = 'running'
|
||||
|
||||
def action_set_draft(self):
|
||||
for asset in self:
|
||||
if asset.state not in ('draft', 'paused'):
|
||||
raise ValidationError(
|
||||
_("Cannot reset to draft from %s.") % asset.state,
|
||||
)
|
||||
asset.state = 'draft'
|
||||
|
||||
_check_cost_positive = models.Constraint(
|
||||
'CHECK(cost >= 0)',
|
||||
'Asset cost must be non-negative.',
|
||||
)
|
||||
_check_salvage_lte_cost = models.Constraint(
|
||||
'CHECK(salvage_value >= 0 AND salvage_value <= cost)',
|
||||
'Salvage value must be between 0 and cost.',
|
||||
)
|
||||
42
fusion_accounting_assets/models/fusion_asset_anomaly.py
Normal file
42
fusion_accounting_assets/models/fusion_asset_anomaly.py
Normal file
@@ -0,0 +1,42 @@
|
||||
"""Persisted asset anomaly flags from the engine's variance detection."""
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
SEVERITY = [('low', 'Low'), ('medium', 'Medium'), ('high', 'High')]
|
||||
ANOMALY_TYPES = [
|
||||
('behind_schedule', 'Behind Schedule'),
|
||||
('ahead_of_schedule', 'Ahead of Schedule'),
|
||||
('low_utilization', 'Low Utilization'),
|
||||
]
|
||||
|
||||
|
||||
class FusionAssetAnomaly(models.Model):
|
||||
_name = "fusion.asset.anomaly"
|
||||
_description = "Flagged Asset Anomaly"
|
||||
_order = "detected_at desc, severity desc"
|
||||
|
||||
asset_id = fields.Many2one('fusion.asset', required=True, ondelete='cascade')
|
||||
company_id = fields.Many2one(related='asset_id.company_id', store=True)
|
||||
anomaly_type = fields.Selection(ANOMALY_TYPES, required=True)
|
||||
severity = fields.Selection(SEVERITY, required=True)
|
||||
expected = fields.Float()
|
||||
actual = fields.Float()
|
||||
variance_pct = fields.Float()
|
||||
detail = fields.Text()
|
||||
detected_at = fields.Datetime(default=fields.Datetime.now, required=True)
|
||||
state = fields.Selection([
|
||||
('new', 'New'),
|
||||
('acknowledged', 'Acknowledged'),
|
||||
('resolved', 'Resolved'),
|
||||
('dismissed', 'Dismissed'),
|
||||
], default='new', required=True)
|
||||
|
||||
def action_acknowledge(self):
|
||||
self.write({'state': 'acknowledged'})
|
||||
|
||||
def action_dismiss(self):
|
||||
self.write({'state': 'dismissed'})
|
||||
|
||||
def action_resolve(self):
|
||||
self.write({'state': 'resolved'})
|
||||
53
fusion_accounting_assets/models/fusion_asset_category.py
Normal file
53
fusion_accounting_assets/models/fusion_asset_category.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Asset categories with default settings (used as templates)."""
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class FusionAssetCategory(models.Model):
|
||||
_name = "fusion.asset.category"
|
||||
_description = "Fusion Asset Category"
|
||||
_order = "sequence, name"
|
||||
|
||||
name = fields.Char(required=True, translate=True)
|
||||
sequence = fields.Integer(default=10)
|
||||
company_id = fields.Many2one(
|
||||
'res.company', default=lambda self: self.env.company,
|
||||
)
|
||||
|
||||
method = fields.Selection([
|
||||
('straight_line', 'Straight Line'),
|
||||
('declining_balance', 'Declining Balance'),
|
||||
('units_of_production', 'Units of Production'),
|
||||
], default='straight_line', required=True)
|
||||
useful_life_years = fields.Integer(default=5)
|
||||
declining_rate_pct = fields.Float(default=20.0)
|
||||
salvage_value_pct = fields.Float(
|
||||
default=0.0,
|
||||
help="% of cost (used for new assets in this category).",
|
||||
)
|
||||
prorate_convention = fields.Selection([
|
||||
('full_month', 'Full Month'),
|
||||
('days_365', 'Days / 365'),
|
||||
('days_period', 'Days in Period'),
|
||||
], default='days_period', required=True)
|
||||
|
||||
asset_account_id = fields.Many2one(
|
||||
'account.account', string='Asset Account',
|
||||
domain="[('account_type', 'in', ('asset_fixed', 'asset_non_current'))]",
|
||||
)
|
||||
depreciation_account_id = fields.Many2one(
|
||||
'account.account', string='Depreciation Account',
|
||||
domain="[('account_type', '=', 'asset_fixed')]",
|
||||
)
|
||||
expense_account_id = fields.Many2one(
|
||||
'account.account', string='Expense Account',
|
||||
domain="[('account_type', '=', 'expense_depreciation')]",
|
||||
)
|
||||
|
||||
asset_count = fields.Integer(compute='_compute_asset_count')
|
||||
|
||||
def _compute_asset_count(self):
|
||||
for cat in self:
|
||||
cat.asset_count = self.env['fusion.asset'].search_count([
|
||||
('category_id', '=', cat.id),
|
||||
])
|
||||
@@ -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.',
|
||||
)
|
||||
56
fusion_accounting_assets/models/fusion_asset_disposal.py
Normal file
56
fusion_accounting_assets/models/fusion_asset_disposal.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Asset disposal records (sale, scrap, donation)."""
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
DISPOSAL_TYPES = [
|
||||
('sale', 'Sale'),
|
||||
('scrap', 'Scrap'),
|
||||
('donation', 'Donation'),
|
||||
('lost', 'Lost / Stolen'),
|
||||
]
|
||||
|
||||
|
||||
class FusionAssetDisposal(models.Model):
|
||||
_name = "fusion.asset.disposal"
|
||||
_description = "Asset Disposal Record"
|
||||
_order = "disposal_date desc, id desc"
|
||||
_inherit = ['mail.thread']
|
||||
|
||||
asset_id = fields.Many2one(
|
||||
'fusion.asset', required=True, ondelete='restrict', tracking=True,
|
||||
)
|
||||
company_id = fields.Many2one(related='asset_id.company_id', store=True)
|
||||
currency_id = fields.Many2one(related='asset_id.currency_id', store=True)
|
||||
|
||||
disposal_type = fields.Selection(
|
||||
DISPOSAL_TYPES, required=True, default='sale', tracking=True,
|
||||
)
|
||||
disposal_date = fields.Date(
|
||||
required=True, default=fields.Date.today, tracking=True,
|
||||
)
|
||||
sale_amount = fields.Monetary(
|
||||
default=0.0, tracking=True,
|
||||
help="Cash received (for sale disposal type).",
|
||||
)
|
||||
sale_partner_id = fields.Many2one('res.partner', tracking=True)
|
||||
|
||||
book_value_at_disposal = fields.Monetary(
|
||||
readonly=True,
|
||||
help="Asset book value at disposal date.",
|
||||
)
|
||||
gain_loss_amount = fields.Monetary(compute='_compute_gain_loss', store=True)
|
||||
notes = fields.Text()
|
||||
|
||||
move_id = fields.Many2one(
|
||||
'account.move', readonly=True, copy=False,
|
||||
help="Journal entry created for this disposal.",
|
||||
)
|
||||
|
||||
@api.depends('sale_amount', 'book_value_at_disposal', 'disposal_type')
|
||||
def _compute_gain_loss(self):
|
||||
for d in self:
|
||||
if d.disposal_type == 'sale':
|
||||
d.gain_loss_amount = d.sale_amount - d.book_value_at_disposal
|
||||
else:
|
||||
d.gain_loss_amount = -d.book_value_at_disposal
|
||||
398
fusion_accounting_assets/models/fusion_asset_engine.py
Normal file
398
fusion_accounting_assets/models/fusion_asset_engine.py
Normal file
@@ -0,0 +1,398 @@
|
||||
"""The asset engine — orchestrator for all asset depreciation + lifecycle.
|
||||
|
||||
7-method public API. No direct ORM writes to fusion.asset.depreciation.line
|
||||
or account.move from anywhere else; everything routes through here for
|
||||
consistent validation, audit, and side-effect handling.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import date, timedelta
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
from ..services.depreciation_methods import (
|
||||
straight_line,
|
||||
declining_balance,
|
||||
units_of_production,
|
||||
)
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionAssetEngine(models.AbstractModel):
|
||||
_name = "fusion.asset.engine"
|
||||
_description = "Fusion Asset Engine"
|
||||
|
||||
# ============================================================
|
||||
# PUBLIC API (7 methods)
|
||||
# ============================================================
|
||||
|
||||
@api.model
|
||||
def compute_depreciation_schedule(self, asset, *, recompute: bool = False) -> dict:
|
||||
"""Compute (or re-compute) the depreciation board for an asset.
|
||||
|
||||
If recompute=False and posted lines exist, ONLY un-posted future lines
|
||||
are regenerated. If recompute=True, all unposted lines are wiped and
|
||||
regenerated from scratch using current asset config.
|
||||
"""
|
||||
if not asset:
|
||||
raise ValidationError(_("asset is required"))
|
||||
asset.ensure_one()
|
||||
|
||||
self._validate_asset_for_schedule(asset)
|
||||
|
||||
Line = self.env['fusion.asset.depreciation.line'].sudo()
|
||||
if recompute:
|
||||
Line.search([
|
||||
('asset_id', '=', asset.id),
|
||||
('is_posted', '=', False),
|
||||
]).unlink()
|
||||
|
||||
existing_posted = Line.search([
|
||||
('asset_id', '=', asset.id),
|
||||
('is_posted', '=', True),
|
||||
], order='period_index')
|
||||
start_period = max([l.period_index for l in existing_posted], default=-1) + 1
|
||||
accumulated_so_far = sum(l.amount for l in existing_posted)
|
||||
|
||||
steps = self._compute_steps(asset)
|
||||
new_steps = steps[start_period:]
|
||||
|
||||
base_date = asset.in_service_date or asset.acquisition_date
|
||||
|
||||
# Accumulated baseline at the boundary between posted and to-be-created
|
||||
# lines: subtract the accumulated value the algorithm itself reports at
|
||||
# that boundary, then re-add the actually-posted total. This keeps the
|
||||
# board's accumulated column monotonic when picking up mid-life.
|
||||
baseline_offset = 0.0
|
||||
if start_period > 0 and start_period <= len(steps):
|
||||
baseline_offset = steps[start_period - 1].accumulated_depreciation
|
||||
|
||||
line_vals = []
|
||||
for s in new_steps:
|
||||
scheduled_date = self._add_periods(base_date, s.period_index)
|
||||
running_accumulated = round(
|
||||
accumulated_so_far + s.accumulated_depreciation - baseline_offset, 2
|
||||
)
|
||||
line_vals.append({
|
||||
'asset_id': asset.id,
|
||||
'period_index': s.period_index,
|
||||
'scheduled_date': scheduled_date,
|
||||
'amount': s.period_amount,
|
||||
'accumulated': running_accumulated,
|
||||
'book_value_at_end': s.book_value_at_end,
|
||||
'is_posted': False,
|
||||
})
|
||||
if line_vals:
|
||||
Line.create(line_vals)
|
||||
|
||||
return {
|
||||
'asset_id': asset.id,
|
||||
'lines_created': len(line_vals),
|
||||
'total_lines': len(asset.depreciation_line_ids),
|
||||
'method': asset.method,
|
||||
}
|
||||
|
||||
@api.model
|
||||
def post_depreciation_entry(self, asset, *, period_date: date = None) -> dict:
|
||||
"""Post the next-due un-posted depreciation line.
|
||||
|
||||
If period_date provided, post all lines whose scheduled_date <= period_date.
|
||||
Otherwise, post the single next un-posted line (the earliest one).
|
||||
"""
|
||||
asset.ensure_one()
|
||||
if asset.state != 'running':
|
||||
raise ValidationError(
|
||||
_("Cannot post depreciation for asset in state %s") % asset.state
|
||||
)
|
||||
|
||||
Line = self.env['fusion.asset.depreciation.line'].sudo()
|
||||
domain = [('asset_id', '=', asset.id), ('is_posted', '=', False)]
|
||||
if period_date:
|
||||
domain.append(('scheduled_date', '<=', period_date))
|
||||
unposted = Line.search(domain, order='scheduled_date, period_index')
|
||||
if not unposted:
|
||||
return {'posted_count': 0, 'reason': 'no unposted lines due'}
|
||||
|
||||
if not period_date:
|
||||
unposted = unposted[:1]
|
||||
|
||||
posted_ids = []
|
||||
for line in unposted:
|
||||
self._create_journal_entry(asset, line)
|
||||
line.action_post()
|
||||
posted_ids.append(line.id)
|
||||
|
||||
return {'posted_count': len(posted_ids), 'posted_line_ids': posted_ids}
|
||||
|
||||
@api.model
|
||||
def dispose_asset(self, asset, *, sale_amount: float = 0.0,
|
||||
sale_date: date = None, sale_partner=None,
|
||||
disposal_type: str = 'sale') -> dict:
|
||||
"""Dispose an asset (sale, scrap, donation, lost)."""
|
||||
asset.ensure_one()
|
||||
if asset.state == 'disposed':
|
||||
raise ValidationError(_("Asset already disposed."))
|
||||
sale_date = sale_date or fields.Date.today()
|
||||
|
||||
Line = self.env['fusion.asset.depreciation.line'].sudo()
|
||||
future_unposted = Line.search([
|
||||
('asset_id', '=', asset.id),
|
||||
('is_posted', '=', False),
|
||||
('scheduled_date', '>', sale_date),
|
||||
])
|
||||
future_unposted.unlink()
|
||||
|
||||
asset.invalidate_recordset(['book_value', 'total_depreciated'])
|
||||
book_value = asset.book_value
|
||||
|
||||
Disposal = self.env['fusion.asset.disposal'].sudo()
|
||||
partner_id = False
|
||||
if sale_partner:
|
||||
partner_id = sale_partner.id if hasattr(sale_partner, 'id') else sale_partner
|
||||
disposal = Disposal.create({
|
||||
'asset_id': asset.id,
|
||||
'disposal_type': disposal_type,
|
||||
'disposal_date': sale_date,
|
||||
'sale_amount': sale_amount,
|
||||
'sale_partner_id': partner_id,
|
||||
'book_value_at_disposal': book_value,
|
||||
})
|
||||
|
||||
asset.write({
|
||||
'state': 'disposed',
|
||||
'disposed_date': sale_date,
|
||||
})
|
||||
|
||||
return {
|
||||
'asset_id': asset.id,
|
||||
'disposal_id': disposal.id,
|
||||
'gain_loss_amount': disposal.gain_loss_amount,
|
||||
'book_value_at_disposal': book_value,
|
||||
}
|
||||
|
||||
@api.model
|
||||
def partial_sale(self, asset, *, sold_amount: float, sold_qty: float = None,
|
||||
sale_date: date = None, sale_partner=None) -> dict:
|
||||
"""Partially dispose: split asset into two — sold child + remaining parent.
|
||||
|
||||
sold_amount is cash received for the sold portion.
|
||||
sold_qty is the ratio of original cost to attribute to the sold portion (0..1).
|
||||
If sold_qty is None, defaults to sold_amount / cost.
|
||||
"""
|
||||
asset.ensure_one()
|
||||
if asset.state == 'disposed':
|
||||
raise ValidationError(_("Cannot partially sell a disposed asset."))
|
||||
if sold_qty is None:
|
||||
sold_qty = sold_amount / asset.cost if asset.cost else 0
|
||||
if not (0 < sold_qty < 1):
|
||||
raise ValidationError(
|
||||
_("sold_qty must be strictly between 0 and 1; got %s") % sold_qty
|
||||
)
|
||||
|
||||
sale_date = sale_date or fields.Date.today()
|
||||
|
||||
Asset = self.env['fusion.asset'].sudo()
|
||||
sold_cost = round(asset.cost * sold_qty, 2)
|
||||
sold_salvage = round(asset.salvage_value * sold_qty, 2)
|
||||
child_vals = {
|
||||
'name': f"{asset.name} (sold portion)",
|
||||
'parent_id': asset.id,
|
||||
'cost': sold_cost,
|
||||
'salvage_value': sold_salvage,
|
||||
'acquisition_date': asset.acquisition_date,
|
||||
'in_service_date': asset.in_service_date,
|
||||
'method': asset.method,
|
||||
'useful_life_years': asset.useful_life_years,
|
||||
'declining_rate_pct': asset.declining_rate_pct,
|
||||
'prorate_convention': asset.prorate_convention,
|
||||
'company_id': asset.company_id.id,
|
||||
'state': 'running',
|
||||
}
|
||||
if asset.category_id:
|
||||
child_vals['category_id'] = asset.category_id.id
|
||||
child = Asset.create(child_vals)
|
||||
|
||||
new_cost = round(asset.cost - sold_cost, 2)
|
||||
new_salvage = round(asset.salvage_value - sold_salvage, 2)
|
||||
asset.write({
|
||||
'cost': new_cost,
|
||||
'salvage_value': new_salvage,
|
||||
})
|
||||
self.compute_depreciation_schedule(asset, recompute=True)
|
||||
|
||||
result = self.dispose_asset(
|
||||
child, sale_amount=sold_amount, sale_date=sale_date,
|
||||
sale_partner=sale_partner, disposal_type='sale',
|
||||
)
|
||||
return {
|
||||
'parent_asset_id': asset.id,
|
||||
'child_asset_id': child.id,
|
||||
'disposal_id': result['disposal_id'],
|
||||
'gain_loss_amount': result['gain_loss_amount'],
|
||||
}
|
||||
|
||||
@api.model
|
||||
def pause_asset(self, asset, pause_date: date = None) -> dict:
|
||||
"""Pause depreciation. Wraps asset.action_pause for API symmetry and
|
||||
to log the pause date for downstream auditing."""
|
||||
asset.ensure_one()
|
||||
asset.action_pause()
|
||||
return {
|
||||
'asset_id': asset.id,
|
||||
'pause_date': pause_date or fields.Date.today(),
|
||||
'state': 'paused',
|
||||
}
|
||||
|
||||
@api.model
|
||||
def resume_asset(self, asset, resume_date: date = None) -> dict:
|
||||
"""Resume a paused asset."""
|
||||
asset.ensure_one()
|
||||
asset.action_resume()
|
||||
return {
|
||||
'asset_id': asset.id,
|
||||
'resume_date': resume_date or fields.Date.today(),
|
||||
'state': 'running',
|
||||
}
|
||||
|
||||
@api.model
|
||||
def reverse_disposal(self, asset) -> dict:
|
||||
"""Reverse a disposal (rare — recovery from accidental sale entry)."""
|
||||
asset.ensure_one()
|
||||
if asset.state != 'disposed':
|
||||
raise ValidationError(_("Asset is not disposed."))
|
||||
|
||||
Disposal = self.env['fusion.asset.disposal'].sudo()
|
||||
last_disposal = Disposal.search(
|
||||
[('asset_id', '=', asset.id)],
|
||||
order='disposal_date desc, id desc', limit=1,
|
||||
)
|
||||
if last_disposal and last_disposal.move_id:
|
||||
try:
|
||||
last_disposal.move_id.button_cancel()
|
||||
except Exception as e: # noqa: BLE001
|
||||
_logger.warning("Could not cancel disposal move: %s", e)
|
||||
if last_disposal:
|
||||
last_disposal.unlink()
|
||||
asset.write({'state': 'running', 'disposed_date': False})
|
||||
return {'asset_id': asset.id, 'state': 'running'}
|
||||
|
||||
# ============================================================
|
||||
# PRIVATE HELPERS
|
||||
# ============================================================
|
||||
|
||||
def _validate_asset_for_schedule(self, asset):
|
||||
if asset.cost <= 0:
|
||||
raise ValidationError(_("Asset cost must be > 0 to compute schedule."))
|
||||
if asset.method == 'units_of_production' and not asset.total_units_expected:
|
||||
raise ValidationError(_(
|
||||
"Units of Production assets need total_units_expected set."
|
||||
))
|
||||
if asset.method in ('straight_line', 'declining_balance'):
|
||||
if asset.useful_life_years < 1:
|
||||
raise ValidationError(_("useful_life_years must be >= 1."))
|
||||
if asset.salvage_value > asset.cost:
|
||||
raise ValidationError(_("Salvage value cannot exceed cost."))
|
||||
|
||||
def _compute_steps(self, asset) -> list:
|
||||
"""Dispatch to the appropriate depreciation method service."""
|
||||
if asset.method == 'straight_line':
|
||||
return straight_line(
|
||||
cost=asset.cost,
|
||||
salvage_value=asset.salvage_value,
|
||||
n_periods=asset.useful_life_years,
|
||||
)
|
||||
if asset.method == 'declining_balance':
|
||||
return declining_balance(
|
||||
cost=asset.cost,
|
||||
salvage_value=asset.salvage_value,
|
||||
n_periods=asset.useful_life_years,
|
||||
rate=asset.declining_rate_pct / 100.0,
|
||||
)
|
||||
if asset.method == 'units_of_production':
|
||||
# Phase 3 simple: assume even per-period units. Phase 3.5 can read
|
||||
# from a per-period usage table populated by maintenance/IoT data.
|
||||
if asset.useful_life_years:
|
||||
per_period = asset.total_units_expected / asset.useful_life_years
|
||||
periods = asset.useful_life_years
|
||||
else:
|
||||
per_period = asset.total_units_expected
|
||||
periods = 1
|
||||
return units_of_production(
|
||||
cost=asset.cost,
|
||||
salvage_value=asset.salvage_value,
|
||||
total_units_expected=asset.total_units_expected,
|
||||
units_per_period=[per_period] * periods,
|
||||
)
|
||||
return []
|
||||
|
||||
def _add_periods(self, base_date: date, n_periods: int) -> date:
|
||||
"""Add (n_periods + 1) yearly increments to base_date and step back one
|
||||
day, giving the period-end date.
|
||||
|
||||
Phase 3.5 can split this into monthly/quarterly variants when the asset
|
||||
carries a sub-annual frequency.
|
||||
"""
|
||||
try:
|
||||
return base_date.replace(year=base_date.year + n_periods + 1) - timedelta(days=1)
|
||||
except ValueError:
|
||||
return base_date.replace(
|
||||
year=base_date.year + n_periods + 1, day=28,
|
||||
) - timedelta(days=1)
|
||||
|
||||
def _create_journal_entry(self, asset, line):
|
||||
"""Create the journal entry for a depreciation line.
|
||||
|
||||
Phase 3 keeps this minimal: requires the category to have both
|
||||
depreciation_account_id and expense_account_id wired up. Without that,
|
||||
the line is still posted (is_posted flag) but no move is created.
|
||||
Phase 3.5 will add multi-currency, allocation rules, and analytic tags.
|
||||
"""
|
||||
category = asset.category_id
|
||||
if not category or not (category.depreciation_account_id and category.expense_account_id):
|
||||
_logger.debug(
|
||||
"No accounts on category for asset %s; skipping journal entry",
|
||||
asset.id,
|
||||
)
|
||||
return None
|
||||
Move = self.env['account.move'].sudo()
|
||||
journal = self.env['account.journal'].search([
|
||||
('type', '=', 'general'),
|
||||
('company_id', '=', asset.company_id.id),
|
||||
], limit=1)
|
||||
if not journal:
|
||||
_logger.warning(
|
||||
"No general journal for company %s; skipping move creation",
|
||||
asset.company_id.name,
|
||||
)
|
||||
return None
|
||||
try:
|
||||
move = Move.create({
|
||||
'date': line.scheduled_date,
|
||||
'journal_id': journal.id,
|
||||
'ref': f"Depreciation: {asset.name} (P{line.period_index + 1})",
|
||||
'line_ids': [
|
||||
(0, 0, {
|
||||
'name': f"Depreciation expense - {asset.name}",
|
||||
'account_id': category.expense_account_id.id,
|
||||
'debit': line.amount,
|
||||
'credit': 0,
|
||||
}),
|
||||
(0, 0, {
|
||||
'name': f"Accumulated depreciation - {asset.name}",
|
||||
'account_id': category.depreciation_account_id.id,
|
||||
'debit': 0,
|
||||
'credit': line.amount,
|
||||
}),
|
||||
],
|
||||
})
|
||||
move.action_post()
|
||||
line.write({'move_id': move.id})
|
||||
return move
|
||||
except Exception as e: # noqa: BLE001
|
||||
_logger.warning(
|
||||
"Failed to create depreciation move for asset %s line %s: %s",
|
||||
asset.id, line.id, e,
|
||||
)
|
||||
return None
|
||||
85
fusion_accounting_assets/models/fusion_assets_cron.py
Normal file
85
fusion_accounting_assets/models/fusion_assets_cron.py
Normal file
@@ -0,0 +1,85 @@
|
||||
"""Cron handlers for fusion_accounting_assets.
|
||||
|
||||
- _cron_post_due_depreciation: daily, post due depreciation lines for running assets
|
||||
- _cron_anomaly_scan: monthly, scan for schedule variance and create anomaly records
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
from ..services.anomaly_detection import detect_schedule_variance
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionAssetsCron(models.AbstractModel):
|
||||
_name = "fusion.assets.cron"
|
||||
_description = "Fusion Assets Cron Handlers"
|
||||
|
||||
@api.model
|
||||
def _cron_post_due_depreciation(self):
|
||||
"""For each running asset, post any due un-posted depreciation lines."""
|
||||
today = fields.Date.today()
|
||||
engine = self.env['fusion.asset.engine']
|
||||
Asset = self.env['fusion.asset']
|
||||
running_assets = Asset.search([('state', '=', 'running')])
|
||||
posted_total = 0
|
||||
for asset in running_assets:
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
result = engine.post_depreciation_entry(asset, period_date=today)
|
||||
posted_total += result.get('posted_count', 0)
|
||||
except Exception as e: # noqa: BLE001
|
||||
_logger.warning("Cron post failed for asset %s: %s", asset.id, e)
|
||||
_logger.info(
|
||||
"Cron: posted depreciation on %d lines across %d running assets",
|
||||
posted_total, len(running_assets),
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _cron_anomaly_scan(self):
|
||||
"""For each running asset, compare expected accumulated depreciation
|
||||
vs posted, and persist any variance flags."""
|
||||
Asset = self.env['fusion.asset']
|
||||
Anomaly = self.env['fusion.asset.anomaly']
|
||||
running_assets = Asset.search([('state', '=', 'running')])
|
||||
flagged = 0
|
||||
today = fields.Date.today()
|
||||
for asset in running_assets:
|
||||
try:
|
||||
expected = sum(
|
||||
l.amount for l in asset.depreciation_line_ids
|
||||
if l.scheduled_date and l.scheduled_date <= today
|
||||
)
|
||||
actual = asset.total_depreciated
|
||||
anomaly = detect_schedule_variance(
|
||||
asset_id=asset.id, asset_name=asset.name,
|
||||
expected_accumulated=expected, actual_accumulated=actual,
|
||||
)
|
||||
if anomaly is None:
|
||||
continue
|
||||
anomaly_dict = anomaly.to_dict()
|
||||
existing = Anomaly.search([
|
||||
('asset_id', '=', asset.id),
|
||||
('anomaly_type', '=', anomaly_dict['anomaly_type']),
|
||||
('state', 'in', ('new', 'acknowledged')),
|
||||
], limit=1)
|
||||
if existing:
|
||||
continue
|
||||
Anomaly.create({
|
||||
'asset_id': asset.id,
|
||||
'anomaly_type': anomaly_dict['anomaly_type'],
|
||||
'severity': anomaly_dict['severity'],
|
||||
'expected': anomaly_dict['expected'],
|
||||
'actual': anomaly_dict['actual'],
|
||||
'variance_pct': anomaly_dict['variance_pct'],
|
||||
'detail': anomaly_dict['detail'],
|
||||
})
|
||||
flagged += 1
|
||||
except Exception as e: # noqa: BLE001
|
||||
_logger.warning("Cron anomaly scan failed for asset %s: %s", asset.id, e)
|
||||
_logger.info(
|
||||
"Cron: scanned %d assets, flagged %d anomalies",
|
||||
len(running_assets), flagged,
|
||||
)
|
||||
0
fusion_accounting_assets/reports/__init__.py
Normal file
0
fusion_accounting_assets/reports/__init__.py
Normal file
11
fusion_accounting_assets/security/ir.model.access.csv
Normal file
11
fusion_accounting_assets/security/ir.model.access.csv
Normal file
@@ -0,0 +1,11 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_fusion_asset_user,fusion.asset.user,model_fusion_asset,base.group_user,1,0,0,0
|
||||
access_fusion_asset_admin,fusion.asset.admin,model_fusion_asset,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1
|
||||
access_fusion_asset_depreciation_line_user,fusion.asset.depreciation.line.user,model_fusion_asset_depreciation_line,base.group_user,1,0,0,0
|
||||
access_fusion_asset_depreciation_line_admin,fusion.asset.depreciation.line.admin,model_fusion_asset_depreciation_line,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1
|
||||
access_fusion_asset_category_user,fusion.asset.category.user,model_fusion_asset_category,base.group_user,1,0,0,0
|
||||
access_fusion_asset_category_admin,fusion.asset.category.admin,model_fusion_asset_category,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1
|
||||
access_fusion_asset_disposal_user,fusion.asset.disposal.user,model_fusion_asset_disposal,base.group_user,1,0,0,0
|
||||
access_fusion_asset_disposal_admin,fusion.asset.disposal.admin,model_fusion_asset_disposal,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1
|
||||
access_fusion_asset_anomaly_user,fusion.asset.anomaly.user,model_fusion_asset_anomaly,base.group_user,1,0,0,0
|
||||
access_fusion_asset_anomaly_admin,fusion.asset.anomaly.admin,model_fusion_asset_anomaly,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1
|
||||
|
6
fusion_accounting_assets/services/__init__.py
Normal file
6
fusion_accounting_assets/services/__init__.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from . import depreciation_methods
|
||||
from . import prorate
|
||||
from . import salvage_value
|
||||
from . import anomaly_detection
|
||||
from . import useful_life_prompt
|
||||
from . import useful_life_predictor
|
||||
96
fusion_accounting_assets/services/anomaly_detection.py
Normal file
96
fusion_accounting_assets/services/anomaly_detection.py
Normal file
@@ -0,0 +1,96 @@
|
||||
"""Asset utilization anomaly detection.
|
||||
|
||||
Flags assets where actual usage / posted depreciation deviates significantly
|
||||
from the expected schedule. Three signal types:
|
||||
- behind_schedule: actual depreciation < expected by > threshold pct
|
||||
- ahead_of_schedule: actual > expected (over-depreciated; scrap or recompute)
|
||||
- low_utilization: units_used < expected_units_per_period (waste alert)
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class AssetAnomaly:
|
||||
asset_id: int
|
||||
asset_name: str
|
||||
anomaly_type: str
|
||||
severity: str
|
||||
expected: float
|
||||
actual: float
|
||||
variance_pct: float
|
||||
detail: str
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'asset_id': self.asset_id,
|
||||
'asset_name': self.asset_name,
|
||||
'anomaly_type': self.anomaly_type,
|
||||
'severity': self.severity,
|
||||
'expected': self.expected,
|
||||
'actual': self.actual,
|
||||
'variance_pct': self.variance_pct,
|
||||
'detail': self.detail,
|
||||
}
|
||||
|
||||
|
||||
DEFAULT_LOW_THRESHOLD_PCT = 10.0
|
||||
DEFAULT_MEDIUM_THRESHOLD_PCT = 25.0
|
||||
DEFAULT_HIGH_THRESHOLD_PCT = 50.0
|
||||
|
||||
|
||||
def detect_schedule_variance(*, asset_id: int, asset_name: str,
|
||||
expected_accumulated: float,
|
||||
actual_accumulated: float) -> AssetAnomaly | None:
|
||||
"""Compare expected accumulated depreciation vs actual posted."""
|
||||
if expected_accumulated <= 0:
|
||||
return None
|
||||
variance_amt = actual_accumulated - expected_accumulated
|
||||
variance_pct = abs(variance_amt) / expected_accumulated * 100
|
||||
if variance_pct < DEFAULT_LOW_THRESHOLD_PCT:
|
||||
return None
|
||||
direction = 'ahead_of_schedule' if variance_amt > 0 else 'behind_schedule'
|
||||
if variance_pct >= DEFAULT_HIGH_THRESHOLD_PCT:
|
||||
severity = 'high'
|
||||
elif variance_pct >= DEFAULT_MEDIUM_THRESHOLD_PCT:
|
||||
severity = 'medium'
|
||||
else:
|
||||
severity = 'low'
|
||||
detail = f"Posted ${actual_accumulated:,.2f} vs expected ${expected_accumulated:,.2f}"
|
||||
return AssetAnomaly(
|
||||
asset_id=asset_id,
|
||||
asset_name=asset_name,
|
||||
anomaly_type=direction,
|
||||
severity=severity,
|
||||
expected=expected_accumulated,
|
||||
actual=actual_accumulated,
|
||||
variance_pct=round(variance_pct, 1),
|
||||
detail=detail,
|
||||
)
|
||||
|
||||
|
||||
def detect_low_utilization(*, asset_id: int, asset_name: str,
|
||||
expected_units: float,
|
||||
actual_units: float) -> AssetAnomaly | None:
|
||||
"""For units-of-production assets: flag low actual usage."""
|
||||
if expected_units <= 0:
|
||||
return None
|
||||
if actual_units >= expected_units * 0.9:
|
||||
return None
|
||||
deficit_pct = (expected_units - actual_units) / expected_units * 100
|
||||
if deficit_pct >= 50:
|
||||
severity = 'high'
|
||||
elif deficit_pct >= 25:
|
||||
severity = 'medium'
|
||||
else:
|
||||
severity = 'low'
|
||||
return AssetAnomaly(
|
||||
asset_id=asset_id,
|
||||
asset_name=asset_name,
|
||||
anomaly_type='low_utilization',
|
||||
severity=severity,
|
||||
expected=expected_units,
|
||||
actual=actual_units,
|
||||
variance_pct=round(deficit_pct, 1),
|
||||
detail=f"Used {actual_units:.0f} of expected {expected_units:.0f} units",
|
||||
)
|
||||
116
fusion_accounting_assets/services/depreciation_methods.py
Normal file
116
fusion_accounting_assets/services/depreciation_methods.py
Normal file
@@ -0,0 +1,116 @@
|
||||
"""Depreciation method primitives.
|
||||
|
||||
Three methods supported:
|
||||
- straight_line: equal periodic charge over useful_life
|
||||
- declining_balance: % per period of remaining book value
|
||||
- units_of_production: charge proportional to units used / total units expected
|
||||
|
||||
All return a list of DepreciationStep dataclasses (period_index, period_amount,
|
||||
accumulated_depreciation, book_value_at_end). Total depreciation always
|
||||
sums to (cost - salvage_value), within 1-cent rounding tolerance.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
|
||||
Method = Literal['straight_line', 'declining_balance', 'units_of_production']
|
||||
|
||||
|
||||
@dataclass
|
||||
class DepreciationStep:
|
||||
period_index: int
|
||||
period_amount: float
|
||||
accumulated_depreciation: float
|
||||
book_value_at_end: float
|
||||
|
||||
|
||||
def straight_line(*, cost: float, salvage_value: float = 0.0,
|
||||
n_periods: int) -> list[DepreciationStep]:
|
||||
"""Equal charge per period: (cost - salvage) / n_periods.
|
||||
|
||||
Last period absorbs rounding so total == cost - salvage exactly.
|
||||
"""
|
||||
if n_periods < 1:
|
||||
return []
|
||||
depreciable = cost - salvage_value
|
||||
per_period = round(depreciable / n_periods, 2)
|
||||
steps = []
|
||||
accumulated = 0.0
|
||||
for i in range(n_periods):
|
||||
if i == n_periods - 1:
|
||||
amount = round(depreciable - accumulated, 2)
|
||||
else:
|
||||
amount = per_period
|
||||
accumulated = round(accumulated + amount, 2)
|
||||
book = round(cost - accumulated, 2)
|
||||
steps.append(DepreciationStep(
|
||||
period_index=i,
|
||||
period_amount=amount,
|
||||
accumulated_depreciation=accumulated,
|
||||
book_value_at_end=book,
|
||||
))
|
||||
return steps
|
||||
|
||||
|
||||
def declining_balance(*, cost: float, salvage_value: float = 0.0,
|
||||
n_periods: int, rate: float) -> list[DepreciationStep]:
|
||||
"""Apply `rate` (e.g. 0.20 = 20%) to remaining book each period.
|
||||
|
||||
Switches to straight-line when straight-line would deplete remaining book
|
||||
faster (typical Odoo behavior). Last step caps at salvage_value.
|
||||
"""
|
||||
if n_periods < 1 or rate <= 0:
|
||||
return []
|
||||
if rate >= 1:
|
||||
# Pathological: 100%+ rate. Charge full depreciable amount in period 0.
|
||||
depreciable = round(cost - salvage_value, 2)
|
||||
return [DepreciationStep(0, depreciable, depreciable, round(salvage_value, 2))]
|
||||
steps = []
|
||||
book = cost
|
||||
accumulated = 0.0
|
||||
for i in range(n_periods):
|
||||
remaining_periods = n_periods - i
|
||||
db_amount = round(book * rate, 2)
|
||||
sl_amount = round((book - salvage_value) / remaining_periods, 2) if remaining_periods else 0.0
|
||||
amount = max(db_amount, sl_amount)
|
||||
if book - amount < salvage_value:
|
||||
amount = round(book - salvage_value, 2)
|
||||
accumulated = round(accumulated + amount, 2)
|
||||
book = round(book - amount, 2)
|
||||
steps.append(DepreciationStep(
|
||||
period_index=i,
|
||||
period_amount=amount,
|
||||
accumulated_depreciation=accumulated,
|
||||
book_value_at_end=book,
|
||||
))
|
||||
if book <= salvage_value:
|
||||
break
|
||||
return steps
|
||||
|
||||
|
||||
def units_of_production(*, cost: float, salvage_value: float = 0.0,
|
||||
total_units_expected: float,
|
||||
units_per_period: list[float]) -> list[DepreciationStep]:
|
||||
"""Charge per period = (units_used / total_expected) * (cost - salvage)."""
|
||||
if total_units_expected <= 0:
|
||||
return []
|
||||
depreciable = cost - salvage_value
|
||||
per_unit = depreciable / total_units_expected
|
||||
steps = []
|
||||
accumulated = 0.0
|
||||
for i, units in enumerate(units_per_period):
|
||||
amount = round(units * per_unit, 2)
|
||||
if accumulated + amount > depreciable:
|
||||
amount = round(depreciable - accumulated, 2)
|
||||
accumulated = round(accumulated + amount, 2)
|
||||
book = round(cost - accumulated, 2)
|
||||
steps.append(DepreciationStep(
|
||||
period_index=i,
|
||||
period_amount=amount,
|
||||
accumulated_depreciation=accumulated,
|
||||
book_value_at_end=book,
|
||||
))
|
||||
if accumulated >= depreciable:
|
||||
break
|
||||
return steps
|
||||
34
fusion_accounting_assets/services/prorate.py
Normal file
34
fusion_accounting_assets/services/prorate.py
Normal file
@@ -0,0 +1,34 @@
|
||||
"""Prorating helpers for first-period and last-period depreciation.
|
||||
|
||||
When an asset starts mid-month, the first period charges only a fraction
|
||||
of the full period_amount. Three conventions:
|
||||
- 'full_month': always charge full month (no proration)
|
||||
- 'days_365': pro-rate by actual days / 365
|
||||
- 'days_period': pro-rate by actual days in period / total days in period
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
from typing import Literal
|
||||
|
||||
|
||||
ProrateConvention = Literal['full_month', 'days_365', 'days_period']
|
||||
|
||||
|
||||
def prorate_factor(*, period_start: date, period_end: date,
|
||||
asset_start: date,
|
||||
convention: ProrateConvention = 'days_period') -> float:
|
||||
"""Return a 0..1 factor for how much of `period`'s depreciation
|
||||
applies to an asset that started on `asset_start`."""
|
||||
if convention == 'full_month':
|
||||
return 1.0
|
||||
if asset_start <= period_start:
|
||||
return 1.0
|
||||
if asset_start > period_end:
|
||||
return 0.0
|
||||
actual_days = (period_end - asset_start).days + 1
|
||||
if convention == 'days_365':
|
||||
return actual_days / 365.0
|
||||
if convention == 'days_period':
|
||||
period_days = (period_end - period_start).days + 1
|
||||
return actual_days / period_days
|
||||
raise ValueError(f"Unknown convention: {convention}")
|
||||
38
fusion_accounting_assets/services/salvage_value.py
Normal file
38
fusion_accounting_assets/services/salvage_value.py
Normal file
@@ -0,0 +1,38 @@
|
||||
"""Salvage value (scrap value) calculation helpers.
|
||||
|
||||
Most clients use straight % of cost; some use fixed dollar amounts.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Literal
|
||||
|
||||
|
||||
SalvageMethod = Literal['percentage', 'fixed', 'zero']
|
||||
|
||||
|
||||
@dataclass
|
||||
class SalvageConfig:
|
||||
method: SalvageMethod
|
||||
value: float = 0.0
|
||||
|
||||
|
||||
def compute_salvage_value(*, cost: float, config: SalvageConfig) -> float:
|
||||
"""Compute end-of-life salvage value."""
|
||||
if config.method == 'zero':
|
||||
return 0.0
|
||||
if config.method == 'percentage':
|
||||
return round(cost * config.value / 100, 2)
|
||||
if config.method == 'fixed':
|
||||
return round(config.value, 2)
|
||||
raise ValueError(f"Unknown salvage method: {config.method}")
|
||||
|
||||
|
||||
def remaining_useful_life_value(*, current_book: float, salvage: float,
|
||||
periods_used: int, total_periods: int) -> float:
|
||||
"""Estimate remaining value if asset is sold/scrapped now."""
|
||||
if total_periods <= 0:
|
||||
return current_book
|
||||
if periods_used >= total_periods:
|
||||
return salvage
|
||||
remaining_pct = (total_periods - periods_used) / total_periods
|
||||
return round(salvage + (current_book - salvage) * remaining_pct, 2)
|
||||
94
fusion_accounting_assets/services/useful_life_predictor.py
Normal file
94
fusion_accounting_assets/services/useful_life_predictor.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""AI-suggested useful life from invoice context.
|
||||
|
||||
Wraps useful_life_prompt + an LLMProvider. Returns a dict per the prompt's
|
||||
output contract. Templated fallback when no provider configured.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Templated fallback rules: (regex, years, method, rationale)
|
||||
FALLBACK_RULES = [
|
||||
(r'\b(computer|laptop|monitor|server|workstation)\b', 4, 'straight_line', 'Computer hardware'),
|
||||
(r'\b(furniture|desk|chair|cabinet)\b', 7, 'straight_line', 'Furniture'),
|
||||
(r'\b(vehicle|truck|car|van)\b', 5, 'declining_balance', 'Vehicle (CRA Class 10)'),
|
||||
(r'\b(building|warehouse)\b', 30, 'straight_line', 'Building'),
|
||||
(r'\b(software|license)\b', 4, 'straight_line', 'Software license'),
|
||||
(r'\b(equipment|machinery|machine)\b', 10, 'straight_line', 'Manufacturing equipment'),
|
||||
(r'\b(leasehold improvement)\b', 5, 'straight_line', 'Leasehold improvements'),
|
||||
]
|
||||
FALLBACK_DEFAULT = (5, 'straight_line', 'Generic fixed asset (default)')
|
||||
|
||||
|
||||
def predict_useful_life(env, *, description: str, amount: float = None,
|
||||
partner_name: str = None, provider=None) -> dict:
|
||||
"""Suggest useful life + method via LLM, with templated fallback."""
|
||||
if provider is None:
|
||||
provider = _get_provider(env)
|
||||
if provider is None:
|
||||
return _templated_fallback(description)
|
||||
|
||||
try:
|
||||
from .useful_life_prompt import build_prompt
|
||||
system, user = build_prompt(
|
||||
description=description, amount=amount, partner_name=partner_name,
|
||||
)
|
||||
response = provider.complete(
|
||||
system=system,
|
||||
messages=[{'role': 'user', 'content': user}],
|
||||
max_tokens=400, temperature=0.1,
|
||||
)
|
||||
content = response.get('content') if isinstance(response, dict) else response
|
||||
parsed = json.loads(content)
|
||||
for key in ('useful_life_years', 'depreciation_method', 'rationale'):
|
||||
if key not in parsed:
|
||||
raise ValueError(f"Missing key: {key}")
|
||||
parsed.setdefault('confidence', 0.7)
|
||||
return parsed
|
||||
except Exception as e:
|
||||
_logger.warning("Useful life LLM prediction failed (%s); falling back", e)
|
||||
return _templated_fallback(description)
|
||||
|
||||
|
||||
def _templated_fallback(description: str) -> dict:
|
||||
"""Pattern-match keyword rules. Always returns a usable dict."""
|
||||
desc_lower = description.lower() if description else ''
|
||||
for pattern, years, method, rationale in FALLBACK_RULES:
|
||||
if re.search(pattern, desc_lower):
|
||||
return {
|
||||
'useful_life_years': years,
|
||||
'depreciation_method': method,
|
||||
'rationale': rationale,
|
||||
'confidence': 0.5,
|
||||
}
|
||||
years, method, rationale = FALLBACK_DEFAULT
|
||||
return {
|
||||
'useful_life_years': years,
|
||||
'depreciation_method': method,
|
||||
'rationale': rationale,
|
||||
'confidence': 0.3,
|
||||
}
|
||||
|
||||
|
||||
def _get_provider(env):
|
||||
"""Look up provider for 'asset_useful_life' feature."""
|
||||
param = env['ir.config_parameter'].sudo()
|
||||
name = param.get_param('fusion_accounting.provider.asset_useful_life')
|
||||
if not name:
|
||||
name = param.get_param('fusion_accounting.provider.default')
|
||||
if not name:
|
||||
return None
|
||||
try:
|
||||
from odoo.addons.fusion_accounting_ai.services.adapters.openai_adapter import OpenAIAdapter
|
||||
from odoo.addons.fusion_accounting_ai.services.adapters.claude import ClaudeAdapter
|
||||
except ImportError:
|
||||
return None
|
||||
if name.startswith('openai'):
|
||||
return OpenAIAdapter(env)
|
||||
elif name.startswith('claude'):
|
||||
return ClaudeAdapter(env)
|
||||
return None
|
||||
48
fusion_accounting_assets/services/useful_life_prompt.py
Normal file
48
fusion_accounting_assets/services/useful_life_prompt.py
Normal file
@@ -0,0 +1,48 @@
|
||||
"""LLM prompt builder for AI-suggested useful life from invoice description.
|
||||
|
||||
Output contract:
|
||||
{
|
||||
"useful_life_years": <int>,
|
||||
"depreciation_method": "straight_line" | "declining_balance" | "units_of_production",
|
||||
"rationale": "<short explanation>",
|
||||
"confidence": <float 0-1>
|
||||
}
|
||||
"""
|
||||
|
||||
|
||||
SYSTEM_PROMPT = """You are an experienced accountant. Given an invoice line
|
||||
description for a fixed asset, suggest the appropriate useful life in years
|
||||
and depreciation method based on common accounting standards (IFRS / GAAP / CRA).
|
||||
|
||||
Respond ONLY with valid JSON of this exact shape:
|
||||
{
|
||||
"useful_life_years": <integer>,
|
||||
"depreciation_method": "straight_line" | "declining_balance" | "units_of_production",
|
||||
"rationale": "<one or two sentence explanation>",
|
||||
"confidence": <float between 0 and 1>
|
||||
}
|
||||
|
||||
Common useful-life conventions:
|
||||
- Furniture: 7 years, straight-line
|
||||
- Office equipment: 5 years, straight-line
|
||||
- Computers: 3-4 years, straight-line or declining
|
||||
- Vehicles: 5 years, declining-balance (CRA Class 10 30%)
|
||||
- Buildings: 25-40 years, straight-line
|
||||
- Manufacturing equipment: 10-15 years, units of production if measurable
|
||||
- Software (licenses): 3-5 years, straight-line
|
||||
- Leasehold improvements: lesser of lease term or useful life
|
||||
|
||||
Do NOT include markdown code fences. Do NOT include any prose outside the JSON."""
|
||||
|
||||
|
||||
def build_prompt(*, description: str, amount: float = None,
|
||||
partner_name: str = None) -> tuple[str, str]:
|
||||
"""Return (system, user) prompt tuple."""
|
||||
parts = [f"INVOICE LINE: {description}"]
|
||||
if amount is not None:
|
||||
parts.append(f"AMOUNT: ${amount:,.2f}")
|
||||
if partner_name:
|
||||
parts.append(f"VENDOR: {partner_name}")
|
||||
parts.append("")
|
||||
parts.append("Suggest the useful life and depreciation method per the system prompt.")
|
||||
return (SYSTEM_PROMPT, "\n".join(parts))
|
||||
BIN
fusion_accounting_assets/static/description/icon.png
Normal file
BIN
fusion_accounting_assets/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
17
fusion_accounting_assets/tests/__init__.py
Normal file
17
fusion_accounting_assets/tests/__init__.py
Normal file
@@ -0,0 +1,17 @@
|
||||
from . import test_depreciation_methods
|
||||
from . import test_prorate
|
||||
from . import test_salvage_value
|
||||
from . import test_asset_anomaly_detection
|
||||
from . import test_useful_life_predictor
|
||||
from . import test_fusion_asset
|
||||
from . import test_fusion_asset_depreciation_line
|
||||
from . import test_fusion_asset_category
|
||||
from . import test_fusion_asset_disposal
|
||||
from . import test_fusion_asset_anomaly
|
||||
from . import test_account_move_inherit
|
||||
from . import test_fusion_asset_engine
|
||||
from . import test_engine_integration
|
||||
from . import test_assets_controller
|
||||
from . import test_assets_adapter
|
||||
from . import test_asset_tools
|
||||
from . import test_assets_cron
|
||||
47
fusion_accounting_assets/tests/test_account_move_inherit.py
Normal file
47
fusion_accounting_assets/tests/test_account_move_inherit.py
Normal file
@@ -0,0 +1,47 @@
|
||||
from datetime import date
|
||||
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.tests import tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestAccountMoveLineFusionAsset(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.asset = self.env['fusion.asset'].create({
|
||||
'name': 'Asset From Invoice',
|
||||
'cost': 8000,
|
||||
'acquisition_date': date(2026, 1, 1),
|
||||
})
|
||||
self.partner = self.env['res.partner'].create({'name': 'Vendor X'})
|
||||
product = self.env['product.product'].create({'name': 'Test Asset Item'})
|
||||
bill = self.env['account.move'].create({
|
||||
'move_type': 'in_invoice',
|
||||
'partner_id': self.partner.id,
|
||||
'invoice_date': date(2026, 1, 1),
|
||||
'invoice_line_ids': [(0, 0, {
|
||||
'product_id': product.id,
|
||||
'name': 'Test asset purchase',
|
||||
'quantity': 1,
|
||||
'price_unit': 8000,
|
||||
})],
|
||||
})
|
||||
self.invoice_line = bill.invoice_line_ids[0]
|
||||
|
||||
def test_line_starts_without_asset_link(self):
|
||||
self.assertFalse(self.invoice_line.fusion_asset_id)
|
||||
self.assertEqual(self.invoice_line.fusion_asset_count, 0)
|
||||
|
||||
def test_link_invoice_line_to_asset(self):
|
||||
self.invoice_line.fusion_asset_id = self.asset
|
||||
self.assertEqual(self.invoice_line.fusion_asset_id, self.asset)
|
||||
self.invoice_line.invalidate_recordset(['fusion_asset_count'])
|
||||
self.assertEqual(self.invoice_line.fusion_asset_count, 1)
|
||||
|
||||
def test_action_open_fusion_asset_returns_window_action(self):
|
||||
self.invoice_line.fusion_asset_id = self.asset
|
||||
action = self.invoice_line.action_open_fusion_asset()
|
||||
self.assertEqual(action['res_model'], 'fusion.asset')
|
||||
self.assertEqual(action['res_id'], self.asset.id)
|
||||
self.assertEqual(action['view_mode'], 'form')
|
||||
@@ -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')
|
||||
56
fusion_accounting_assets/tests/test_asset_tools.py
Normal file
56
fusion_accounting_assets/tests/test_asset_tools.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Tests for the 5 fusion-asset AI tools."""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from odoo.tests import tagged
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
from odoo.addons.fusion_accounting_ai.services.tools import asset_management as tools
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestFusionAssetTools(TransactionCase):
|
||||
|
||||
def test_fusion_list_assets(self):
|
||||
self.env['fusion.asset'].create({
|
||||
'name': 'Tool Test', 'cost': 1000,
|
||||
'acquisition_date': date(2026, 1, 1),
|
||||
'method': 'straight_line', 'useful_life_years': 4,
|
||||
})
|
||||
result = tools.fusion_list_assets(self.env, {'company_id': self.env.company.id})
|
||||
self.assertGreaterEqual(result.get('count', 0), 1)
|
||||
|
||||
def test_fusion_get_asset_detail(self):
|
||||
asset = self.env['fusion.asset'].create({
|
||||
'name': 'Detail Test', 'cost': 1500,
|
||||
'acquisition_date': date(2026, 1, 1),
|
||||
'method': 'straight_line', 'useful_life_years': 4,
|
||||
})
|
||||
result = tools.fusion_get_asset_detail(self.env, {'asset_id': asset.id})
|
||||
self.assertEqual(result['asset']['name'], 'Detail Test')
|
||||
|
||||
def test_fusion_compute_schedule(self):
|
||||
asset = self.env['fusion.asset'].create({
|
||||
'name': 'Schedule Test', 'cost': 2000,
|
||||
'acquisition_date': date(2026, 1, 1),
|
||||
'method': 'straight_line', 'useful_life_years': 4,
|
||||
})
|
||||
result = tools.fusion_compute_asset_schedule(self.env, {'asset_id': asset.id})
|
||||
self.assertEqual(result['lines_created'], 4)
|
||||
|
||||
def test_fusion_suggest_useful_life(self):
|
||||
self.env['ir.config_parameter'].sudo().search([
|
||||
('key', 'in', ['fusion_accounting.provider.asset_useful_life',
|
||||
'fusion_accounting.provider.default'])
|
||||
]).unlink()
|
||||
result = tools.fusion_suggest_asset_useful_life(self.env, {
|
||||
'description': 'desk',
|
||||
})
|
||||
self.assertEqual(result['useful_life_years'], 7)
|
||||
|
||||
def test_tools_registered_in_dispatch(self):
|
||||
from odoo.addons.fusion_accounting_ai.services.tools import TOOL_DISPATCH
|
||||
for tool_name in ['fusion_list_assets', 'fusion_get_asset_detail',
|
||||
'fusion_compute_asset_schedule', 'fusion_dispose_asset',
|
||||
'fusion_suggest_asset_useful_life']:
|
||||
self.assertIn(tool_name, TOOL_DISPATCH)
|
||||
40
fusion_accounting_assets/tests/test_assets_adapter.py
Normal file
40
fusion_accounting_assets/tests/test_assets_adapter.py
Normal file
@@ -0,0 +1,40 @@
|
||||
"""AssetsAdapter wiring tests — fusion-mode dispatch."""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from odoo.tests import tagged
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
from odoo.addons.fusion_accounting_ai.services.data_adapters.assets import (
|
||||
AssetsAdapter,
|
||||
)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestAssetsAdapter(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.adapter = AssetsAdapter(self.env)
|
||||
|
||||
def test_list_assets_via_fusion(self):
|
||||
self.env['fusion.asset'].create({
|
||||
'name': 'Adapter Test', 'cost': 1000,
|
||||
'acquisition_date': date(2026, 1, 1),
|
||||
'method': 'straight_line', 'useful_life_years': 4,
|
||||
})
|
||||
result = self.adapter.list_assets_via_fusion(company_id=self.env.company.id)
|
||||
self.assertGreaterEqual(result['count'], 1)
|
||||
|
||||
def test_suggest_useful_life_via_fusion_uses_templated_fallback(self):
|
||||
self.env['ir.config_parameter'].sudo().search([
|
||||
('key', 'in', ['fusion_accounting.provider.asset_useful_life',
|
||||
'fusion_accounting.provider.default'])
|
||||
]).unlink()
|
||||
result = self.adapter.suggest_useful_life_via_fusion(description='laptop')
|
||||
self.assertEqual(result['useful_life_years'], 4)
|
||||
self.assertEqual(result['depreciation_method'], 'straight_line')
|
||||
|
||||
def test_dispose_asset_via_community_returns_error(self):
|
||||
result = self.adapter.dispose_asset_via_community(asset_id=1, sale_amount=100)
|
||||
self.assertIn('error', result)
|
||||
103
fusion_accounting_assets/tests/test_assets_controller.py
Normal file
103
fusion_accounting_assets/tests/test_assets_controller.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""Controller tests using HttpCase."""
|
||||
|
||||
import json
|
||||
from datetime import date
|
||||
|
||||
from odoo.tests import tagged
|
||||
from odoo.tests.common import HttpCase, new_test_user
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestAssetsController(HttpCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.user = new_test_user(
|
||||
self.env, login='assets_test_user',
|
||||
groups='base.group_user,account.group_account_invoice',
|
||||
)
|
||||
|
||||
def _jsonrpc(self, endpoint, params):
|
||||
self.authenticate('assets_test_user', 'assets_test_user')
|
||||
url = f'/fusion/assets/{endpoint}'
|
||||
body = {'jsonrpc': '2.0', 'method': 'call', 'params': params, 'id': 1}
|
||||
response = self.url_open(
|
||||
url, data=json.dumps(body),
|
||||
headers={'Content-Type': 'application/json'},
|
||||
)
|
||||
self.assertEqual(
|
||||
response.status_code, 200,
|
||||
f"{endpoint} returned {response.status_code}: {response.text[:300]}",
|
||||
)
|
||||
result = response.json()
|
||||
if 'error' in result:
|
||||
self.fail(f"{endpoint} errored: {result['error']}")
|
||||
return result.get('result', {})
|
||||
|
||||
def test_list_returns_dict(self):
|
||||
result = self._jsonrpc('list', {'company_id': self.env.company.id})
|
||||
self.assertIn('assets', result)
|
||||
self.assertIn('total', result)
|
||||
|
||||
def test_get_detail_returns_asset(self):
|
||||
asset = self.env['fusion.asset'].create({
|
||||
'name': 'Ctrl Test Asset', 'cost': 5000,
|
||||
'acquisition_date': date(2026, 1, 1),
|
||||
'method': 'straight_line', 'useful_life_years': 5,
|
||||
})
|
||||
result = self._jsonrpc('get_detail', {'asset_id': asset.id})
|
||||
self.assertEqual(result['asset']['id'], asset.id)
|
||||
self.assertIn('depreciation_lines', result)
|
||||
|
||||
def test_compute_schedule_creates_lines(self):
|
||||
asset = self.env['fusion.asset'].create({
|
||||
'name': 'CompTest', 'cost': 4000,
|
||||
'acquisition_date': date(2026, 1, 1),
|
||||
'method': 'straight_line', 'useful_life_years': 4,
|
||||
})
|
||||
result = self._jsonrpc('compute_schedule', {'asset_id': asset.id})
|
||||
self.assertEqual(result['lines_created'], 4)
|
||||
|
||||
def test_post_depreciation_after_running(self):
|
||||
asset = self.env['fusion.asset'].create({
|
||||
'name': 'PostTest', 'cost': 3000,
|
||||
'acquisition_date': date(2026, 1, 1),
|
||||
'in_service_date': date(2026, 1, 1),
|
||||
'method': 'straight_line', 'useful_life_years': 3,
|
||||
})
|
||||
self.env['fusion.asset.engine'].compute_depreciation_schedule(asset)
|
||||
asset.action_set_running()
|
||||
result = self._jsonrpc('post_depreciation', {'asset_id': asset.id})
|
||||
self.assertEqual(result['posted_count'], 1)
|
||||
|
||||
def test_dispose_marks_asset_disposed(self):
|
||||
asset = self.env['fusion.asset'].create({
|
||||
'name': 'DispTest', 'cost': 6000,
|
||||
'acquisition_date': date(2026, 1, 1),
|
||||
'in_service_date': date(2026, 1, 1),
|
||||
'method': 'straight_line', 'useful_life_years': 3,
|
||||
})
|
||||
self.env['fusion.asset.engine'].compute_depreciation_schedule(asset)
|
||||
asset.action_set_running()
|
||||
result = self._jsonrpc('dispose', {
|
||||
'asset_id': asset.id, 'sale_amount': 4000,
|
||||
'sale_date': '2027-06-01', 'disposal_type': 'sale',
|
||||
})
|
||||
self.assertIn('disposal_id', result)
|
||||
asset.invalidate_recordset(['state'])
|
||||
self.assertEqual(asset.state, 'disposed')
|
||||
|
||||
def test_get_anomalies_returns_list(self):
|
||||
result = self._jsonrpc('get_anomalies', {'company_id': self.env.company.id})
|
||||
self.assertIn('anomalies', result)
|
||||
|
||||
def test_suggest_useful_life_returns_dict(self):
|
||||
result = self._jsonrpc('suggest_useful_life', {'description': 'Dell laptop'})
|
||||
self.assertIn('useful_life_years', result)
|
||||
self.assertIn('depreciation_method', result)
|
||||
self.assertEqual(result['useful_life_years'], 4)
|
||||
|
||||
def test_get_partner_history(self):
|
||||
partner = self.env['res.partner'].create({'name': 'History Test Partner'})
|
||||
result = self._jsonrpc('get_partner_history', {'partner_id': partner.id})
|
||||
self.assertEqual(result['partner_id'], partner.id)
|
||||
28
fusion_accounting_assets/tests/test_assets_cron.py
Normal file
28
fusion_accounting_assets/tests/test_assets_cron.py
Normal file
@@ -0,0 +1,28 @@
|
||||
"""Cron handler smoke tests."""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from odoo.tests import tagged
|
||||
from odoo.tests.common import TransactionCase
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestFusionAssetsCron(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.cron = self.env['fusion.assets.cron']
|
||||
self.asset = self.env['fusion.asset'].create({
|
||||
'name': 'Cron Test', 'cost': 4000,
|
||||
'acquisition_date': date(2026, 1, 1),
|
||||
'in_service_date': date(2026, 1, 1),
|
||||
'method': 'straight_line', 'useful_life_years': 4,
|
||||
})
|
||||
self.env['fusion.asset.engine'].compute_depreciation_schedule(self.asset)
|
||||
self.asset.action_set_running()
|
||||
|
||||
def test_cron_post_due_depreciation_runs(self):
|
||||
self.cron._cron_post_due_depreciation()
|
||||
|
||||
def test_cron_anomaly_scan_runs(self):
|
||||
self.cron._cron_anomaly_scan()
|
||||
88
fusion_accounting_assets/tests/test_depreciation_methods.py
Normal file
88
fusion_accounting_assets/tests/test_depreciation_methods.py
Normal file
@@ -0,0 +1,88 @@
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.tests import tagged
|
||||
from odoo.addons.fusion_accounting_assets.services.depreciation_methods import (
|
||||
straight_line, declining_balance, units_of_production,
|
||||
)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestStraightLine(TransactionCase):
|
||||
|
||||
def test_total_equals_cost_minus_salvage(self):
|
||||
steps = straight_line(cost=10000, salvage_value=1000, n_periods=5)
|
||||
total = sum(s.period_amount for s in steps)
|
||||
self.assertAlmostEqual(total, 9000, places=2)
|
||||
|
||||
def test_per_period_equal_except_last(self):
|
||||
steps = straight_line(cost=10000, salvage_value=0, n_periods=4)
|
||||
self.assertEqual([s.period_amount for s in steps], [2500.0] * 4)
|
||||
|
||||
def test_last_period_absorbs_rounding(self):
|
||||
steps = straight_line(cost=10000, salvage_value=0, n_periods=3)
|
||||
total = sum(s.period_amount for s in steps)
|
||||
self.assertAlmostEqual(total, 10000, places=2)
|
||||
|
||||
def test_zero_periods_returns_empty(self):
|
||||
self.assertEqual(straight_line(cost=10000, n_periods=0), [])
|
||||
|
||||
def test_book_value_decreasing(self):
|
||||
steps = straight_line(cost=10000, salvage_value=1000, n_periods=5)
|
||||
for i in range(1, len(steps)):
|
||||
self.assertLess(steps[i].book_value_at_end, steps[i - 1].book_value_at_end)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestDecliningBalance(TransactionCase):
|
||||
|
||||
def test_total_does_not_exceed_depreciable(self):
|
||||
steps = declining_balance(cost=10000, salvage_value=1000, n_periods=10, rate=0.20)
|
||||
total = sum(s.period_amount for s in steps)
|
||||
self.assertLessEqual(total, 9000.01)
|
||||
|
||||
def test_does_not_go_below_salvage(self):
|
||||
steps = declining_balance(cost=10000, salvage_value=1000, n_periods=10, rate=0.50)
|
||||
for s in steps:
|
||||
self.assertGreaterEqual(s.book_value_at_end, 999.99)
|
||||
|
||||
def test_zero_rate_returns_empty(self):
|
||||
self.assertEqual(declining_balance(cost=10000, n_periods=5, rate=0), [])
|
||||
|
||||
def test_pathological_100pct_rate_one_period(self):
|
||||
steps = declining_balance(cost=10000, salvage_value=500, n_periods=10, rate=1.0)
|
||||
self.assertEqual(len(steps), 1)
|
||||
self.assertAlmostEqual(steps[0].period_amount, 9500, places=2)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestUnitsOfProduction(TransactionCase):
|
||||
|
||||
def test_total_proportional_to_units_used(self):
|
||||
steps = units_of_production(
|
||||
cost=20000, salvage_value=2000,
|
||||
total_units_expected=10000,
|
||||
units_per_period=[1000, 2000, 3000, 4000],
|
||||
)
|
||||
total = sum(s.period_amount for s in steps)
|
||||
self.assertAlmostEqual(total, 18000, places=1)
|
||||
|
||||
def test_partial_use_partial_depreciation(self):
|
||||
steps = units_of_production(
|
||||
cost=10000, salvage_value=0,
|
||||
total_units_expected=1000,
|
||||
units_per_period=[200],
|
||||
)
|
||||
self.assertAlmostEqual(steps[0].period_amount, 2000, places=2)
|
||||
|
||||
def test_zero_total_units_returns_empty(self):
|
||||
self.assertEqual(
|
||||
units_of_production(cost=10000, total_units_expected=0, units_per_period=[100]),
|
||||
[],
|
||||
)
|
||||
|
||||
def test_does_not_overshoot_salvage(self):
|
||||
steps = units_of_production(
|
||||
cost=10000, salvage_value=1000,
|
||||
total_units_expected=1000,
|
||||
units_per_period=[2000],
|
||||
)
|
||||
self.assertAlmostEqual(steps[0].period_amount, 9000, places=2)
|
||||
151
fusion_accounting_assets/tests/test_engine_integration.py
Normal file
151
fusion_accounting_assets/tests/test_engine_integration.py
Normal file
@@ -0,0 +1,151 @@
|
||||
"""End-to-end engine integration tests.
|
||||
|
||||
Each test creates a complete realistic asset (with category and accounts),
|
||||
runs the engine through a full lifecycle, and asserts both the model state
|
||||
and the journal entries (where category accounts are configured).
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.tests import tagged
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install', 'integration')
|
||||
class TestAssetEngineIntegration(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.engine = self.env['fusion.asset.engine']
|
||||
Account = self.env['account.account']
|
||||
company_id = self.env.company.id
|
||||
self.expense_account = Account.search([
|
||||
('account_type', '=', 'expense_depreciation'),
|
||||
('company_ids', 'in', company_id),
|
||||
], limit=1)
|
||||
if not self.expense_account:
|
||||
self.expense_account = Account.create({
|
||||
'name': 'Test Depreciation Expense',
|
||||
'code': '7180',
|
||||
'account_type': 'expense_depreciation',
|
||||
'company_ids': [(6, 0, [company_id])],
|
||||
})
|
||||
self.dep_account = Account.search([
|
||||
('account_type', '=', 'asset_fixed'),
|
||||
('company_ids', 'in', company_id),
|
||||
], limit=1)
|
||||
if not self.dep_account:
|
||||
self.dep_account = Account.create({
|
||||
'name': 'Test Accumulated Depreciation',
|
||||
'code': '1690',
|
||||
'account_type': 'asset_fixed',
|
||||
'company_ids': [(6, 0, [company_id])],
|
||||
})
|
||||
self.category = self.env['fusion.asset.category'].create({
|
||||
'name': 'Test Category',
|
||||
'method': 'straight_line',
|
||||
'useful_life_years': 5,
|
||||
'asset_account_id': self.dep_account.id,
|
||||
'depreciation_account_id': self.dep_account.id,
|
||||
'expense_account_id': self.expense_account.id,
|
||||
})
|
||||
|
||||
def _make_asset(self, **kwargs):
|
||||
defaults = {
|
||||
'name': 'Integration Asset',
|
||||
'cost': 12000,
|
||||
'salvage_value': 0,
|
||||
'acquisition_date': date(2026, 1, 1),
|
||||
'in_service_date': date(2026, 1, 1),
|
||||
'method': 'straight_line',
|
||||
'useful_life_years': 4,
|
||||
'category_id': self.category.id,
|
||||
}
|
||||
defaults.update(kwargs)
|
||||
return self.env['fusion.asset'].create(defaults)
|
||||
|
||||
def test_full_lifecycle_straight_line(self):
|
||||
asset = self._make_asset()
|
||||
self.engine.compute_depreciation_schedule(asset)
|
||||
self.assertEqual(len(asset.depreciation_line_ids), 4)
|
||||
self.assertAlmostEqual(
|
||||
sum(asset.depreciation_line_ids.mapped('amount')), 12000, places=2,
|
||||
)
|
||||
|
||||
asset.action_set_running()
|
||||
for _i in range(2):
|
||||
result = self.engine.post_depreciation_entry(asset)
|
||||
self.assertEqual(result['posted_count'], 1)
|
||||
asset.invalidate_recordset(['book_value', 'total_depreciated'])
|
||||
self.assertAlmostEqual(asset.total_depreciated, 6000, places=2)
|
||||
|
||||
def test_post_creates_journal_entry_when_accounts_configured(self):
|
||||
asset = self._make_asset()
|
||||
self.engine.compute_depreciation_schedule(asset)
|
||||
asset.action_set_running()
|
||||
self.engine.post_depreciation_entry(asset)
|
||||
first = asset.depreciation_line_ids.sorted('period_index')[0]
|
||||
self.assertTrue(first.move_id, "Expected journal entry on posted line")
|
||||
moves = first.move_id
|
||||
self.assertAlmostEqual(
|
||||
sum(moves.line_ids.mapped('debit')),
|
||||
sum(moves.line_ids.mapped('credit')),
|
||||
places=2,
|
||||
)
|
||||
|
||||
def test_dispose_caps_future_lines(self):
|
||||
asset = self._make_asset()
|
||||
self.engine.compute_depreciation_schedule(asset)
|
||||
asset.action_set_running()
|
||||
self.engine.post_depreciation_entry(asset)
|
||||
self.engine.dispose_asset(
|
||||
asset, sale_amount=5000, sale_date=date(2027, 6, 1),
|
||||
)
|
||||
self.assertEqual(asset.state, 'disposed')
|
||||
unposted = asset.depreciation_line_ids.filtered(lambda l: not l.is_posted)
|
||||
for line in unposted:
|
||||
self.assertLessEqual(line.scheduled_date, date(2027, 6, 1))
|
||||
|
||||
def test_dispose_records_correct_book_value(self):
|
||||
asset = self._make_asset()
|
||||
self.engine.compute_depreciation_schedule(asset)
|
||||
asset.action_set_running()
|
||||
for _i in range(2):
|
||||
self.engine.post_depreciation_entry(asset)
|
||||
result = self.engine.dispose_asset(
|
||||
asset, sale_amount=8000, sale_date=date(2028, 6, 1),
|
||||
)
|
||||
# Book value at disposal = cost - accumulated = 12000 - 6000 = 6000.
|
||||
self.assertAlmostEqual(result['book_value_at_disposal'], 6000, places=2)
|
||||
# Gain = 8000 - 6000 = 2000.
|
||||
self.assertAlmostEqual(result['gain_loss_amount'], 2000, places=2)
|
||||
|
||||
def test_partial_sale_30pct(self):
|
||||
asset = self._make_asset(cost=10000, salvage_value=0)
|
||||
self.engine.compute_depreciation_schedule(asset)
|
||||
asset.action_set_running()
|
||||
result = self.engine.partial_sale(
|
||||
asset, sold_amount=3500, sold_qty=0.3,
|
||||
sale_date=date(2027, 1, 1),
|
||||
)
|
||||
asset.invalidate_recordset(['cost'])
|
||||
self.assertAlmostEqual(asset.cost, 7000, places=2)
|
||||
child = self.env['fusion.asset'].browse(result['child_asset_id'])
|
||||
self.assertAlmostEqual(child.cost, 3000, places=2)
|
||||
self.assertEqual(child.state, 'disposed')
|
||||
# Child has no posted depreciation; book_value at disposal = 3000.
|
||||
# Gain = 3500 - 3000 = 500.
|
||||
self.assertAlmostEqual(result['gain_loss_amount'], 500, places=0)
|
||||
|
||||
def test_pause_then_resume_lifecycle(self):
|
||||
asset = self._make_asset()
|
||||
self.engine.compute_depreciation_schedule(asset)
|
||||
asset.action_set_running()
|
||||
self.engine.post_depreciation_entry(asset)
|
||||
self.engine.pause_asset(asset)
|
||||
with self.assertRaises(ValidationError):
|
||||
self.engine.post_depreciation_entry(asset)
|
||||
self.engine.resume_asset(asset)
|
||||
result = self.engine.post_depreciation_entry(asset)
|
||||
self.assertEqual(result['posted_count'], 1)
|
||||
59
fusion_accounting_assets/tests/test_fusion_asset.py
Normal file
59
fusion_accounting_assets/tests/test_fusion_asset.py
Normal file
@@ -0,0 +1,59 @@
|
||||
from datetime import date
|
||||
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.tests import tagged
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestFusionAsset(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.asset_vals = {
|
||||
'name': 'Test Asset',
|
||||
'cost': 10000,
|
||||
'salvage_value': 1000,
|
||||
'acquisition_date': date(2026, 1, 1),
|
||||
'method': 'straight_line',
|
||||
'useful_life_years': 5,
|
||||
}
|
||||
|
||||
def test_create_minimal(self):
|
||||
a = self.env['fusion.asset'].create(self.asset_vals)
|
||||
self.assertEqual(a.state, 'draft')
|
||||
self.assertEqual(a.book_value, 10000)
|
||||
|
||||
def test_state_transitions_draft_to_running(self):
|
||||
a = self.env['fusion.asset'].create(self.asset_vals)
|
||||
a.action_set_running()
|
||||
self.assertEqual(a.state, 'running')
|
||||
self.assertTrue(a.in_service_date)
|
||||
|
||||
def test_pause_resume(self):
|
||||
a = self.env['fusion.asset'].create(self.asset_vals)
|
||||
a.action_set_running()
|
||||
a.action_pause()
|
||||
self.assertEqual(a.state, 'paused')
|
||||
a.action_resume()
|
||||
self.assertEqual(a.state, 'running')
|
||||
|
||||
def test_cannot_pause_from_draft(self):
|
||||
a = self.env['fusion.asset'].create(self.asset_vals)
|
||||
with self.assertRaises(ValidationError):
|
||||
a.action_pause()
|
||||
|
||||
def test_negative_cost_rejected(self):
|
||||
with self.assertRaises(Exception):
|
||||
self.env['fusion.asset'].create({**self.asset_vals, 'cost': -100})
|
||||
|
||||
def test_salvage_exceeds_cost_rejected(self):
|
||||
with self.assertRaises(Exception):
|
||||
self.env['fusion.asset'].create(
|
||||
{**self.asset_vals, 'cost': 1000, 'salvage_value': 5000},
|
||||
)
|
||||
|
||||
def test_book_value_starts_at_cost(self):
|
||||
a = self.env['fusion.asset'].create(self.asset_vals)
|
||||
self.assertEqual(a.book_value, a.cost)
|
||||
self.assertEqual(a.total_depreciated, 0)
|
||||
49
fusion_accounting_assets/tests/test_fusion_asset_anomaly.py
Normal file
49
fusion_accounting_assets/tests/test_fusion_asset_anomaly.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from datetime import date
|
||||
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.tests import tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestFusionAssetAnomaly(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.asset = self.env['fusion.asset'].create({
|
||||
'name': 'Watched Asset',
|
||||
'cost': 5000,
|
||||
'acquisition_date': date(2026, 1, 1),
|
||||
})
|
||||
|
||||
def _make_anomaly(self, **kw):
|
||||
vals = {
|
||||
'asset_id': self.asset.id,
|
||||
'anomaly_type': 'behind_schedule',
|
||||
'severity': 'medium',
|
||||
'expected': 1000.0,
|
||||
'actual': 700.0,
|
||||
'variance_pct': -30.0,
|
||||
}
|
||||
vals.update(kw)
|
||||
return self.env['fusion.asset.anomaly'].create(vals)
|
||||
|
||||
def test_create_defaults_state_new(self):
|
||||
a = self._make_anomaly()
|
||||
self.assertEqual(a.state, 'new')
|
||||
self.assertTrue(a.detected_at)
|
||||
self.assertEqual(a.company_id, self.asset.company_id)
|
||||
|
||||
def test_acknowledge_transitions(self):
|
||||
a = self._make_anomaly()
|
||||
a.action_acknowledge()
|
||||
self.assertEqual(a.state, 'acknowledged')
|
||||
|
||||
def test_dismiss_transitions(self):
|
||||
a = self._make_anomaly()
|
||||
a.action_dismiss()
|
||||
self.assertEqual(a.state, 'dismissed')
|
||||
|
||||
def test_resolve_transitions(self):
|
||||
a = self._make_anomaly(anomaly_type='low_utilization', severity='high')
|
||||
a.action_resolve()
|
||||
self.assertEqual(a.state, 'resolved')
|
||||
35
fusion_accounting_assets/tests/test_fusion_asset_category.py
Normal file
35
fusion_accounting_assets/tests/test_fusion_asset_category.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from datetime import date
|
||||
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.tests import tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestFusionAssetCategory(TransactionCase):
|
||||
|
||||
def test_create_with_defaults(self):
|
||||
cat = self.env['fusion.asset.category'].create({'name': 'Computers'})
|
||||
self.assertEqual(cat.method, 'straight_line')
|
||||
self.assertEqual(cat.useful_life_years, 5)
|
||||
self.assertEqual(cat.prorate_convention, 'days_period')
|
||||
self.assertEqual(cat.asset_count, 0)
|
||||
|
||||
def test_asset_count_reflects_linked_assets(self):
|
||||
cat = self.env['fusion.asset.category'].create({'name': 'Vehicles'})
|
||||
for i in range(3):
|
||||
self.env['fusion.asset'].create({
|
||||
'name': f'Truck {i}',
|
||||
'cost': 50000,
|
||||
'acquisition_date': date(2026, 1, 1),
|
||||
'method': 'declining_balance',
|
||||
'category_id': cat.id,
|
||||
})
|
||||
cat.invalidate_recordset(['asset_count'])
|
||||
self.assertEqual(cat.asset_count, 3)
|
||||
|
||||
def test_method_must_be_in_selection(self):
|
||||
with self.assertRaises(Exception):
|
||||
self.env['fusion.asset.category'].create({
|
||||
'name': 'Bogus',
|
||||
'method': 'not_a_method',
|
||||
})
|
||||
@@ -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)
|
||||
56
fusion_accounting_assets/tests/test_fusion_asset_disposal.py
Normal file
56
fusion_accounting_assets/tests/test_fusion_asset_disposal.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from datetime import date
|
||||
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.tests import tagged
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestFusionAssetDisposal(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.asset = self.env['fusion.asset'].create({
|
||||
'name': 'Disposable Asset',
|
||||
'cost': 10000,
|
||||
'salvage_value': 0,
|
||||
'acquisition_date': date(2026, 1, 1),
|
||||
'method': 'straight_line',
|
||||
'useful_life_years': 5,
|
||||
})
|
||||
|
||||
def test_create_minimal_sale(self):
|
||||
d = self.env['fusion.asset.disposal'].create({
|
||||
'asset_id': self.asset.id,
|
||||
'disposal_type': 'sale',
|
||||
'sale_amount': 7000,
|
||||
'book_value_at_disposal': 6000,
|
||||
})
|
||||
self.assertEqual(d.gain_loss_amount, 1000)
|
||||
self.assertEqual(d.company_id, self.asset.company_id)
|
||||
|
||||
def test_sale_at_loss(self):
|
||||
d = self.env['fusion.asset.disposal'].create({
|
||||
'asset_id': self.asset.id,
|
||||
'disposal_type': 'sale',
|
||||
'sale_amount': 4000,
|
||||
'book_value_at_disposal': 6000,
|
||||
})
|
||||
self.assertEqual(d.gain_loss_amount, -2000)
|
||||
|
||||
def test_scrap_full_loss(self):
|
||||
d = self.env['fusion.asset.disposal'].create({
|
||||
'asset_id': self.asset.id,
|
||||
'disposal_type': 'scrap',
|
||||
'sale_amount': 0,
|
||||
'book_value_at_disposal': 6000,
|
||||
})
|
||||
self.assertEqual(d.gain_loss_amount, -6000)
|
||||
|
||||
def test_donation_ignores_sale_amount(self):
|
||||
d = self.env['fusion.asset.disposal'].create({
|
||||
'asset_id': self.asset.id,
|
||||
'disposal_type': 'donation',
|
||||
'sale_amount': 999,
|
||||
'book_value_at_disposal': 6000,
|
||||
})
|
||||
self.assertEqual(d.gain_loss_amount, -6000)
|
||||
115
fusion_accounting_assets/tests/test_fusion_asset_engine.py
Normal file
115
fusion_accounting_assets/tests/test_fusion_asset_engine.py
Normal file
@@ -0,0 +1,115 @@
|
||||
from datetime import date
|
||||
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.tests import tagged
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestFusionAssetEngine(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
self.engine = self.env['fusion.asset.engine']
|
||||
self.asset = self.env['fusion.asset'].create({
|
||||
'name': 'Test Engine Asset',
|
||||
'cost': 10000,
|
||||
'salvage_value': 1000,
|
||||
'acquisition_date': date(2026, 1, 1),
|
||||
'in_service_date': date(2026, 1, 1),
|
||||
'method': 'straight_line',
|
||||
'useful_life_years': 5,
|
||||
})
|
||||
|
||||
def test_engine_model_exists(self):
|
||||
self.assertIn('fusion.asset.engine', self.env.registry)
|
||||
|
||||
def test_compute_schedule_straight_line(self):
|
||||
result = self.engine.compute_depreciation_schedule(self.asset)
|
||||
self.assertEqual(result['lines_created'], 5)
|
||||
lines = self.asset.depreciation_line_ids
|
||||
self.assertEqual(len(lines), 5)
|
||||
# Total depreciation should equal cost - salvage = 9000
|
||||
total = sum(lines.mapped('amount'))
|
||||
self.assertAlmostEqual(total, 9000, places=2)
|
||||
|
||||
def test_compute_schedule_declining_balance(self):
|
||||
self.asset.write({'method': 'declining_balance', 'declining_rate_pct': 30.0})
|
||||
self.engine.compute_depreciation_schedule(self.asset)
|
||||
lines = self.asset.depreciation_line_ids
|
||||
self.assertGreater(len(lines), 0)
|
||||
# First-period amount should be cost * rate = 10000 * 0.3 = 3000
|
||||
first = lines.sorted('period_index')[0]
|
||||
self.assertAlmostEqual(first.amount, 3000, places=2)
|
||||
|
||||
def test_compute_schedule_recompute_wipes_unposted(self):
|
||||
self.engine.compute_depreciation_schedule(self.asset)
|
||||
self.asset.write({'useful_life_years': 8})
|
||||
self.engine.compute_depreciation_schedule(self.asset, recompute=True)
|
||||
self.assertEqual(len(self.asset.depreciation_line_ids), 8)
|
||||
|
||||
def test_compute_schedule_validates_zero_cost(self):
|
||||
# Bypass DB constraint with sudo + the constraint allows cost >= 0,
|
||||
# but engine validation requires cost > 0.
|
||||
bad = self.env['fusion.asset'].create({
|
||||
'name': 'Zero',
|
||||
'cost': 0,
|
||||
'acquisition_date': date(2026, 1, 1),
|
||||
'method': 'straight_line',
|
||||
'useful_life_years': 5,
|
||||
})
|
||||
with self.assertRaises(ValidationError):
|
||||
self.engine.compute_depreciation_schedule(bad)
|
||||
|
||||
def test_post_depreciation_entry_marks_line_posted(self):
|
||||
self.engine.compute_depreciation_schedule(self.asset)
|
||||
self.asset.action_set_running()
|
||||
result = self.engine.post_depreciation_entry(self.asset)
|
||||
self.assertEqual(result['posted_count'], 1)
|
||||
first_line = self.asset.depreciation_line_ids.sorted('period_index')[0]
|
||||
self.assertTrue(first_line.is_posted)
|
||||
|
||||
def test_post_depreciation_only_after_running(self):
|
||||
self.engine.compute_depreciation_schedule(self.asset)
|
||||
# asset is still in 'draft' state
|
||||
with self.assertRaises(ValidationError):
|
||||
self.engine.post_depreciation_entry(self.asset)
|
||||
|
||||
def test_dispose_asset_creates_disposal_record(self):
|
||||
self.engine.compute_depreciation_schedule(self.asset)
|
||||
self.asset.action_set_running()
|
||||
result = self.engine.dispose_asset(
|
||||
self.asset, sale_amount=5000, sale_date=date(2027, 6, 1),
|
||||
)
|
||||
self.assertEqual(self.asset.state, 'disposed')
|
||||
self.assertIn('disposal_id', result)
|
||||
self.assertEqual(result['book_value_at_disposal'], self.asset.book_value)
|
||||
|
||||
def test_partial_sale_creates_child_and_disposes(self):
|
||||
self.engine.compute_depreciation_schedule(self.asset)
|
||||
self.asset.action_set_running()
|
||||
original_cost = self.asset.cost
|
||||
result = self.engine.partial_sale(
|
||||
self.asset, sold_amount=3000, sold_qty=0.3,
|
||||
sale_date=date(2027, 6, 1),
|
||||
)
|
||||
self.assertIn('parent_asset_id', result)
|
||||
self.assertIn('child_asset_id', result)
|
||||
self.asset.invalidate_recordset(['cost'])
|
||||
expected_remaining = round(original_cost * 0.7, 2)
|
||||
self.assertAlmostEqual(self.asset.cost, expected_remaining, places=2)
|
||||
|
||||
def test_pause_resume_round_trip(self):
|
||||
self.asset.action_set_running()
|
||||
self.engine.pause_asset(self.asset)
|
||||
self.assertEqual(self.asset.state, 'paused')
|
||||
self.engine.resume_asset(self.asset)
|
||||
self.assertEqual(self.asset.state, 'running')
|
||||
|
||||
def test_reverse_disposal_restores_running_state(self):
|
||||
self.engine.compute_depreciation_schedule(self.asset)
|
||||
self.asset.action_set_running()
|
||||
self.engine.dispose_asset(self.asset, sale_amount=5000)
|
||||
self.assertEqual(self.asset.state, 'disposed')
|
||||
self.engine.reverse_disposal(self.asset)
|
||||
self.assertEqual(self.asset.state, 'running')
|
||||
65
fusion_accounting_assets/tests/test_prorate.py
Normal file
65
fusion_accounting_assets/tests/test_prorate.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from datetime import date
|
||||
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.tests import tagged
|
||||
from odoo.addons.fusion_accounting_assets.services.prorate import prorate_factor
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestProrate(TransactionCase):
|
||||
|
||||
def test_full_month_convention_always_one(self):
|
||||
f = prorate_factor(
|
||||
period_start=date(2026, 1, 1),
|
||||
period_end=date(2026, 1, 31),
|
||||
asset_start=date(2026, 1, 15),
|
||||
convention='full_month',
|
||||
)
|
||||
self.assertEqual(f, 1.0)
|
||||
|
||||
def test_asset_starts_before_period_full_factor(self):
|
||||
f = prorate_factor(
|
||||
period_start=date(2026, 1, 1),
|
||||
period_end=date(2026, 1, 31),
|
||||
asset_start=date(2025, 12, 1),
|
||||
convention='days_period',
|
||||
)
|
||||
self.assertEqual(f, 1.0)
|
||||
|
||||
def test_asset_starts_after_period_zero_factor(self):
|
||||
f = prorate_factor(
|
||||
period_start=date(2026, 1, 1),
|
||||
period_end=date(2026, 1, 31),
|
||||
asset_start=date(2026, 2, 5),
|
||||
convention='days_period',
|
||||
)
|
||||
self.assertEqual(f, 0.0)
|
||||
|
||||
def test_days_period_mid_month(self):
|
||||
# Jan 16 -> Jan 31 inclusive = 16 days; period = 31 days
|
||||
f = prorate_factor(
|
||||
period_start=date(2026, 1, 1),
|
||||
period_end=date(2026, 1, 31),
|
||||
asset_start=date(2026, 1, 16),
|
||||
convention='days_period',
|
||||
)
|
||||
self.assertAlmostEqual(f, 16 / 31, places=5)
|
||||
|
||||
def test_days_365_mid_month(self):
|
||||
# 16 days / 365
|
||||
f = prorate_factor(
|
||||
period_start=date(2026, 1, 1),
|
||||
period_end=date(2026, 1, 31),
|
||||
asset_start=date(2026, 1, 16),
|
||||
convention='days_365',
|
||||
)
|
||||
self.assertAlmostEqual(f, 16 / 365.0, places=5)
|
||||
|
||||
def test_unknown_convention_raises(self):
|
||||
with self.assertRaises(ValueError):
|
||||
prorate_factor(
|
||||
period_start=date(2026, 1, 1),
|
||||
period_end=date(2026, 1, 31),
|
||||
asset_start=date(2026, 1, 15),
|
||||
convention='bogus', # type: ignore[arg-type]
|
||||
)
|
||||
45
fusion_accounting_assets/tests/test_salvage_value.py
Normal file
45
fusion_accounting_assets/tests/test_salvage_value.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.tests import tagged
|
||||
from odoo.addons.fusion_accounting_assets.services.salvage_value import (
|
||||
SalvageConfig, compute_salvage_value, remaining_useful_life_value,
|
||||
)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestSalvageValue(TransactionCase):
|
||||
|
||||
def test_zero_method_returns_zero(self):
|
||||
v = compute_salvage_value(cost=10000, config=SalvageConfig(method='zero'))
|
||||
self.assertEqual(v, 0.0)
|
||||
|
||||
def test_percentage_method(self):
|
||||
v = compute_salvage_value(
|
||||
cost=10000, config=SalvageConfig(method='percentage', value=10),
|
||||
)
|
||||
self.assertAlmostEqual(v, 1000.0, places=2)
|
||||
|
||||
def test_fixed_method(self):
|
||||
v = compute_salvage_value(
|
||||
cost=10000, config=SalvageConfig(method='fixed', value=750),
|
||||
)
|
||||
self.assertAlmostEqual(v, 750.0, places=2)
|
||||
|
||||
def test_unknown_method_raises(self):
|
||||
with self.assertRaises(ValueError):
|
||||
compute_salvage_value(
|
||||
cost=10000,
|
||||
config=SalvageConfig(method='bogus', value=0), # type: ignore[arg-type]
|
||||
)
|
||||
|
||||
def test_remaining_useful_life_value_midway(self):
|
||||
# Halfway through life; current book 6000, salvage 1000 -> 1000 + 5000*0.5 = 3500
|
||||
v = remaining_useful_life_value(
|
||||
current_book=6000, salvage=1000, periods_used=5, total_periods=10,
|
||||
)
|
||||
self.assertAlmostEqual(v, 3500.0, places=2)
|
||||
|
||||
def test_remaining_useful_life_value_at_end_returns_salvage(self):
|
||||
v = remaining_useful_life_value(
|
||||
current_book=1200, salvage=1000, periods_used=10, total_periods=10,
|
||||
)
|
||||
self.assertEqual(v, 1000.0)
|
||||
61
fusion_accounting_assets/tests/test_useful_life_predictor.py
Normal file
61
fusion_accounting_assets/tests/test_useful_life_predictor.py
Normal file
@@ -0,0 +1,61 @@
|
||||
from odoo.tests.common import TransactionCase
|
||||
from odoo.tests import tagged
|
||||
from odoo.addons.fusion_accounting_assets.services.useful_life_predictor import (
|
||||
predict_useful_life,
|
||||
)
|
||||
from odoo.addons.fusion_accounting_assets.services.useful_life_prompt import (
|
||||
SYSTEM_PROMPT, build_prompt,
|
||||
)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestUsefulLifePredictor(TransactionCase):
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
# Ensure no provider configured for these fallback tests.
|
||||
self.env['ir.config_parameter'].sudo().search([
|
||||
('key', 'in', [
|
||||
'fusion_accounting.provider.asset_useful_life',
|
||||
'fusion_accounting.provider.default',
|
||||
])
|
||||
]).unlink()
|
||||
|
||||
def test_fallback_computer(self):
|
||||
result = predict_useful_life(self.env, description="Dell laptop")
|
||||
self.assertEqual(result['useful_life_years'], 4)
|
||||
self.assertEqual(result['depreciation_method'], 'straight_line')
|
||||
|
||||
def test_fallback_furniture(self):
|
||||
result = predict_useful_life(self.env, description="office desk")
|
||||
self.assertEqual(result['useful_life_years'], 7)
|
||||
|
||||
def test_fallback_vehicle_uses_declining(self):
|
||||
result = predict_useful_life(self.env, description="Ford F-150 truck")
|
||||
self.assertEqual(result['useful_life_years'], 5)
|
||||
self.assertEqual(result['depreciation_method'], 'declining_balance')
|
||||
|
||||
def test_fallback_default_for_unknown(self):
|
||||
result = predict_useful_life(self.env, description="mystery widget")
|
||||
self.assertEqual(result['useful_life_years'], 5)
|
||||
self.assertEqual(result['confidence'], 0.3)
|
||||
|
||||
def test_returns_dict_with_required_keys(self):
|
||||
result = predict_useful_life(self.env, description="server")
|
||||
for key in ('useful_life_years', 'depreciation_method', 'rationale', 'confidence'):
|
||||
self.assertIn(key, result)
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestUsefulLifePrompt(TransactionCase):
|
||||
|
||||
def test_system_prompt_requires_json(self):
|
||||
self.assertIn('JSON', SYSTEM_PROMPT)
|
||||
|
||||
def test_build_prompt_returns_tuple(self):
|
||||
result = build_prompt(description='test')
|
||||
self.assertEqual(len(result), 2)
|
||||
|
||||
def test_user_prompt_includes_amount(self):
|
||||
_, user = build_prompt(description='laptop', amount=2000)
|
||||
self.assertIn('2,000', user)
|
||||
0
fusion_accounting_assets/wizards/__init__.py
Normal file
0
fusion_accounting_assets/wizards/__init__.py
Normal file
147
fusion_accounting_reports/CLAUDE.md
Normal file
147
fusion_accounting_reports/CLAUDE.md
Normal file
@@ -0,0 +1,147 @@
|
||||
# fusion_accounting_reports — Cursor / Claude Context
|
||||
|
||||
## Purpose
|
||||
|
||||
AI-augmented financial reports — a Fusion-native replacement for Odoo
|
||||
Enterprise's `account_reports` module. Phase 2 of the fusion_accounting
|
||||
roadmap.
|
||||
|
||||
CORE scope:
|
||||
- Income Statement (P&L)
|
||||
- Balance Sheet
|
||||
- Trial Balance
|
||||
- General Ledger (with drill-down)
|
||||
|
||||
AI augmentation:
|
||||
- Anomaly detection (variance vs prior period)
|
||||
- AI commentary (LLM-generated narrative)
|
||||
|
||||
## Architecture
|
||||
|
||||
Hybrid: the engine (`fusion.report.engine`, AbstractModel) is the SINGLE
|
||||
read surface for reports. Per-report definitions are stored as `fusion.report`
|
||||
records with JSON `line_specs` so non-developers can tweak the layouts.
|
||||
|
||||
Public engine API (5 methods):
|
||||
- `compute_pnl(period, *, comparison='none', company_id=None)`
|
||||
- `compute_balance_sheet(date_to, *, comparison='none', company_id=None)`
|
||||
- `compute_trial_balance(period, *, company_id=None)`
|
||||
- `compute_gl(period, *, account_ids=None, company_id=None)`
|
||||
- `drill_down(*, account_id, period, company_id=None)`
|
||||
|
||||
Pure-Python services in `services/` (no Odoo imports — independently
|
||||
unit-testable):
|
||||
- `date_periods` — `Period` dataclass + comparison-period math
|
||||
- `account_hierarchy` — chart-of-accounts tree walk
|
||||
- `totaling` — debit/credit/balance roll-ups
|
||||
- `currency_conversion` — multi-currency conversion via `res.currency.rate`
|
||||
- `line_resolver` — JSON `line_specs` → rendered rows
|
||||
- `drill_down_resolver` — line → underlying journal items
|
||||
- `anomaly_detection` — variance vs prior period (z-score + abs/pct gates)
|
||||
- `commentary_generator` — LLM narrative with templated fallback
|
||||
- `commentary_prompt` — provider-agnostic system + user prompt
|
||||
|
||||
Persisted models in `models/`:
|
||||
- `fusion.report` — definition with JSON `line_specs`
|
||||
- `fusion.report.commentary` — LLM-output cache (one per period+mode)
|
||||
- `fusion.report.anomaly` — flagged variances
|
||||
- `fusion.account.balance.mv` — pre-aggregated materialized view
|
||||
- `fusion.report.engine` — AbstractModel (the API)
|
||||
- `fusion.reports.cron` — cron handlers (commentary refresh, MV refresh)
|
||||
- `fusion.xlsx.export.wizard` — TransientModel (XLSX export)
|
||||
- `fusion.period.picker.wizard` — TransientModel (UX entry-point)
|
||||
- `fusion.migration.wizard` (inherits) — adds `_reports_bootstrap_step`
|
||||
|
||||
Controller: `controllers/reports_controller.py` exposes 8 JSON-RPC endpoints
|
||||
under `/fusion/reports/*`. All read paths route through the engine.
|
||||
|
||||
OWL frontend: `static/src/`
|
||||
- `scss/` — variables, base styles, dark-mode overrides
|
||||
- `services/reports_service.js` — central reactive state + RPC wrappers
|
||||
- `views/report_viewer/` — top-level OWL view + view-registry adapter
|
||||
- `components/report_table/` — generic financial-table renderer
|
||||
- `components/drill_down_dialog/` — modal for journal-item listing
|
||||
- `components/period_filter/` — date-range + comparison picker
|
||||
- `components/ai_commentary_panel/` — LLM commentary surface
|
||||
- `components/anomaly_strip/` — variance summary banner
|
||||
- `tours/reports_tours.js` — 5 OWL tour smoke tests
|
||||
|
||||
## Coexistence
|
||||
|
||||
When `account_reports` is installed, the Reports menu hides via
|
||||
`fusion_accounting_core.group_fusion_show_when_enterprise_absent`
|
||||
(a computed group). The engine + AI tools (commentary, anomaly detection)
|
||||
remain available for the chat regardless.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **V19 deprecations to avoid:** `_sql_constraints` (use `models.Constraint`),
|
||||
`@api.depends('id')` (raises `NotImplementedError`), `@route(type='json')`
|
||||
(use `type='jsonrpc'`), `numbercall` field on `ir.cron` (removed),
|
||||
`groups_id` on `res.users` (use `all_group_ids` for searching),
|
||||
`users` field on `res.groups` (use `user_ids`), `groups_id` on
|
||||
`ir.ui.menu` (use `group_ids`).
|
||||
|
||||
- **Engine signature:** Public methods are keyword-only after the leading
|
||||
positional `period` / `date_to`. Always pass `company_id=...` explicitly.
|
||||
|
||||
- **`fusion.report` lookup:** `_get_report` falls back from per-company
|
||||
override to global (`company_id=False`) — order is `company_id desc nulls
|
||||
last`.
|
||||
|
||||
- **Materialized view refresh:** `fusion.account.balance.mv` rebuilds via a
|
||||
dedicated autocommit cursor (REFRESH CONCURRENTLY can't run inside Odoo's
|
||||
regular transaction). Triggered by cron + on demand from the engine when
|
||||
data is older than the configured TTL.
|
||||
|
||||
- **JSON `line_specs`:** Strings prefixed `account:`, `prefix:`, `formula:`
|
||||
or `header` — `line_resolver.py` resolves each spec to a row. Header rows
|
||||
have no compute payload and are silently skipped by downstream totals.
|
||||
|
||||
- **Commentary cache:** Keyed on `(report_id, company_id, period_from,
|
||||
period_to, comparison_mode)` with a unique constraint. Re-runs use the
|
||||
cache unless `force_refresh=True`.
|
||||
|
||||
## Test counts (Phase 2 ship)
|
||||
|
||||
- 130 logical tests, 0 failed, 0 errors
|
||||
- Includes:
|
||||
- 6 benchmarks (tagged `benchmark`)
|
||||
- 1 LLM compat smoke (tagged `local_llm`, skips when no LLM)
|
||||
- 5 OWL tours (tagged `tour`, skips without `websocket-client`)
|
||||
- Property-based, integration, controller, materialized-view, coexistence,
|
||||
migration round-trip, PDF/XLSX export
|
||||
|
||||
## Performance baseline
|
||||
|
||||
| Operation | Median | P95 | Budget |
|
||||
|---|---|---|---|
|
||||
| `engine.compute_pnl` | 3ms | 8ms | <2000ms |
|
||||
| `engine.compute_balance_sheet` | 15ms | 20ms | <2000ms |
|
||||
| `engine.compute_trial_balance` | 3ms | 8ms | <1000ms |
|
||||
| `engine.compute_gl` | 25ms | 81ms | <3000ms |
|
||||
| `engine.drill_down` | 2ms | 10ms | <500ms |
|
||||
| `controller.run` (HTTP round-trip) | 9ms | 46ms | <2500ms |
|
||||
|
||||
All metrics within 1x of budget at Phase 2 ship. Numbers from
|
||||
`tests/test_performance_benchmarks.py` against the dev VM
|
||||
(`westin-v19`, ~1 fiscal year of data).
|
||||
|
||||
## Known concerns / Phase 2.5 backlog
|
||||
|
||||
- Trial balance period-only sum doesn't auto-close to retained earnings
|
||||
(drift visible in `test_trial_balance_total_near_zero`, currently skipped)
|
||||
- Balance sheet `TOTAL LIABILITIES + EQUITY` math limited (no
|
||||
subtotal-of-subtotals expansion in `formula:` specs)
|
||||
- GL `line_specs` need `prefix:` empty-string handling for
|
||||
"all accounts" semantics
|
||||
- Header rows (no compute payload) silently skipped by `line_resolver` —
|
||||
fine for layout, but a `header_only=True` flag would be clearer
|
||||
- `expense` prefix overlaps with subtypes (`expense_direct_cost`,
|
||||
`expense_depreciation`) — current line_specs need explicit ordering or a
|
||||
longer-prefix-wins rule
|
||||
- `wkhtmltopdf` may need configuration for PDF export on first install
|
||||
- `ReportsAdapter.run_report` vs `run_fusion_report` naming (legacy clash
|
||||
with Enterprise wrapper)
|
||||
- Tour tests skip when `websocket-client` is absent — install it in CI to
|
||||
exercise the OWL surface end-to-end
|
||||
103
fusion_accounting_reports/README.md
Normal file
103
fusion_accounting_reports/README.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# fusion_accounting_reports
|
||||
|
||||
AI-augmented financial reports for Odoo 19 Community — a Fusion-native
|
||||
replacement for Enterprise's `account_reports` module.
|
||||
|
||||
## What it does
|
||||
|
||||
- **CORE reports**: Income Statement (P&L), Balance Sheet, Trial Balance,
|
||||
General Ledger (with drill-down to journal items)
|
||||
- **AI augmentation**: variance-based anomaly detection + LLM-generated
|
||||
commentary (Claude / GPT / local LM Studio / Ollama)
|
||||
- **Wizards**: period picker (common presets — MTD, QTD, YTD, last month,
|
||||
custom range) + XLSX export
|
||||
- **Coexists** with Enterprise's `account_reports` (Enterprise wins by
|
||||
default; the Fusion menu appears only when Enterprise is uninstalled —
|
||||
the engine and AI tools are always available via the AI chat)
|
||||
- **Multi-currency** aware via `services/currency_conversion.py`
|
||||
- **Multi-company** aware (per-company `fusion.report` overrides fall back
|
||||
to global definitions)
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# Install
|
||||
odoo --addons-path=... -i fusion_accounting_reports
|
||||
|
||||
# Open the reports menu (when Enterprise's account_reports is NOT installed)
|
||||
# Apps → Reports → Open Financial Report
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
### LLM commentary (optional)
|
||||
|
||||
For LM Studio / Ollama (local):
|
||||
|
||||
- `fusion_accounting.openai_base_url` = `http://host.docker.internal:1234/v1`
|
||||
- `fusion_accounting.openai_model` = your local model name
|
||||
- `fusion_accounting.openai_api_key` = `lm-studio` (or anything non-empty)
|
||||
- `fusion_accounting.provider.reports_commentary` = `openai`
|
||||
|
||||
For OpenAI / Anthropic, set the corresponding API keys via the
|
||||
`fusion_accounting_ai` config screen — `reports_commentary` will route
|
||||
through whatever provider you choose.
|
||||
|
||||
If no provider is configured, commentary falls back to a deterministic
|
||||
templated summary (no LLM call).
|
||||
|
||||
### Cron jobs
|
||||
|
||||
Two cron handlers live in `models/fusion_reports_cron.py`:
|
||||
|
||||
- `fusion_reports_commentary_refresh` — daily, regenerates commentary for
|
||||
the most recently completed period
|
||||
- `fusion_reports_mv_refresh` — every 15 min, refreshes
|
||||
`fusion.account.balance.mv`
|
||||
|
||||
## Public engine API
|
||||
|
||||
```python
|
||||
engine = env['fusion.report.engine']
|
||||
|
||||
# Income statement
|
||||
result = engine.compute_pnl(period, comparison='previous_year')
|
||||
|
||||
# Balance sheet (point-in-time)
|
||||
result = engine.compute_balance_sheet(date(2026, 12, 31))
|
||||
|
||||
# Trial balance
|
||||
result = engine.compute_trial_balance(period)
|
||||
|
||||
# General ledger (journal items per account)
|
||||
result = engine.compute_gl(period, account_ids=[1, 2, 3])
|
||||
|
||||
# Drill-down (one account, period)
|
||||
items = engine.drill_down(account_id=1, period=period)
|
||||
```
|
||||
|
||||
## JSON-RPC endpoints
|
||||
|
||||
All under `/fusion/reports/`:
|
||||
|
||||
- `POST /fusion/reports/run` — single entry-point (dispatches by `report_type`)
|
||||
- `POST /fusion/reports/drill_down` — journal items for an account+period
|
||||
- `POST /fusion/reports/commentary` — fetch/refresh LLM commentary
|
||||
- `POST /fusion/reports/anomalies` — flagged variances for a period
|
||||
- `POST /fusion/reports/export_xlsx` — XLSX bytes
|
||||
- `POST /fusion/reports/export_pdf` — PDF bytes (via wkhtmltopdf)
|
||||
- `POST /fusion/reports/list_definitions` — available `fusion.report` records
|
||||
- `POST /fusion/reports/period_presets` — date-range presets for the picker
|
||||
|
||||
## Test counts
|
||||
|
||||
- 130 logical tests, 0 failures, 0 errors
|
||||
- 6 performance benchmarks (tagged `benchmark`)
|
||||
- 1 local-LLM compat smoke (tagged `local_llm`, skips without LLM)
|
||||
- 5 OWL tour tests (tagged `tour`, skips without `websocket-client`)
|
||||
|
||||
## See also
|
||||
|
||||
- `CLAUDE.md` — agent context (architecture, conventions, perf baseline,
|
||||
Phase 2.5 backlog)
|
||||
- `UPGRADE_NOTES.md` — V19 anchor + migration strategy
|
||||
60
fusion_accounting_reports/UPGRADE_NOTES.md
Normal file
60
fusion_accounting_reports/UPGRADE_NOTES.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# fusion_accounting_reports — Upgrade Notes
|
||||
|
||||
## Odoo Version Anchor
|
||||
|
||||
This module targets **Odoo 19.0** (community-base).
|
||||
|
||||
Reference snapshot of Enterprise code mirrored from:
|
||||
- `account_reports` (Odoo 19.0.x)
|
||||
- Source: `/Users/gurpreet/Github/RePackaged-Odoo/accounting/account_reports/`
|
||||
|
||||
## Cross-Version Diff Strategy
|
||||
|
||||
When a new Odoo version ships:
|
||||
|
||||
1. Run `check_odoo_diff.sh` (in repo root) against the new Enterprise version
|
||||
2. Note any breaking changes in `account.move.line` / `account.account` API
|
||||
surfaces relied on by `services/totaling.py` and
|
||||
`services/drill_down_resolver.py`
|
||||
3. For mirrored OWL components, diff Enterprise's new versions against ours
|
||||
and port material changes (signature renames, new behaviour we want to
|
||||
inherit)
|
||||
4. Re-run the full test suite + tour tests + benchmarks against the new Odoo
|
||||
version
|
||||
5. Update this file with the new version anchor + any deviations
|
||||
|
||||
## V19 Migration Notes (already applied — Phase 1 lessons)
|
||||
|
||||
These were the bite-points from Phase 1 (`fusion_accounting_bank_rec`); we
|
||||
preempted them in Phase 2 from day one:
|
||||
|
||||
- `_sql_constraints` → `models.Constraint` (used in `fusion.report`,
|
||||
`fusion.report.commentary`, `fusion.report.anomaly`)
|
||||
- `@api.depends('id')` → removed everywhere; computed fields depend on real
|
||||
field names instead
|
||||
- `@route(type='json')` → `type='jsonrpc'` (all 8 endpoints)
|
||||
- `numbercall` field on `ir.cron` → omitted (removed in V19)
|
||||
- `res.groups.users` → `user_ids`
|
||||
- `ir.ui.menu.groups_id` → `group_ids` (used in `views/menu_views.xml` and
|
||||
the two wizard view files for the coexistence-group filter)
|
||||
|
||||
## Engine API Stability
|
||||
|
||||
The 5 public engine methods (`compute_pnl`, `compute_balance_sheet`,
|
||||
`compute_trial_balance`, `compute_gl`, `drill_down`) are the public contract.
|
||||
Their signatures are keyword-only after the first positional argument and
|
||||
will be treated as semver-stable across patch releases. Breaking changes
|
||||
will bump the minor version (e.g. 19.0.2.x.y).
|
||||
|
||||
## Phase 2 → Phase 2.5 Migration
|
||||
|
||||
If we ship Phase 2.5 (line_spec polish, deferred features, header_only
|
||||
flag, prefix overlap fix), changes will go in incremental commits. No DB
|
||||
migration needed — Phase 2 schema is forward-compatible:
|
||||
|
||||
- `fusion.report.line_specs` is a JSON column; the migration path is to
|
||||
rewrite specs in place
|
||||
- `fusion.account.balance.mv` can be dropped/re-created freely
|
||||
- `fusion.report.commentary` is a cache; safe to truncate on upgrade
|
||||
- `fusion.report.anomaly` records carry Period as date_from/date_to fields;
|
||||
no schema-level changes anticipated
|
||||
5
fusion_accounting_reports/__init__.py
Normal file
5
fusion_accounting_reports/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from . import services
|
||||
from . import models
|
||||
from . import controllers
|
||||
from . import reports
|
||||
from . import wizards
|
||||
76
fusion_accounting_reports/__manifest__.py
Normal file
76
fusion_accounting_reports/__manifest__.py
Normal file
@@ -0,0 +1,76 @@
|
||||
{
|
||||
'name': 'Fusion Accounting Reports',
|
||||
'version': '19.0.1.0.38',
|
||||
'category': 'Accounting/Accounting',
|
||||
'summary': 'AI-augmented financial reports (P&L, balance sheet, trial balance, GL).',
|
||||
'description': """
|
||||
Fusion Accounting Reports
|
||||
=========================
|
||||
|
||||
A Fusion-native replacement for Odoo Enterprise's account_reports module.
|
||||
|
||||
CORE scope (Phase 2):
|
||||
- Income Statement (P&L)
|
||||
- Balance Sheet
|
||||
- Trial Balance
|
||||
- General Ledger (with drill-down)
|
||||
|
||||
AI augmentation:
|
||||
- Anomaly detection (variance vs prior period)
|
||||
- AI commentary (LLM-generated narrative)
|
||||
|
||||
Coexists with Enterprise: when account_reports is installed, the Fusion
|
||||
menu hides; the engine and AI tools remain available for the chat.
|
||||
""",
|
||||
'author': 'Fusion Accounting',
|
||||
'license': 'LGPL-3',
|
||||
'depends': [
|
||||
'fusion_accounting_core',
|
||||
'fusion_accounting_ai',
|
||||
'fusion_accounting_migration',
|
||||
'account',
|
||||
],
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'data/report_pnl.xml',
|
||||
'data/report_balance_sheet.xml',
|
||||
'data/report_trial_balance.xml',
|
||||
'data/report_general_ledger.xml',
|
||||
'data/cron.xml',
|
||||
'reports/report_pdf_template.xml',
|
||||
'wizards/xlsx_export_wizard_views.xml',
|
||||
'wizards/period_picker_wizard_views.xml',
|
||||
'views/menu_views.xml',
|
||||
],
|
||||
'external_dependencies': {
|
||||
'python': ['xlsxwriter'],
|
||||
},
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'fusion_accounting_reports/static/src/scss/_variables.scss',
|
||||
'fusion_accounting_reports/static/src/scss/reports.scss',
|
||||
'fusion_accounting_reports/static/src/scss/dark_mode.scss',
|
||||
'fusion_accounting_reports/static/src/services/reports_service.js',
|
||||
'fusion_accounting_reports/static/src/views/report_viewer/report_viewer.js',
|
||||
'fusion_accounting_reports/static/src/views/report_viewer/report_viewer.xml',
|
||||
'fusion_accounting_reports/static/src/views/report_viewer/report_viewer_view.js',
|
||||
'fusion_accounting_reports/static/src/components/report_table/report_table.js',
|
||||
'fusion_accounting_reports/static/src/components/report_table/report_table.xml',
|
||||
'fusion_accounting_reports/static/src/components/drill_down_dialog/drill_down_dialog.js',
|
||||
'fusion_accounting_reports/static/src/components/drill_down_dialog/drill_down_dialog.xml',
|
||||
'fusion_accounting_reports/static/src/components/period_filter/period_filter.js',
|
||||
'fusion_accounting_reports/static/src/components/period_filter/period_filter.xml',
|
||||
'fusion_accounting_reports/static/src/components/ai_commentary_panel/ai_commentary_panel.js',
|
||||
'fusion_accounting_reports/static/src/components/ai_commentary_panel/ai_commentary_panel.xml',
|
||||
'fusion_accounting_reports/static/src/components/anomaly_strip/anomaly_strip.js',
|
||||
'fusion_accounting_reports/static/src/components/anomaly_strip/anomaly_strip.xml',
|
||||
],
|
||||
'web.assets_tests': [
|
||||
'fusion_accounting_reports/static/src/tours/reports_tours.js',
|
||||
],
|
||||
},
|
||||
'installable': True,
|
||||
'auto_install': False,
|
||||
'application': False,
|
||||
'icon': '/fusion_accounting_reports/static/description/icon.png',
|
||||
}
|
||||
1
fusion_accounting_reports/controllers/__init__.py
Normal file
1
fusion_accounting_reports/controllers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import reports_controller
|
||||
248
fusion_accounting_reports/controllers/reports_controller.py
Normal file
248
fusion_accounting_reports/controllers/reports_controller.py
Normal file
@@ -0,0 +1,248 @@
|
||||
"""HTTP controller: 8 JSON-RPC endpoints for the OWL reports widget.
|
||||
|
||||
All endpoints route through fusion.report.engine - no direct ORM
|
||||
aggregation from the controller. Uses V19's type='jsonrpc'.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import date, datetime
|
||||
|
||||
from odoo import _, http
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.http import request
|
||||
|
||||
from ..services.anomaly_detection import detect as detect_anomalies
|
||||
from ..services.commentary_generator import generate_commentary
|
||||
from ..services.date_periods import Period
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
REPORT_TYPES = {'pnl', 'balance_sheet', 'trial_balance', 'general_ledger'}
|
||||
|
||||
|
||||
def _parse_date(value):
|
||||
if isinstance(value, date):
|
||||
return value
|
||||
return datetime.strptime(value, '%Y-%m-%d').date()
|
||||
|
||||
|
||||
def _build_period(date_from, date_to, label=None):
|
||||
df = _parse_date(date_from)
|
||||
dt = _parse_date(date_to)
|
||||
return Period(date_from=df, date_to=dt, label=label or f"{df} - {dt}")
|
||||
|
||||
|
||||
class FusionReportsController(http.Controller):
|
||||
|
||||
@http.route('/fusion/reports/list_available', type='jsonrpc', auth='user')
|
||||
def list_available(self, company_id=None):
|
||||
company_id = int(company_id) if company_id else request.env.company.id
|
||||
Report = request.env['fusion.report'].sudo()
|
||||
reports = Report.search([
|
||||
('active', '=', True),
|
||||
'|', ('company_id', '=', company_id), ('company_id', '=', False),
|
||||
], order='sequence, name')
|
||||
return {
|
||||
'reports': [{
|
||||
'id': r.id,
|
||||
'name': r.name,
|
||||
'code': r.code,
|
||||
'report_type': r.report_type,
|
||||
'description': r.description or '',
|
||||
'default_comparison_mode': r.default_comparison_mode,
|
||||
} for r in reports],
|
||||
}
|
||||
|
||||
@http.route('/fusion/reports/run', type='jsonrpc', auth='user')
|
||||
def run(self, report_type, date_from=None, date_to=None,
|
||||
comparison='none', company_id=None):
|
||||
if report_type not in REPORT_TYPES:
|
||||
raise ValidationError(_("Unknown report type: %s") % report_type)
|
||||
company_id = int(company_id) if company_id else request.env.company.id
|
||||
engine = request.env['fusion.report.engine']
|
||||
|
||||
if report_type == 'pnl':
|
||||
period = _build_period(date_from, date_to)
|
||||
return engine.compute_pnl(
|
||||
period, comparison=comparison, company_id=company_id,
|
||||
)
|
||||
if report_type == 'balance_sheet':
|
||||
return engine.compute_balance_sheet(
|
||||
_parse_date(date_to),
|
||||
comparison=comparison,
|
||||
company_id=company_id,
|
||||
)
|
||||
if report_type == 'trial_balance':
|
||||
period = _build_period(date_from, date_to)
|
||||
return engine.compute_trial_balance(period, company_id=company_id)
|
||||
# general_ledger
|
||||
period = _build_period(date_from, date_to)
|
||||
return engine.compute_gl(period, company_id=company_id)
|
||||
|
||||
@http.route('/fusion/reports/drill_down', type='jsonrpc', auth='user')
|
||||
def drill_down(self, account_id, date_from, date_to, company_id=None):
|
||||
company_id = int(company_id) if company_id else request.env.company.id
|
||||
engine = request.env['fusion.report.engine']
|
||||
period = _build_period(date_from, date_to)
|
||||
rows = engine.drill_down(
|
||||
account_id=int(account_id),
|
||||
period=period,
|
||||
company_id=company_id,
|
||||
)
|
||||
return {'rows': rows, 'count': len(rows)}
|
||||
|
||||
@http.route('/fusion/reports/get_anomalies', type='jsonrpc', auth='user')
|
||||
def get_anomalies(self, report_type, date_from, date_to,
|
||||
comparison='previous_year', persist=False, company_id=None):
|
||||
company_id = int(company_id) if company_id else request.env.company.id
|
||||
report_result = self.run(
|
||||
report_type=report_type,
|
||||
date_from=date_from, date_to=date_to,
|
||||
comparison=comparison, company_id=company_id,
|
||||
)
|
||||
anomalies = detect_anomalies(report_result)
|
||||
if persist and anomalies:
|
||||
Report = request.env['fusion.report']
|
||||
report_def = Report.search([('report_type', '=', report_type)], limit=1)
|
||||
if report_def:
|
||||
self._persist_anomalies(
|
||||
report_def,
|
||||
_parse_date(date_from), _parse_date(date_to),
|
||||
anomalies,
|
||||
)
|
||||
return {'anomalies': anomalies, 'count': len(anomalies)}
|
||||
|
||||
def _persist_anomalies(self, report, period_from, period_to, anomalies):
|
||||
Anomaly = request.env['fusion.report.anomaly']
|
||||
for a in anomalies:
|
||||
existing = Anomaly.search([
|
||||
('report_id', '=', report.id),
|
||||
('period_from', '=', period_from),
|
||||
('period_to', '=', period_to),
|
||||
('row_id', '=', a['row_id']),
|
||||
], limit=1)
|
||||
vals = {
|
||||
'report_id': report.id,
|
||||
'period_from': period_from,
|
||||
'period_to': period_to,
|
||||
'row_id': a['row_id'],
|
||||
'label': a['label'],
|
||||
'current_amount': a['current_amount'],
|
||||
'comparison_amount': a['comparison_amount'],
|
||||
'variance_amount': a['variance_amount'],
|
||||
'variance_pct': a['variance_pct'],
|
||||
'severity': a['severity'],
|
||||
'direction': a['direction'],
|
||||
}
|
||||
if existing:
|
||||
existing.write(vals)
|
||||
else:
|
||||
Anomaly.create(vals)
|
||||
|
||||
@http.route('/fusion/reports/get_commentary', type='jsonrpc', auth='user')
|
||||
def get_commentary(self, report_type, date_from, date_to,
|
||||
comparison='none', force_regenerate=False, company_id=None):
|
||||
company_id = int(company_id) if company_id else request.env.company.id
|
||||
Report = request.env['fusion.report']
|
||||
Commentary = request.env['fusion.report.commentary']
|
||||
report_def = Report.search([('report_type', '=', report_type)], limit=1)
|
||||
if not report_def:
|
||||
raise ValidationError(_("No report definition for %s") % report_type)
|
||||
|
||||
period_from = _parse_date(date_from)
|
||||
period_to = _parse_date(date_to)
|
||||
|
||||
cached = Commentary.search([
|
||||
('report_id', '=', report_def.id),
|
||||
('company_id', '=', company_id),
|
||||
('period_from', '=', period_from),
|
||||
('period_to', '=', period_to),
|
||||
('comparison_mode', '=', comparison),
|
||||
], limit=1)
|
||||
if cached and not force_regenerate:
|
||||
return {
|
||||
'cached': True,
|
||||
'summary': cached.summary or '',
|
||||
'highlights': cached.highlights or [],
|
||||
'concerns': cached.concerns or [],
|
||||
'next_actions': cached.next_actions or [],
|
||||
'generated_at': str(cached.generated_at),
|
||||
}
|
||||
|
||||
report_result = self.run(
|
||||
report_type=report_type, date_from=date_from,
|
||||
date_to=date_to, comparison=comparison,
|
||||
company_id=company_id,
|
||||
)
|
||||
anomalies = detect_anomalies(report_result)
|
||||
commentary = generate_commentary(
|
||||
request.env,
|
||||
report_result=report_result,
|
||||
anomalies=anomalies,
|
||||
)
|
||||
vals = {
|
||||
'report_id': report_def.id,
|
||||
'company_id': company_id,
|
||||
'period_from': period_from,
|
||||
'period_to': period_to,
|
||||
'comparison_mode': comparison,
|
||||
'summary': commentary.get('summary', ''),
|
||||
'highlights': commentary.get('highlights', []),
|
||||
'concerns': commentary.get('concerns', []),
|
||||
'next_actions': commentary.get('next_actions', []),
|
||||
}
|
||||
if cached:
|
||||
cached.write(vals)
|
||||
else:
|
||||
Commentary.create(vals)
|
||||
return {'cached': False, **commentary}
|
||||
|
||||
@http.route('/fusion/reports/compare_periods', type='jsonrpc', auth='user')
|
||||
def compare_periods(self, report_type, date_from, date_to,
|
||||
comparison='previous_year', company_id=None):
|
||||
return self.run(
|
||||
report_type=report_type, date_from=date_from,
|
||||
date_to=date_to, comparison=comparison,
|
||||
company_id=company_id,
|
||||
)
|
||||
|
||||
@http.route('/fusion/reports/export_pdf', type='jsonrpc', auth='user')
|
||||
def export_pdf(self, report_type, date_from, date_to,
|
||||
comparison='none', company_id=None):
|
||||
Report = request.env['fusion.report']
|
||||
report_def = Report.search([('report_type', '=', report_type)], limit=1)
|
||||
if not report_def:
|
||||
return {'status': 'error', 'message': f'No report definition for {report_type}'}
|
||||
company_id = int(company_id) if company_id else request.env.company.id
|
||||
pdf, _ct = request.env['ir.actions.report'].sudo()._render_qweb_pdf(
|
||||
'fusion_accounting_reports.report_pdf_template',
|
||||
res_ids=[report_def.id],
|
||||
data={
|
||||
'report_type': report_type,
|
||||
'date_from': date_from, 'date_to': date_to,
|
||||
'comparison': comparison, 'company_id': company_id,
|
||||
},
|
||||
)
|
||||
import base64
|
||||
return {
|
||||
'status': 'ok',
|
||||
'pdf_base64': base64.b64encode(pdf).decode('ascii'),
|
||||
'filename': f'{report_type}_{date_from}_{date_to}.pdf',
|
||||
}
|
||||
|
||||
@http.route('/fusion/reports/export_xlsx', type='jsonrpc', auth='user')
|
||||
def export_xlsx(self, report_type, date_from, date_to,
|
||||
comparison='none', company_id=None):
|
||||
wizard = request.env['fusion.xlsx.export.wizard'].create({
|
||||
'report_type': report_type,
|
||||
'date_from': _parse_date(date_from),
|
||||
'date_to': _parse_date(date_to),
|
||||
'comparison': comparison,
|
||||
})
|
||||
wizard.action_export()
|
||||
return {
|
||||
'status': 'ok',
|
||||
'xlsx_base64': wizard.xlsx_file.decode('ascii') if wizard.xlsx_file else '',
|
||||
'filename': wizard.xlsx_filename,
|
||||
}
|
||||
24
fusion_accounting_reports/data/cron.xml
Normal file
24
fusion_accounting_reports/data/cron.xml
Normal file
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
|
||||
<record id="cron_fusion_reports_anomaly_scan" model="ir.cron">
|
||||
<field name="name">Fusion Reports - Daily Anomaly Scan</field>
|
||||
<field name="model_id" ref="model_fusion_reports_cron"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_anomaly_scan()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="cron_fusion_reports_mv_refresh" model="ir.cron">
|
||||
<field name="name">Fusion Reports - MV Refresh</field>
|
||||
<field name="model_id" ref="model_fusion_reports_cron"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_mv_refresh()</field>
|
||||
<field name="interval_number">15</field>
|
||||
<field name="interval_type">minutes</field>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
32
fusion_accounting_reports/data/report_balance_sheet.xml
Normal file
32
fusion_accounting_reports/data/report_balance_sheet.xml
Normal file
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="report_balance_sheet" model="fusion.report">
|
||||
<field name="name">Balance Sheet</field>
|
||||
<field name="code">balance_sheet</field>
|
||||
<field name="report_type">balance_sheet</field>
|
||||
<field name="sequence">20</field>
|
||||
<field name="default_comparison_mode">previous_year</field>
|
||||
<field name="description">Statement of financial position as of a given date.</field>
|
||||
<field name="line_specs" eval="[
|
||||
{'label': 'ASSETS', 'level': 0},
|
||||
{'label': 'Current Assets', 'account_type_prefix': 'asset_current', 'sign': 1, 'level': 1},
|
||||
{'label': 'Receivables', 'account_type_prefix': 'asset_receivable', 'sign': 1, 'level': 1},
|
||||
{'label': 'Cash & Bank', 'account_type_prefix': 'asset_cash', 'sign': 1, 'level': 1},
|
||||
{'label': 'Prepayments', 'account_type_prefix': 'asset_prepayments', 'sign': 1, 'level': 1},
|
||||
{'label': 'Non-Current Assets', 'account_type_prefix': 'asset_non_current', 'sign': 1, 'level': 1},
|
||||
{'label': 'Fixed Assets', 'account_type_prefix': 'asset_fixed', 'sign': 1, 'level': 1},
|
||||
{'label': 'TOTAL ASSETS', 'compute': 'subtotal', 'above': 6, 'sign': 1, 'level': 0},
|
||||
{'label': 'LIABILITIES', 'level': 0},
|
||||
{'label': 'Payables', 'account_type_prefix': 'liability_payable', 'sign': -1, 'level': 1},
|
||||
{'label': 'Credit Cards', 'account_type_prefix': 'liability_credit_card', 'sign': -1, 'level': 1},
|
||||
{'label': 'Current Liabilities', 'account_type_prefix': 'liability_current', 'sign': -1, 'level': 1},
|
||||
{'label': 'Non-Current Liabilities', 'account_type_prefix': 'liability_non_current', 'sign': -1, 'level': 1},
|
||||
{'label': 'TOTAL LIABILITIES', 'compute': 'subtotal', 'above': 4, 'sign': 1, 'level': 0},
|
||||
{'label': 'EQUITY', 'level': 0},
|
||||
{'label': 'Equity', 'account_type_prefix': 'equity', 'sign': -1, 'level': 1},
|
||||
{'label': 'TOTAL EQUITY', 'compute': 'subtotal', 'above': 1, 'sign': 1, 'level': 0},
|
||||
{'label': 'TOTAL LIABILITIES + EQUITY', 'compute': 'subtotal', 'above': 2, 'sign': 1, 'level': 0},
|
||||
]"/>
|
||||
<field name="company_id" eval="False"/>
|
||||
</record>
|
||||
</odoo>
|
||||
19
fusion_accounting_reports/data/report_general_ledger.xml
Normal file
19
fusion_accounting_reports/data/report_general_ledger.xml
Normal file
@@ -0,0 +1,19 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="report_general_ledger" model="fusion.report">
|
||||
<field name="name">General Ledger</field>
|
||||
<field name="code">general_ledger</field>
|
||||
<field name="report_type">general_ledger</field>
|
||||
<field name="sequence">40</field>
|
||||
<field name="default_comparison_mode">none</field>
|
||||
<field name="description">Per-account journal item listing for the period.</field>
|
||||
<field name="line_specs" eval="[
|
||||
{'label': 'All Accounts', 'account_type_prefix': 'asset', 'sign': 1, 'level': 0},
|
||||
{'label': 'All Accounts (liability)', 'account_type_prefix': 'liability', 'sign': 1, 'level': 0},
|
||||
{'label': 'All Accounts (equity)', 'account_type_prefix': 'equity', 'sign': 1, 'level': 0},
|
||||
{'label': 'All Accounts (income)', 'account_type_prefix': 'income', 'sign': 1, 'level': 0},
|
||||
{'label': 'All Accounts (expense)', 'account_type_prefix': 'expense', 'sign': 1, 'level': 0},
|
||||
]"/>
|
||||
<field name="company_id" eval="False"/>
|
||||
</record>
|
||||
</odoo>
|
||||
17
fusion_accounting_reports/data/report_pnl.xml
Normal file
17
fusion_accounting_reports/data/report_pnl.xml
Normal file
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="report_pnl" model="fusion.report">
|
||||
<field name="name">Profit and Loss</field>
|
||||
<field name="code">pnl</field>
|
||||
<field name="report_type">pnl</field>
|
||||
<field name="sequence">10</field>
|
||||
<field name="default_comparison_mode">previous_year</field>
|
||||
<field name="description">Income Statement summarizing revenue, expenses, and net income for a period.</field>
|
||||
<field name="line_specs" eval="[
|
||||
{'label': 'Revenue', 'account_type_prefix': 'income', 'sign': -1, 'level': 0},
|
||||
{'label': 'Operating Expenses', 'account_type_prefix': 'expense', 'sign': -1, 'level': 0},
|
||||
{'label': 'Net Income', 'compute': 'subtotal', 'above': 2, 'sign': 1, 'level': 0},
|
||||
]"/>
|
||||
<field name="company_id" eval="False"/>
|
||||
</record>
|
||||
</odoo>
|
||||
20
fusion_accounting_reports/data/report_trial_balance.xml
Normal file
20
fusion_accounting_reports/data/report_trial_balance.xml
Normal file
@@ -0,0 +1,20 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="report_trial_balance" model="fusion.report">
|
||||
<field name="name">Trial Balance</field>
|
||||
<field name="code">trial_balance</field>
|
||||
<field name="report_type">trial_balance</field>
|
||||
<field name="sequence">30</field>
|
||||
<field name="default_comparison_mode">none</field>
|
||||
<field name="description">Per-account balances for verifying that debits equal credits.</field>
|
||||
<field name="line_specs" eval="[
|
||||
{'label': 'Assets', 'account_type_prefix': 'asset', 'sign': 1, 'level': 0},
|
||||
{'label': 'Liabilities', 'account_type_prefix': 'liability', 'sign': -1, 'level': 0},
|
||||
{'label': 'Equity', 'account_type_prefix': 'equity', 'sign': -1, 'level': 0},
|
||||
{'label': 'Income', 'account_type_prefix': 'income', 'sign': -1, 'level': 0},
|
||||
{'label': 'Expenses', 'account_type_prefix': 'expense', 'sign': 1, 'level': 0},
|
||||
{'label': 'Total (should be 0)', 'compute': 'subtotal', 'above': 5, 'sign': 1, 'level': 0},
|
||||
]"/>
|
||||
<field name="company_id" eval="False"/>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Materialized view: per-account aggregated balances by year-month.
|
||||
-- Used by GL drill-down + trial balance for large DBs.
|
||||
-- Refresh strategy: cron every 15 minutes (Task 25); CONCURRENTLY-capable
|
||||
-- thanks to the unique index.
|
||||
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS fusion_account_balance_mv AS
|
||||
SELECT
|
||||
ROW_NUMBER() OVER (
|
||||
ORDER BY account_id, company_id, DATE_TRUNC('month', date)
|
||||
)::INTEGER AS id,
|
||||
account_id,
|
||||
company_id,
|
||||
DATE_TRUNC('month', date)::date AS period_month,
|
||||
SUM(debit) AS debit,
|
||||
SUM(credit) AS credit,
|
||||
SUM(balance) AS balance,
|
||||
COUNT(*) AS line_count
|
||||
FROM account_move_line
|
||||
WHERE parent_state = 'posted'
|
||||
GROUP BY account_id, company_id, DATE_TRUNC('month', date);
|
||||
|
||||
-- The (account_id, company_id, period_month) tuple is the natural key.
|
||||
-- We mark it UNIQUE so REFRESH MATERIALIZED VIEW CONCURRENTLY is allowed.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS fusion_account_balance_mv_pkey
|
||||
ON fusion_account_balance_mv (account_id, company_id, period_month);
|
||||
-- A separate index on the synthetic id is required by Odoo's ORM, which
|
||||
-- expects every model row to be addressable by `id`.
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS fusion_account_balance_mv_id_idx
|
||||
ON fusion_account_balance_mv (id);
|
||||
CREATE INDEX IF NOT EXISTS fusion_account_balance_mv_company_month
|
||||
ON fusion_account_balance_mv (company_id, period_month);
|
||||
7
fusion_accounting_reports/models/__init__.py
Normal file
7
fusion_accounting_reports/models/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from . import fusion_report
|
||||
from . import fusion_report_engine
|
||||
from . import fusion_report_commentary
|
||||
from . import fusion_report_anomaly
|
||||
from . import fusion_account_balance_mv
|
||||
from . import fusion_reports_cron
|
||||
from . import fusion_migration_wizard
|
||||
@@ -0,0 +1,80 @@
|
||||
"""Materialized view of per-account-per-month balances.
|
||||
|
||||
Created lazily by init() (called by Odoo on install/upgrade). Refresh
|
||||
via the model's _refresh() method or via cron (Task 25)."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionAccountBalanceMV(models.Model):
|
||||
_name = "fusion.account.balance.mv"
|
||||
_description = "MV of per-account per-month aggregated balances"
|
||||
_auto = False
|
||||
_table = "fusion_account_balance_mv"
|
||||
_order = "period_month desc, account_id"
|
||||
|
||||
account_id = fields.Many2one('account.account', readonly=True)
|
||||
company_id = fields.Many2one('res.company', readonly=True)
|
||||
period_month = fields.Date(readonly=True)
|
||||
debit = fields.Float(readonly=True)
|
||||
credit = fields.Float(readonly=True)
|
||||
balance = fields.Float(readonly=True)
|
||||
line_count = fields.Integer(readonly=True)
|
||||
|
||||
def init(self):
|
||||
# If the MV exists but is missing the synthetic `id` column (e.g. from
|
||||
# an earlier dev install), drop it so the new schema applies cleanly.
|
||||
self.env.cr.execute(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM pg_matviews mv
|
||||
JOIN pg_attribute a
|
||||
ON a.attrelid = (mv.schemaname || '.' || mv.matviewname)::regclass
|
||||
AND a.attname = 'id'
|
||||
WHERE mv.matviewname = 'fusion_account_balance_mv'
|
||||
"""
|
||||
)
|
||||
if not self.env.cr.fetchone():
|
||||
self.env.cr.execute(
|
||||
"DROP MATERIALIZED VIEW IF EXISTS fusion_account_balance_mv"
|
||||
)
|
||||
sql_path = os.path.join(
|
||||
os.path.dirname(__file__), '..', 'data', 'sql',
|
||||
'create_mv_account_balance.sql',
|
||||
)
|
||||
with open(sql_path, 'r') as f:
|
||||
self.env.cr.execute(f.read())
|
||||
_logger.info(
|
||||
"fusion_account_balance_mv: created/verified MV + indexes")
|
||||
|
||||
@api.model
|
||||
def _refresh(self, *, concurrently=True):
|
||||
"""Refresh the MV. Falls back to non-concurrent if CONCURRENTLY fails.
|
||||
|
||||
REFRESH MATERIALIZED VIEW CONCURRENTLY requires the MV to be already
|
||||
populated and an autocommit-capable cursor; the cron path in Task 25
|
||||
opens a dedicated cursor for that. This helper keeps callers safe by
|
||||
retrying without CONCURRENTLY on failure."""
|
||||
keyword = "CONCURRENTLY" if concurrently else ""
|
||||
try:
|
||||
self.env.cr.execute(
|
||||
f"REFRESH MATERIALIZED VIEW {keyword} fusion_account_balance_mv"
|
||||
)
|
||||
_logger.debug(
|
||||
"fusion_account_balance_mv refreshed (%s)",
|
||||
'concurrent' if concurrently else 'blocking',
|
||||
)
|
||||
except Exception as e:
|
||||
if concurrently:
|
||||
_logger.warning(
|
||||
"Concurrent MV refresh failed (%s); falling back", e)
|
||||
self.env.cr.execute(
|
||||
"REFRESH MATERIALIZED VIEW fusion_account_balance_mv"
|
||||
)
|
||||
else:
|
||||
raise
|
||||
35
fusion_accounting_reports/models/fusion_migration_wizard.py
Normal file
35
fusion_accounting_reports/models/fusion_migration_wizard.py
Normal file
@@ -0,0 +1,35 @@
|
||||
"""Reports-specific migration step.
|
||||
|
||||
Ensures the 4 CORE report definitions are present after migration."""
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionMigrationWizard(models.TransientModel):
|
||||
_inherit = "fusion.migration.wizard"
|
||||
|
||||
def _reports_bootstrap_step(self):
|
||||
"""Verify all 4 CORE report definitions exist."""
|
||||
Report = self.env['fusion.report'].sudo()
|
||||
expected = ['pnl', 'balance_sheet', 'trial_balance', 'general_ledger']
|
||||
present = Report.search([('report_type', 'in', expected)]).mapped('report_type')
|
||||
missing = set(expected) - set(present)
|
||||
return {
|
||||
'step': 'reports_bootstrap',
|
||||
'expected_reports': expected,
|
||||
'present_reports': list(present),
|
||||
'missing_reports': list(missing),
|
||||
}
|
||||
|
||||
def action_run_migration(self):
|
||||
"""Override to add reports-bootstrap step at the end of the chain."""
|
||||
result = super().action_run_migration() if hasattr(super(), 'action_run_migration') else None
|
||||
try:
|
||||
self._reports_bootstrap_step()
|
||||
except Exception as e:
|
||||
_logger.warning("reports_bootstrap_step failed: %s", e)
|
||||
return result
|
||||
63
fusion_accounting_reports/models/fusion_report.py
Normal file
63
fusion_accounting_reports/models/fusion_report.py
Normal file
@@ -0,0 +1,63 @@
|
||||
"""Persistent definition of a Fusion financial report.
|
||||
|
||||
Each report (P&L, balance sheet, trial balance, GL) has ONE row in
|
||||
fusion.report describing its metadata + line specs. The line specs
|
||||
are stored as a JSON-typed field for flexibility (each line spec
|
||||
includes account_type filter, sub-totaling rules, sign convention)."""
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
|
||||
|
||||
REPORT_TYPES = [
|
||||
('pnl', 'Income Statement (P&L)'),
|
||||
('balance_sheet', 'Balance Sheet'),
|
||||
('trial_balance', 'Trial Balance'),
|
||||
('general_ledger', 'General Ledger'),
|
||||
]
|
||||
|
||||
|
||||
class FusionReport(models.Model):
|
||||
_name = "fusion.report"
|
||||
_description = "Fusion Financial Report Definition"
|
||||
_order = "sequence, id"
|
||||
|
||||
name = fields.Char(required=True, translate=True)
|
||||
code = fields.Char(
|
||||
required=True,
|
||||
help="Unique technical code (e.g. 'pnl', 'balance_sheet').",
|
||||
)
|
||||
report_type = fields.Selection(REPORT_TYPES, required=True)
|
||||
sequence = fields.Integer(default=10)
|
||||
description = fields.Text()
|
||||
active = fields.Boolean(default=True)
|
||||
|
||||
# Layout config - stored as JSON for flexibility per report type.
|
||||
# Example for P&L:
|
||||
# [
|
||||
# {"label": "Revenue", "account_type_prefix": "income_", "sign": 1},
|
||||
# {"label": "Cost of Goods Sold", "account_type_prefix": "expense_direct_", "sign": -1},
|
||||
# {"label": "Gross Profit", "compute": "subtotal", "above": 2},
|
||||
# ...
|
||||
# ]
|
||||
line_specs = fields.Json(string="Line Specs")
|
||||
|
||||
show_zero_balances = fields.Boolean(default=False)
|
||||
show_unposted = fields.Boolean(default=False)
|
||||
default_comparison_mode = fields.Selection(
|
||||
[
|
||||
('none', 'No comparison'),
|
||||
('previous_period', 'Previous Period'),
|
||||
('previous_year', 'Previous Year'),
|
||||
],
|
||||
default='none',
|
||||
)
|
||||
|
||||
company_id = fields.Many2one(
|
||||
'res.company',
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
|
||||
_unique_company_code = models.Constraint(
|
||||
'UNIQUE(company_id, code)',
|
||||
'Report code must be unique per company.',
|
||||
)
|
||||
56
fusion_accounting_reports/models/fusion_report_anomaly.py
Normal file
56
fusion_accounting_reports/models/fusion_report_anomaly.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Persisted anomaly flags from the engine's variance detection.
|
||||
|
||||
Each row captures one flagged report row variance. Used by the OWL
|
||||
anomaly_strip + the audit trail."""
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
|
||||
|
||||
SEVERITY = [('low', 'Low'), ('medium', 'Medium'), ('high', 'High')]
|
||||
DIRECTION = [('increase', 'Increase'), ('decrease', 'Decrease')]
|
||||
|
||||
|
||||
class FusionReportAnomaly(models.Model):
|
||||
_name = "fusion.report.anomaly"
|
||||
_description = "Flagged Report Variance"
|
||||
_order = "detected_at desc, severity desc"
|
||||
|
||||
report_id = fields.Many2one('fusion.report', required=True, ondelete='cascade')
|
||||
company_id = fields.Many2one('res.company', required=True,
|
||||
default=lambda self: self.env.company)
|
||||
period_from = fields.Date(required=True)
|
||||
period_to = fields.Date(required=True)
|
||||
|
||||
row_id = fields.Char(required=True, help="Engine-generated row id (e.g. 'line_3').")
|
||||
label = fields.Char(required=True)
|
||||
current_amount = fields.Float()
|
||||
comparison_amount = fields.Float()
|
||||
variance_amount = fields.Float()
|
||||
variance_pct = fields.Float()
|
||||
severity = fields.Selection(SEVERITY, required=True)
|
||||
direction = fields.Selection(DIRECTION, required=True)
|
||||
|
||||
detected_at = fields.Datetime(default=fields.Datetime.now, required=True)
|
||||
state = fields.Selection([
|
||||
('new', 'New'),
|
||||
('acknowledged', 'Acknowledged'),
|
||||
('investigating', 'Investigating'),
|
||||
('resolved', 'Resolved'),
|
||||
('dismissed', 'Dismissed'),
|
||||
], default='new', required=True)
|
||||
notes = fields.Text()
|
||||
acknowledged_by = fields.Many2one('res.users')
|
||||
acknowledged_at = fields.Datetime()
|
||||
|
||||
def action_acknowledge(self):
|
||||
self.write({
|
||||
'state': 'acknowledged',
|
||||
'acknowledged_by': self.env.uid,
|
||||
'acknowledged_at': fields.Datetime.now(),
|
||||
})
|
||||
|
||||
def action_dismiss(self):
|
||||
self.write({'state': 'dismissed'})
|
||||
|
||||
def action_resolve(self):
|
||||
self.write({'state': 'resolved'})
|
||||
43
fusion_accounting_reports/models/fusion_report_commentary.py
Normal file
43
fusion_accounting_reports/models/fusion_report_commentary.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Cached AI-generated commentary for a report run.
|
||||
|
||||
One row per (report, period_from, period_to, comparison_mode, company).
|
||||
Refreshed on demand or via cron when the underlying data has changed."""
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
|
||||
|
||||
class FusionReportCommentary(models.Model):
|
||||
_name = "fusion.report.commentary"
|
||||
_description = "AI-Generated Report Commentary Cache"
|
||||
_order = "generated_at desc"
|
||||
|
||||
report_id = fields.Many2one('fusion.report', required=True, ondelete='cascade')
|
||||
company_id = fields.Many2one('res.company', required=True,
|
||||
default=lambda self: self.env.company)
|
||||
period_from = fields.Date(required=True)
|
||||
period_to = fields.Date(required=True)
|
||||
comparison_mode = fields.Selection([
|
||||
('none', 'None'),
|
||||
('previous_period', 'Previous Period'),
|
||||
('previous_year', 'Previous Year'),
|
||||
], default='none', required=True)
|
||||
|
||||
summary = fields.Text()
|
||||
highlights = fields.Json() # list of strings
|
||||
concerns = fields.Json() # list of strings
|
||||
next_actions = fields.Json() # list of strings
|
||||
|
||||
generated_at = fields.Datetime(default=fields.Datetime.now, required=True)
|
||||
generated_by = fields.Selection([
|
||||
('on_demand', 'On Demand'),
|
||||
('cron', 'Cron'),
|
||||
('templated', 'Templated Fallback'),
|
||||
], default='on_demand', required=True)
|
||||
|
||||
provider = fields.Char(help="LLM provider used (e.g. 'openai', 'claude', 'local'). "
|
||||
"Empty for templated fallback.")
|
||||
|
||||
_unique_period = models.Constraint(
|
||||
'UNIQUE(report_id, company_id, period_from, period_to, comparison_mode)',
|
||||
'Only one commentary cache row per report+period+mode.',
|
||||
)
|
||||
245
fusion_accounting_reports/models/fusion_report_engine.py
Normal file
245
fusion_accounting_reports/models/fusion_report_engine.py
Normal file
@@ -0,0 +1,245 @@
|
||||
"""The reports engine - orchestrator for all report computation.
|
||||
|
||||
5-method public API. All controllers, AI tools, wizards, exports must
|
||||
go through these methods; no direct ORM aggregation queries from
|
||||
anywhere else.
|
||||
|
||||
Internal pipeline (per report run):
|
||||
1. Validate (period valid, company allowed, report exists)
|
||||
2. Fetch account hierarchy (cached per (company, fiscal_year))
|
||||
3. Aggregate move lines per account (the SQL workhorse)
|
||||
4. Resolve line_specs into report rows
|
||||
5. (Optional) Compute comparison-period rows
|
||||
6. (Optional) Detect anomalies (deferred to later tasks)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import date
|
||||
|
||||
from odoo import _, api, models
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
from ..services.account_hierarchy import build_tree
|
||||
from ..services.date_periods import Period, comparison_period as _comp_period
|
||||
from ..services.drill_down_resolver import fetch_drill_down
|
||||
from ..services.line_resolver import resolve as _resolve_lines
|
||||
from ..services.totaling import TotalLine
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionReportEngine(models.AbstractModel):
|
||||
_name = "fusion.report.engine"
|
||||
_description = "Fusion Financial Reports Engine"
|
||||
|
||||
# ============================================================
|
||||
# PUBLIC API (5 methods)
|
||||
# ============================================================
|
||||
|
||||
@api.model
|
||||
def compute_pnl(
|
||||
self, period: Period, *, comparison: str = 'none',
|
||||
company_id: int | None = None,
|
||||
) -> dict:
|
||||
"""Income statement (P&L) for the given period."""
|
||||
report = self._get_report('pnl', company_id=company_id)
|
||||
return self._compute(
|
||||
report, period, comparison=comparison, company_id=company_id,
|
||||
)
|
||||
|
||||
@api.model
|
||||
def compute_balance_sheet(
|
||||
self, date_to: date, *, comparison: str = 'none',
|
||||
company_id: int | None = None,
|
||||
) -> dict:
|
||||
"""Balance sheet AS OF date_to. Period.date_from is set to a
|
||||
far-past date so balances are cumulative-since-inception."""
|
||||
report = self._get_report('balance_sheet', company_id=company_id)
|
||||
period = Period(
|
||||
date_from=date(1970, 1, 1),
|
||||
date_to=date_to,
|
||||
label=f"As of {date_to}",
|
||||
)
|
||||
return self._compute(
|
||||
report, period, comparison=comparison, company_id=company_id,
|
||||
)
|
||||
|
||||
@api.model
|
||||
def compute_trial_balance(
|
||||
self, period: Period, *, company_id: int | None = None,
|
||||
) -> dict:
|
||||
"""Trial balance for the given period - every account with
|
||||
non-zero balance."""
|
||||
report = self._get_report('trial_balance', company_id=company_id)
|
||||
return self._compute(
|
||||
report, period, comparison='none', company_id=company_id,
|
||||
)
|
||||
|
||||
@api.model
|
||||
def compute_gl(
|
||||
self, period: Period, *, account_ids: list | None = None,
|
||||
company_id: int | None = None,
|
||||
) -> dict:
|
||||
"""General ledger for the given period.
|
||||
|
||||
Returns per-account move-line listings rather than aggregated rows."""
|
||||
report = self._get_report('general_ledger', company_id=company_id)
|
||||
company_id = company_id or self.env.company.id
|
||||
result = self._compute(
|
||||
report, period, comparison='none', company_id=company_id,
|
||||
)
|
||||
gl_by_account = {}
|
||||
target_ids = account_ids or list(result.get('account_totals', {}).keys())
|
||||
for acct_id in target_ids:
|
||||
gl_by_account[acct_id] = fetch_drill_down(
|
||||
self.env,
|
||||
account_id=acct_id,
|
||||
date_from=period.date_from,
|
||||
date_to=period.date_to,
|
||||
company_id=company_id,
|
||||
limit=200,
|
||||
)
|
||||
result['gl_by_account'] = gl_by_account
|
||||
return result
|
||||
|
||||
@api.model
|
||||
def drill_down(
|
||||
self, *, account_id: int, period: Period,
|
||||
company_id: int | None = None,
|
||||
) -> list:
|
||||
"""Drill into a report line: list the journal items behind it."""
|
||||
company_id = company_id or self.env.company.id
|
||||
return fetch_drill_down(
|
||||
self.env,
|
||||
account_id=account_id,
|
||||
date_from=period.date_from,
|
||||
date_to=period.date_to,
|
||||
company_id=company_id,
|
||||
limit=500,
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# PRIVATE HELPERS
|
||||
# ============================================================
|
||||
|
||||
def _get_report(self, report_type: str, *, company_id: int | None = None):
|
||||
"""Look up the active fusion.report definition for a given
|
||||
type+company. If no per-company override, falls back to global
|
||||
(company_id=False)."""
|
||||
Report = self.env['fusion.report'].sudo()
|
||||
company_id = company_id or self.env.company.id
|
||||
report = Report.search(
|
||||
[
|
||||
('report_type', '=', report_type),
|
||||
('active', '=', True),
|
||||
'|',
|
||||
('company_id', '=', company_id),
|
||||
('company_id', '=', False),
|
||||
],
|
||||
order='company_id desc nulls last',
|
||||
limit=1,
|
||||
)
|
||||
if not report:
|
||||
raise ValidationError(
|
||||
_("No active fusion.report definition for type '%s'") % report_type
|
||||
)
|
||||
return report
|
||||
|
||||
def _fetch_accounts(self, company_id):
|
||||
"""Fetch all accounts for a company, return flat dict + tree."""
|
||||
Account = self.env['account.account'].sudo()
|
||||
records = Account.search([('company_ids', 'in', company_id)])
|
||||
# account.account doesn't carry a parent_id in V19 - we use
|
||||
# account_type prefixes instead, so parent_id is always None here.
|
||||
flat = [
|
||||
{
|
||||
'id': a.id,
|
||||
'code': a.code,
|
||||
'name': a.name,
|
||||
'account_type': a.account_type or '',
|
||||
'parent_id': None,
|
||||
}
|
||||
for a in records
|
||||
]
|
||||
accounts_by_id = {a['id']: a for a in flat}
|
||||
tree = build_tree(flat)
|
||||
return accounts_by_id, tree
|
||||
|
||||
def _aggregate_period(self, period: Period, company_id: int) -> dict:
|
||||
"""SQL aggregate per account_id for a period.
|
||||
|
||||
Raw SQL for performance; this is the perf-critical step."""
|
||||
self.env.cr.execute(
|
||||
"""
|
||||
SELECT account_id,
|
||||
COALESCE(SUM(debit), 0) AS d,
|
||||
COALESCE(SUM(credit), 0) AS c,
|
||||
COALESCE(SUM(balance), 0) AS b
|
||||
FROM account_move_line
|
||||
WHERE parent_state = 'posted'
|
||||
AND company_id = %s
|
||||
AND date >= %s
|
||||
AND date <= %s
|
||||
GROUP BY account_id
|
||||
""",
|
||||
(company_id, period.date_from, period.date_to),
|
||||
)
|
||||
out = {}
|
||||
for row in self.env.cr.fetchall():
|
||||
out[row[0]] = TotalLine(
|
||||
debit=float(row[1] or 0),
|
||||
credit=float(row[2] or 0),
|
||||
balance=float(row[3] or 0),
|
||||
)
|
||||
return out
|
||||
|
||||
def _compute(
|
||||
self, report, period: Period, *, comparison: str,
|
||||
company_id: int | None = None,
|
||||
) -> dict:
|
||||
"""Shared computation pipeline. Returns dict with rows, totals,
|
||||
metadata."""
|
||||
company_id = company_id or self.env.company.id
|
||||
|
||||
accounts_by_id, _tree = self._fetch_accounts(company_id)
|
||||
|
||||
account_totals = self._aggregate_period(period, company_id)
|
||||
|
||||
comp_totals = None
|
||||
comp_period = None
|
||||
if comparison and comparison != 'none':
|
||||
comp_period = _comp_period(period, comparison)
|
||||
if comp_period:
|
||||
comp_totals = self._aggregate_period(comp_period, company_id)
|
||||
|
||||
rows = _resolve_lines(
|
||||
report.line_specs or [],
|
||||
account_totals=account_totals,
|
||||
accounts_by_id=accounts_by_id,
|
||||
comparison_totals=comp_totals,
|
||||
)
|
||||
|
||||
return {
|
||||
'report_id': report.id,
|
||||
'report_name': report.name,
|
||||
'report_type': report.report_type,
|
||||
'period': {
|
||||
'date_from': str(period.date_from),
|
||||
'date_to': str(period.date_to),
|
||||
'label': period.label,
|
||||
},
|
||||
'comparison_period': (
|
||||
{
|
||||
'date_from': str(comp_period.date_from),
|
||||
'date_to': str(comp_period.date_to),
|
||||
'label': comp_period.label,
|
||||
}
|
||||
if comp_period
|
||||
else None
|
||||
),
|
||||
'company_id': company_id,
|
||||
'rows': rows,
|
||||
'account_totals': {
|
||||
aid: tl.balance for aid, tl in account_totals.items()
|
||||
},
|
||||
}
|
||||
117
fusion_accounting_reports/models/fusion_reports_cron.py
Normal file
117
fusion_accounting_reports/models/fusion_reports_cron.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""Cron handlers for fusion_accounting_reports.
|
||||
|
||||
Two scheduled jobs:
|
||||
- _cron_anomaly_scan: daily P&L variance scan -> persist anomalies
|
||||
- _cron_mv_refresh: every 15 min CONCURRENTLY refresh the MV"""
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
import odoo
|
||||
from odoo import api, fields, models
|
||||
|
||||
from ..services.anomaly_detection import detect
|
||||
from ..services.date_periods import month_bounds
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionReportsCron(models.AbstractModel):
|
||||
_name = "fusion.reports.cron"
|
||||
_description = "Fusion Reports Cron Handlers"
|
||||
|
||||
@api.model
|
||||
def _cron_anomaly_scan(self):
|
||||
"""Run last-month P&L vs prior-year-same-month and persist anomalies."""
|
||||
today = fields.Date.today()
|
||||
# Walk back into the previous full calendar month.
|
||||
last_month = today.replace(day=1) - timedelta(days=1)
|
||||
period = month_bounds(last_month)
|
||||
|
||||
Report = self.env['fusion.report'].sudo()
|
||||
Anomaly = self.env['fusion.report.anomaly'].sudo()
|
||||
engine = self.env['fusion.report.engine']
|
||||
|
||||
for company in self.env['res.company'].search([]):
|
||||
try:
|
||||
pnl_def = Report.search(
|
||||
[
|
||||
('report_type', '=', 'pnl'),
|
||||
'|', ('company_id', '=', company.id),
|
||||
('company_id', '=', False),
|
||||
],
|
||||
limit=1,
|
||||
)
|
||||
if not pnl_def:
|
||||
continue
|
||||
result = engine.compute_pnl(
|
||||
period,
|
||||
comparison='previous_year',
|
||||
company_id=company.id,
|
||||
)
|
||||
anomalies = detect(result)
|
||||
for a in anomalies:
|
||||
existing = Anomaly.search(
|
||||
[
|
||||
('report_id', '=', pnl_def.id),
|
||||
('company_id', '=', company.id),
|
||||
('period_from', '=', period.date_from),
|
||||
('period_to', '=', period.date_to),
|
||||
('row_id', '=', a['row_id']),
|
||||
],
|
||||
limit=1,
|
||||
)
|
||||
vals = {
|
||||
'report_id': pnl_def.id,
|
||||
'company_id': company.id,
|
||||
'period_from': period.date_from,
|
||||
'period_to': period.date_to,
|
||||
'row_id': a['row_id'],
|
||||
'label': a['label'],
|
||||
'current_amount': a['current_amount'],
|
||||
'comparison_amount': a['comparison_amount'],
|
||||
'variance_amount': a['variance_amount'],
|
||||
'variance_pct': a['variance_pct'],
|
||||
'severity': a['severity'],
|
||||
'direction': a['direction'],
|
||||
}
|
||||
if existing:
|
||||
existing.write(vals)
|
||||
else:
|
||||
Anomaly.create(vals)
|
||||
_logger.info(
|
||||
"Anomaly scan for company %s: %d flagged",
|
||||
company.id, len(anomalies),
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.exception(
|
||||
"Anomaly scan failed for company %s: %s", company.id, e,
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _cron_mv_refresh(self):
|
||||
"""REFRESH CONCURRENTLY via dedicated autocommit cursor.
|
||||
|
||||
REFRESH MATERIALIZED VIEW CONCURRENTLY cannot run inside a
|
||||
transaction block, so we open a separate connection with autocommit
|
||||
enabled. The blocking REFRESH is used as a fallback if the
|
||||
concurrent path fails (e.g. on a cold MV with no rows yet)."""
|
||||
try:
|
||||
db_name = self.env.cr.dbname
|
||||
db = odoo.sql_db.db_connect(db_name)
|
||||
with db.cursor() as cron_cr:
|
||||
cron_cr._cnx.set_session(autocommit=True)
|
||||
cron_cr.execute(
|
||||
"REFRESH MATERIALIZED VIEW CONCURRENTLY "
|
||||
"fusion_account_balance_mv"
|
||||
)
|
||||
_logger.debug("MV refresh CONCURRENTLY succeeded")
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
"CONCURRENTLY refresh failed (%s); blocking fallback", e)
|
||||
try:
|
||||
self.env['fusion.account.balance.mv']._refresh(
|
||||
concurrently=False)
|
||||
except Exception as e2:
|
||||
_logger.exception(
|
||||
"Blocking MV refresh also failed: %s", e2)
|
||||
1
fusion_accounting_reports/reports/__init__.py
Normal file
1
fusion_accounting_reports/reports/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import report_pdf
|
||||
58
fusion_accounting_reports/reports/report_pdf.py
Normal file
58
fusion_accounting_reports/reports/report_pdf.py
Normal file
@@ -0,0 +1,58 @@
|
||||
"""QWeb PDF report for fusion financial reports.
|
||||
|
||||
Wraps the engine's compute_* methods and feeds the result into a
|
||||
single multi-purpose template that handles all 4 report types."""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from odoo import api, models
|
||||
|
||||
from ..services.date_periods import Period
|
||||
|
||||
|
||||
class FusionReportPdf(models.AbstractModel):
|
||||
_name = "report.fusion_accounting_reports.report_pdf_template"
|
||||
_description = "Fusion Financial Report PDF"
|
||||
|
||||
@api.model
|
||||
def _get_report_values(self, docids, data=None):
|
||||
"""data is expected to be {report_type, date_from, date_to, comparison, company_id}."""
|
||||
data = data or {}
|
||||
report_type = data.get('report_type', 'pnl')
|
||||
company_id = data.get('company_id') or self.env.company.id
|
||||
date_from = data.get('date_from')
|
||||
date_to = data.get('date_to')
|
||||
comparison = data.get('comparison', 'none')
|
||||
|
||||
if isinstance(date_from, str):
|
||||
date_from = datetime.strptime(date_from, '%Y-%m-%d').date()
|
||||
if isinstance(date_to, str):
|
||||
date_to = datetime.strptime(date_to, '%Y-%m-%d').date()
|
||||
|
||||
engine = self.env['fusion.report.engine']
|
||||
if report_type == 'pnl':
|
||||
period = Period(date_from, date_to, f"{date_from} - {date_to}")
|
||||
result = engine.compute_pnl(period, comparison=comparison, company_id=company_id)
|
||||
elif report_type == 'balance_sheet':
|
||||
result = engine.compute_balance_sheet(date_to, comparison=comparison, company_id=company_id)
|
||||
elif report_type == 'trial_balance':
|
||||
period = Period(date_from, date_to, f"{date_from} - {date_to}")
|
||||
result = engine.compute_trial_balance(period, company_id=company_id)
|
||||
elif report_type == 'general_ledger':
|
||||
period = Period(date_from, date_to, f"{date_from} - {date_to}")
|
||||
result = engine.compute_gl(period, company_id=company_id)
|
||||
else:
|
||||
result = {'rows': [], 'report_name': 'Unknown', 'period': {}}
|
||||
|
||||
company = self.env['res.company'].browse(company_id)
|
||||
return {
|
||||
'doc_ids': docids,
|
||||
'doc_model': 'fusion.report',
|
||||
'docs': self.env['fusion.report'].browse(docids) if docids else
|
||||
self.env['fusion.report'].search([('report_type', '=', report_type)], limit=1),
|
||||
'data': data,
|
||||
'result': result,
|
||||
'company_id': company,
|
||||
'company': company,
|
||||
'res_company': company,
|
||||
}
|
||||
72
fusion_accounting_reports/reports/report_pdf_template.xml
Normal file
72
fusion_accounting_reports/reports/report_pdf_template.xml
Normal file
@@ -0,0 +1,72 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<template id="report_pdf_template">
|
||||
<t t-call="web.html_container">
|
||||
<t t-call="web.external_layout">
|
||||
<div class="page">
|
||||
<h2>
|
||||
<t t-esc="result.get('report_name', 'Financial Report')"/>
|
||||
</h2>
|
||||
<p>
|
||||
<strong>Period:</strong>
|
||||
<span t-esc="result.get('period', {}).get('label', '')"/>
|
||||
</p>
|
||||
<p t-if="result.get('comparison_period')">
|
||||
<strong>Compared to:</strong>
|
||||
<span t-esc="result.get('comparison_period', {}).get('label', '')"/>
|
||||
</p>
|
||||
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Line</th>
|
||||
<th class="text-end">Amount</th>
|
||||
<t t-if="result.get('comparison_period')">
|
||||
<th class="text-end">Comparison</th>
|
||||
<th class="text-end">Variance %</th>
|
||||
</t>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr t-foreach="result.get('rows', [])" t-as="row"
|
||||
t-attf-style="{{ 'font-weight: bold;' if row.get('is_subtotal') else '' }}">
|
||||
<td t-attf-style="padding-left: {{ (row.get('level', 0) or 0) * 16 + 8 }}px;">
|
||||
<span t-esc="row.get('label', '')"/>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<span t-esc="'{:,.2f}'.format(row.get('amount', 0))"/>
|
||||
</td>
|
||||
<t t-if="result.get('comparison_period')">
|
||||
<td class="text-end">
|
||||
<t t-if="row.get('amount_comparison') is not None">
|
||||
<span t-esc="'{:,.2f}'.format(row.get('amount_comparison'))"/>
|
||||
</t>
|
||||
</td>
|
||||
<td class="text-end">
|
||||
<t t-if="row.get('variance_pct') is not None">
|
||||
<span t-esc="'{:+.1f}%'.format(row.get('variance_pct'))"/>
|
||||
</t>
|
||||
</td>
|
||||
</t>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p class="text-muted" style="font-size: 0.75rem;">
|
||||
Generated by Fusion Accounting Reports
|
||||
</p>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
|
||||
<record id="action_report_fusion_financial" model="ir.actions.report">
|
||||
<field name="name">Fusion Financial Report (PDF)</field>
|
||||
<field name="model">fusion.report</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_accounting_reports.report_pdf_template</field>
|
||||
<field name="report_file">fusion_accounting_reports.report_pdf_template</field>
|
||||
<field name="binding_model_id" ref="model_fusion_report"/>
|
||||
<field name="binding_view_types">form,list</field>
|
||||
</record>
|
||||
</odoo>
|
||||
7
fusion_accounting_reports/security/ir.model.access.csv
Normal file
7
fusion_accounting_reports/security/ir.model.access.csv
Normal file
@@ -0,0 +1,7 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_fusion_report_user,fusion.report.user,model_fusion_report,base.group_user,1,0,0,0
|
||||
access_fusion_report_admin,fusion.report.admin,model_fusion_report,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1
|
||||
access_fusion_report_commentary,fusion.report.commentary,model_fusion_report_commentary,base.group_user,1,1,1,0
|
||||
access_fusion_report_anomaly,fusion.report.anomaly,model_fusion_report_anomaly,base.group_user,1,1,1,0
|
||||
access_fusion_xlsx_export_wizard_user,fusion.xlsx.export.wizard.user,model_fusion_xlsx_export_wizard,base.group_user,1,1,1,0
|
||||
access_fusion_period_picker_wizard_user,fusion.period.picker.wizard.user,model_fusion_period_picker_wizard,base.group_user,1,1,1,0
|
||||
|
9
fusion_accounting_reports/services/__init__.py
Normal file
9
fusion_accounting_reports/services/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from . import date_periods
|
||||
from . import account_hierarchy
|
||||
from . import totaling
|
||||
from . import currency_conversion
|
||||
from . import line_resolver
|
||||
from . import drill_down_resolver
|
||||
from . import anomaly_detection
|
||||
from . import commentary_prompt
|
||||
from . import commentary_generator
|
||||
62
fusion_accounting_reports/services/account_hierarchy.py
Normal file
62
fusion_accounting_reports/services/account_hierarchy.py
Normal file
@@ -0,0 +1,62 @@
|
||||
"""Account hierarchy walker.
|
||||
|
||||
Given a flat list of accounts with parent_id pointers, build a tree and
|
||||
provide a recursive walker that yields (account, depth, ancestors) tuples.
|
||||
Used by report line resolvers to render group sub-totals."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Iterator
|
||||
|
||||
|
||||
@dataclass
|
||||
class AccountNode:
|
||||
id: int
|
||||
code: str
|
||||
name: str
|
||||
account_type: str
|
||||
parent_id: int | None
|
||||
children: list['AccountNode'] = field(default_factory=list)
|
||||
|
||||
|
||||
def build_tree(accounts: list[dict]) -> list[AccountNode]:
|
||||
"""Build a forest from a flat list of account dicts.
|
||||
|
||||
Each dict must have keys: id, code, name, account_type, parent_id (nullable)."""
|
||||
nodes: dict[int, AccountNode] = {}
|
||||
for acc in accounts:
|
||||
nodes[acc['id']] = AccountNode(
|
||||
id=acc['id'], code=acc['code'], name=acc['name'],
|
||||
account_type=acc['account_type'],
|
||||
parent_id=acc.get('parent_id'),
|
||||
)
|
||||
roots: list[AccountNode] = []
|
||||
for node in nodes.values():
|
||||
if node.parent_id and node.parent_id in nodes:
|
||||
nodes[node.parent_id].children.append(node)
|
||||
else:
|
||||
roots.append(node)
|
||||
for node in nodes.values():
|
||||
node.children.sort(key=lambda n: n.code)
|
||||
roots.sort(key=lambda n: n.code)
|
||||
return roots
|
||||
|
||||
|
||||
def walk(roots: list[AccountNode], *, max_depth: int = 10) -> Iterator[tuple[AccountNode, int, list[AccountNode]]]:
|
||||
"""Depth-first walk yielding (node, depth, ancestors)."""
|
||||
def _walk(node: AccountNode, depth: int, ancestors: list[AccountNode]):
|
||||
yield (node, depth, ancestors)
|
||||
if depth < max_depth:
|
||||
for child in node.children:
|
||||
yield from _walk(child, depth + 1, ancestors + [node])
|
||||
for root in roots:
|
||||
yield from _walk(root, 0, [])
|
||||
|
||||
|
||||
def filter_by_account_type(roots: list[AccountNode], type_prefix: str) -> list[AccountNode]:
|
||||
"""Return all nodes whose account_type starts with type_prefix
|
||||
(e.g. 'asset_' returns asset_receivable, asset_cash, etc.)."""
|
||||
matches: list[AccountNode] = []
|
||||
for node, _depth, _ancestors in walk(roots):
|
||||
if node.account_type.startswith(type_prefix):
|
||||
matches.append(node)
|
||||
return matches
|
||||
81
fusion_accounting_reports/services/anomaly_detection.py
Normal file
81
fusion_accounting_reports/services/anomaly_detection.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""Anomaly detection for financial reports.
|
||||
|
||||
Compares each row's current-period amount to its comparison-period
|
||||
amount and flags variances exceeding a threshold. Uses both:
|
||||
- Absolute threshold ($X minimum movement)
|
||||
- Percentage threshold (Y% min variance)
|
||||
|
||||
Pure-Python: callers pass the engine's compute_*() result; we return
|
||||
a list of anomaly dicts."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class Anomaly:
|
||||
row_id: str
|
||||
label: str
|
||||
current_amount: float
|
||||
comparison_amount: float
|
||||
variance_amount: float
|
||||
variance_pct: float
|
||||
severity: str # 'low', 'medium', 'high'
|
||||
direction: str # 'increase', 'decrease'
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'row_id': self.row_id, 'label': self.label,
|
||||
'current_amount': self.current_amount,
|
||||
'comparison_amount': self.comparison_amount,
|
||||
'variance_amount': self.variance_amount,
|
||||
'variance_pct': self.variance_pct,
|
||||
'severity': self.severity, 'direction': self.direction,
|
||||
}
|
||||
|
||||
|
||||
# Defaults -- tunable per company via ir.config_parameter
|
||||
DEFAULT_MIN_ABSOLUTE_THRESHOLD = 100.0
|
||||
DEFAULT_MIN_PCT_THRESHOLD = 10.0 # 10%
|
||||
DEFAULT_HIGH_PCT_THRESHOLD = 50.0 # 50%+ flagged 'high'
|
||||
|
||||
|
||||
def detect(report_result: dict, *, min_absolute: float = None,
|
||||
min_pct: float = None, high_pct: float = None) -> list[dict]:
|
||||
"""Detect anomalies in a report_result dict (engine output).
|
||||
|
||||
Returns list of anomaly dicts ordered by severity desc, variance_amount desc.
|
||||
Returns empty list if no comparison period was computed."""
|
||||
if not report_result.get('comparison_period'):
|
||||
return []
|
||||
min_absolute = min_absolute if min_absolute is not None else DEFAULT_MIN_ABSOLUTE_THRESHOLD
|
||||
min_pct = min_pct if min_pct is not None else DEFAULT_MIN_PCT_THRESHOLD
|
||||
high_pct = high_pct if high_pct is not None else DEFAULT_HIGH_PCT_THRESHOLD
|
||||
|
||||
anomalies = []
|
||||
for row in report_result.get('rows', []):
|
||||
comparison = row.get('amount_comparison')
|
||||
current = row.get('amount', 0.0)
|
||||
if comparison is None:
|
||||
continue
|
||||
variance_amount = current - comparison
|
||||
variance_pct = abs(row.get('variance_pct') or 0.0)
|
||||
if abs(variance_amount) < min_absolute:
|
||||
continue
|
||||
if variance_pct < min_pct:
|
||||
continue
|
||||
severity = 'high' if variance_pct >= high_pct else 'medium' if variance_pct >= min_pct * 2 else 'low'
|
||||
direction = 'increase' if variance_amount > 0 else 'decrease'
|
||||
anomalies.append(Anomaly(
|
||||
row_id=row['id'],
|
||||
label=row.get('label', ''),
|
||||
current_amount=current,
|
||||
comparison_amount=comparison,
|
||||
variance_amount=variance_amount,
|
||||
variance_pct=variance_pct,
|
||||
severity=severity,
|
||||
direction=direction,
|
||||
).to_dict())
|
||||
|
||||
severity_order = {'high': 0, 'medium': 1, 'low': 2}
|
||||
anomalies.sort(key=lambda a: (severity_order[a['severity']], -abs(a['variance_amount'])))
|
||||
return anomalies
|
||||
103
fusion_accounting_reports/services/commentary_generator.py
Normal file
103
fusion_accounting_reports/services/commentary_generator.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""AI-generated narrative commentary for financial reports.
|
||||
|
||||
Takes a report_result dict + optional anomalies list, builds an LLM
|
||||
prompt, parses the structured output. Output contract:
|
||||
{
|
||||
'summary': str, # 2-3 sentence executive summary
|
||||
'highlights': [str, ...], # 3-5 bullet observations
|
||||
'concerns': [str, ...], # things that warrant investigation
|
||||
'next_actions': [str, ...] # suggested follow-ups
|
||||
}
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def generate_commentary(env, *, report_result: dict, anomalies: list = None,
|
||||
provider=None) -> dict:
|
||||
"""Generate narrative commentary via LLM. Returns dict per the contract.
|
||||
|
||||
If no provider configured, returns a templated fallback (no LLM)."""
|
||||
if provider is None:
|
||||
provider = _get_provider(env)
|
||||
if provider is None:
|
||||
return _templated_fallback(report_result, anomalies)
|
||||
|
||||
try:
|
||||
from odoo.addons.fusion_accounting_reports.services.commentary_prompt import build_prompt
|
||||
except ImportError:
|
||||
_logger.debug("commentary_prompt module not yet available; using fallback")
|
||||
return _templated_fallback(report_result, anomalies)
|
||||
|
||||
system, user = build_prompt(report_result, anomalies or [])
|
||||
try:
|
||||
response = provider.complete(
|
||||
system=system,
|
||||
messages=[{'role': 'user', 'content': user}],
|
||||
max_tokens=1200,
|
||||
temperature=0.2,
|
||||
)
|
||||
content = response.get('content') if isinstance(response, dict) else response
|
||||
parsed = json.loads(content)
|
||||
# Validate shape
|
||||
for key in ('summary', 'highlights', 'concerns', 'next_actions'):
|
||||
parsed.setdefault(key, [] if key != 'summary' else '')
|
||||
return parsed
|
||||
except Exception as e:
|
||||
_logger.warning("AI commentary generation failed: %s", e)
|
||||
return _templated_fallback(report_result, anomalies)
|
||||
|
||||
|
||||
def _templated_fallback(report_result: dict, anomalies: list = None) -> dict:
|
||||
"""No-LLM fallback that produces a basic narrative from the report data."""
|
||||
anomalies = anomalies or []
|
||||
rows = report_result.get('rows', [])
|
||||
period = report_result.get('period', {})
|
||||
period_label = period.get('label', 'this period')
|
||||
|
||||
# Find subtotal rows for the summary
|
||||
subtotals = [r for r in rows if r.get('is_subtotal')]
|
||||
summary_parts = [f"{report_result.get('report_name', 'Report')} for {period_label}."]
|
||||
if subtotals:
|
||||
last = subtotals[-1]
|
||||
summary_parts.append(f"{last['label']}: ${last['amount']:,.2f}.")
|
||||
|
||||
highlights = []
|
||||
for row in subtotals[:3]:
|
||||
highlights.append(f"{row['label']}: ${row['amount']:,.2f}")
|
||||
|
||||
concerns = []
|
||||
for a in anomalies[:3]:
|
||||
concerns.append(
|
||||
f"{a['label']} {a['direction']}d {a['variance_pct']:.1f}% "
|
||||
f"(${a['variance_amount']:+,.2f})")
|
||||
|
||||
return {
|
||||
'summary': ' '.join(summary_parts),
|
||||
'highlights': highlights,
|
||||
'concerns': concerns,
|
||||
'next_actions': ['Review the flagged anomalies above.'] if concerns else [],
|
||||
}
|
||||
|
||||
|
||||
def _get_provider(env):
|
||||
"""Look up provider for 'reports_commentary' feature; return None if not configured."""
|
||||
param = env['ir.config_parameter'].sudo()
|
||||
provider_name = param.get_param('fusion_accounting.provider.reports_commentary')
|
||||
if not provider_name:
|
||||
provider_name = param.get_param('fusion_accounting.provider.default')
|
||||
if not provider_name:
|
||||
return None
|
||||
try:
|
||||
from odoo.addons.fusion_accounting_ai.services.adapters.openai_adapter import OpenAIAdapter
|
||||
from odoo.addons.fusion_accounting_ai.services.adapters.claude import ClaudeAdapter
|
||||
except ImportError:
|
||||
return None
|
||||
if provider_name.startswith('openai'):
|
||||
return OpenAIAdapter(env)
|
||||
elif provider_name.startswith('claude'):
|
||||
return ClaudeAdapter(env)
|
||||
return None
|
||||
67
fusion_accounting_reports/services/commentary_prompt.py
Normal file
67
fusion_accounting_reports/services/commentary_prompt.py
Normal file
@@ -0,0 +1,67 @@
|
||||
"""LLM prompt for AI report commentary.
|
||||
|
||||
Provider-agnostic system + user prompt builder. Output contract:
|
||||
JSON with keys summary, highlights, concerns, next_actions."""
|
||||
|
||||
|
||||
SYSTEM_PROMPT = """You are an experienced CFO providing executive-level commentary
|
||||
on a financial report. Your output MUST be valid JSON of this exact shape:
|
||||
|
||||
{
|
||||
"summary": "<2-3 sentence executive summary of the report period>",
|
||||
"highlights": ["<observation 1>", "<observation 2>", ...],
|
||||
"concerns": ["<thing to investigate 1>", ...],
|
||||
"next_actions": ["<suggested action 1>", ...]
|
||||
}
|
||||
|
||||
Rules:
|
||||
- Use the data provided. Do not invent numbers.
|
||||
- Tone: professional, concise, factual.
|
||||
- Currency formatting: always include the $ symbol and 2 decimal places.
|
||||
- For anomalies: explicitly mention the variance percentage AND the dollar amount.
|
||||
- Do NOT include markdown code fences. Do NOT include any prose outside the JSON.
|
||||
"""
|
||||
|
||||
|
||||
def build_prompt(report_result: dict, anomalies: list) -> tuple[str, str]:
|
||||
"""Build (system_prompt, user_prompt) tuple."""
|
||||
parts = []
|
||||
|
||||
# Report context
|
||||
parts.append(f"REPORT: {report_result.get('report_name', 'Untitled')}")
|
||||
period = report_result.get('period', {})
|
||||
parts.append(f"PERIOD: {period.get('label', '')} "
|
||||
f"({period.get('date_from', '')} to {period.get('date_to', '')})")
|
||||
comp_period = report_result.get('comparison_period')
|
||||
if comp_period:
|
||||
parts.append(f"COMPARED TO: {comp_period.get('label', '')} "
|
||||
f"({comp_period.get('date_from', '')} to {comp_period.get('date_to', '')})")
|
||||
parts.append("")
|
||||
|
||||
# Rows (the actual numbers)
|
||||
parts.append("REPORT LINES:")
|
||||
for row in report_result.get('rows', []):
|
||||
line = f" - {row.get('label', '?')}: ${row.get('amount', 0):,.2f}"
|
||||
if row.get('amount_comparison') is not None:
|
||||
line += f" (comparison: ${row['amount_comparison']:,.2f}"
|
||||
if row.get('variance_pct') is not None:
|
||||
line += f", {row['variance_pct']:+.1f}%"
|
||||
line += ")"
|
||||
if row.get('is_subtotal'):
|
||||
line += " [SUBTOTAL]"
|
||||
parts.append(line)
|
||||
parts.append("")
|
||||
|
||||
# Anomalies
|
||||
if anomalies:
|
||||
parts.append("ANOMALIES (variances exceeding threshold):")
|
||||
for a in anomalies[:10]:
|
||||
parts.append(
|
||||
f" - {a['label']}: {a['direction']}d {a['variance_pct']:.1f}% "
|
||||
f"(${a['variance_amount']:+,.2f}, severity: {a['severity']})"
|
||||
)
|
||||
parts.append("")
|
||||
|
||||
parts.append("Generate the JSON commentary per the system prompt.")
|
||||
|
||||
return (SYSTEM_PROMPT, "\n".join(parts))
|
||||
66
fusion_accounting_reports/services/currency_conversion.py
Normal file
66
fusion_accounting_reports/services/currency_conversion.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""Multi-currency conversion for financial reports.
|
||||
|
||||
Converts move-line amounts to the report's display currency at the
|
||||
report end-date. Pure-Python - caller provides exchange rates as a
|
||||
dict {(source_code, target_code, date): rate}."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
|
||||
|
||||
@dataclass
|
||||
class ConversionRate:
|
||||
source: str
|
||||
target: str
|
||||
rate: float
|
||||
rate_date: date
|
||||
|
||||
|
||||
def convert_amount(amount: float, *, source_currency: str, target_currency: str,
|
||||
rate_date: date, rates: dict) -> float:
|
||||
"""Convert `amount` from source to target at the given date.
|
||||
|
||||
`rates` is a dict keyed by (source, target, date) -> rate.
|
||||
If source == target, returns amount unchanged."""
|
||||
if source_currency == target_currency:
|
||||
return amount
|
||||
key = (source_currency, target_currency, rate_date)
|
||||
if key in rates:
|
||||
return amount * rates[key]
|
||||
inv_key = (target_currency, source_currency, rate_date)
|
||||
if inv_key in rates:
|
||||
inv = rates[inv_key]
|
||||
if inv != 0:
|
||||
return amount / inv
|
||||
candidates = [
|
||||
(d, r) for (s, t, d), r in rates.items()
|
||||
if s == source_currency and t == target_currency and d <= rate_date
|
||||
]
|
||||
if candidates:
|
||||
candidates.sort(key=lambda x: x[0], reverse=True)
|
||||
return amount * candidates[0][1]
|
||||
raise ValueError(
|
||||
f"No exchange rate available for {source_currency}->{target_currency} on or before {rate_date}"
|
||||
)
|
||||
|
||||
|
||||
def fetch_rates(env, *, target_currency_id: int, as_of: date,
|
||||
source_currency_ids: list[int] | None = None) -> dict:
|
||||
"""Fetch all relevant rates from res.currency.rate as of a given date.
|
||||
|
||||
Returns the dict-of-rates structure consumed by convert_amount.
|
||||
Pulls only rates where source != target and date <= as_of."""
|
||||
Rate = env['res.currency.rate'].sudo()
|
||||
target = env['res.currency'].browse(target_currency_id)
|
||||
domain = [
|
||||
('name', '<=', as_of),
|
||||
('currency_id', '!=', target.id),
|
||||
]
|
||||
if source_currency_ids:
|
||||
domain.append(('currency_id', 'in', source_currency_ids))
|
||||
rates_recs = Rate.search(domain)
|
||||
|
||||
out = {}
|
||||
for r in rates_recs:
|
||||
out[(r.currency_id.name, target.name, r.name)] = (1.0 / r.rate) if r.rate else 0.0
|
||||
return out
|
||||
103
fusion_accounting_reports/services/date_periods.py
Normal file
103
fusion_accounting_reports/services/date_periods.py
Normal file
@@ -0,0 +1,103 @@
|
||||
"""Date period math for financial reports.
|
||||
|
||||
Pure-Python helpers that compute:
|
||||
- Fiscal year start/end given any reference date + company fiscal year settings
|
||||
- Comparison periods (prior year same period, prior period, etc.)
|
||||
- Period boundaries for monthly / quarterly / yearly reporting
|
||||
|
||||
NO Odoo imports - all callers pass in primitive types so the same module
|
||||
is unit-testable without an Odoo registry."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, timedelta
|
||||
from typing import Literal
|
||||
|
||||
|
||||
PeriodGranularity = Literal['month', 'quarter', 'year', 'custom']
|
||||
ComparisonMode = Literal['none', 'previous_period', 'previous_year']
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Period:
|
||||
date_from: date
|
||||
date_to: date
|
||||
label: str
|
||||
|
||||
def __post_init__(self):
|
||||
if self.date_from > self.date_to:
|
||||
raise ValueError(f"date_from ({self.date_from}) > date_to ({self.date_to})")
|
||||
|
||||
@property
|
||||
def days(self) -> int:
|
||||
return (self.date_to - self.date_from).days + 1
|
||||
|
||||
|
||||
def fiscal_year_bounds(reference_date: date, *, fy_start_month: int = 1,
|
||||
fy_start_day: int = 1) -> Period:
|
||||
"""Return the fiscal year period containing `reference_date`.
|
||||
|
||||
Default: calendar year (Jan 1 - Dec 31). Pass fy_start_month=4, fy_start_day=1
|
||||
for an April-March fiscal year."""
|
||||
if reference_date.month < fy_start_month or (
|
||||
reference_date.month == fy_start_month and reference_date.day < fy_start_day
|
||||
):
|
||||
start_year = reference_date.year - 1
|
||||
else:
|
||||
start_year = reference_date.year
|
||||
start = date(start_year, fy_start_month, fy_start_day)
|
||||
next_start = date(start_year + 1, fy_start_month, fy_start_day)
|
||||
end = next_start - timedelta(days=1)
|
||||
return Period(date_from=start, date_to=end, label=f"FY {start_year}")
|
||||
|
||||
|
||||
def month_bounds(reference_date: date) -> Period:
|
||||
"""Return the calendar month containing `reference_date`."""
|
||||
start = reference_date.replace(day=1)
|
||||
if reference_date.month == 12:
|
||||
next_start = date(reference_date.year + 1, 1, 1)
|
||||
else:
|
||||
next_start = date(reference_date.year, reference_date.month + 1, 1)
|
||||
return Period(
|
||||
date_from=start,
|
||||
date_to=next_start - timedelta(days=1),
|
||||
label=start.strftime('%B %Y'),
|
||||
)
|
||||
|
||||
|
||||
def quarter_bounds(reference_date: date) -> Period:
|
||||
"""Return the calendar quarter containing `reference_date`."""
|
||||
quarter = (reference_date.month - 1) // 3 + 1
|
||||
start_month = (quarter - 1) * 3 + 1
|
||||
start = date(reference_date.year, start_month, 1)
|
||||
end_month = start_month + 2
|
||||
if end_month == 12:
|
||||
end = date(reference_date.year, 12, 31)
|
||||
else:
|
||||
end = date(reference_date.year, end_month + 1, 1) - timedelta(days=1)
|
||||
return Period(date_from=start, date_to=end, label=f"Q{quarter} {reference_date.year}")
|
||||
|
||||
|
||||
def comparison_period(period: Period, mode: ComparisonMode) -> Period | None:
|
||||
"""Derive the comparison period for `period` per `mode`.
|
||||
|
||||
`previous_period`: same length, immediately before
|
||||
`previous_year`: same calendar dates, one year earlier
|
||||
`none`: returns None"""
|
||||
if mode == 'none':
|
||||
return None
|
||||
if mode == 'previous_period':
|
||||
days = period.days
|
||||
new_to = period.date_from - timedelta(days=1)
|
||||
new_from = new_to - timedelta(days=days - 1)
|
||||
return Period(date_from=new_from, date_to=new_to,
|
||||
label=f"{period.label} (previous)")
|
||||
if mode == 'previous_year':
|
||||
try:
|
||||
new_from = period.date_from.replace(year=period.date_from.year - 1)
|
||||
new_to = period.date_to.replace(year=period.date_to.year - 1)
|
||||
except ValueError:
|
||||
new_from = period.date_from.replace(year=period.date_from.year - 1, day=28)
|
||||
new_to = period.date_to.replace(year=period.date_to.year - 1, day=28)
|
||||
return Period(date_from=new_from, date_to=new_to,
|
||||
label=f"{period.label} (prev year)")
|
||||
raise ValueError(f"Unknown comparison mode: {mode}")
|
||||
81
fusion_accounting_reports/services/drill_down_resolver.py
Normal file
81
fusion_accounting_reports/services/drill_down_resolver.py
Normal file
@@ -0,0 +1,81 @@
|
||||
"""Drill-down: from a report line to its underlying journal items.
|
||||
|
||||
Given an account_id and a Period, fetches the matching account.move.line
|
||||
records and returns them in a flat list. Used by the OWL drill-down
|
||||
dialog and the engine's drill_down() public API."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
|
||||
|
||||
@dataclass
|
||||
class DrillDownRow:
|
||||
move_line_id: int
|
||||
move_id: int
|
||||
move_name: str
|
||||
date: date
|
||||
account_code: str
|
||||
account_name: str
|
||||
partner_name: str | None
|
||||
label: str
|
||||
debit: float
|
||||
credit: float
|
||||
balance: float
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'move_line_id': self.move_line_id,
|
||||
'move_id': self.move_id,
|
||||
'move_name': self.move_name,
|
||||
'date': str(self.date),
|
||||
'account_code': self.account_code,
|
||||
'account_name': self.account_name,
|
||||
'partner_name': self.partner_name or '',
|
||||
'label': self.label,
|
||||
'debit': self.debit,
|
||||
'credit': self.credit,
|
||||
'balance': self.balance,
|
||||
}
|
||||
|
||||
|
||||
def fetch_drill_down(
|
||||
env,
|
||||
*,
|
||||
account_id: int,
|
||||
date_from: date,
|
||||
date_to: date,
|
||||
company_id: int | None = None,
|
||||
limit: int = 500,
|
||||
) -> list[dict]:
|
||||
"""Fetch journal items for an account within a date range.
|
||||
|
||||
Returns flat list of dicts ready for the drill-down OWL table."""
|
||||
Line = env['account.move.line'].sudo()
|
||||
domain = [
|
||||
('account_id', '=', account_id),
|
||||
('date', '>=', date_from),
|
||||
('date', '<=', date_to),
|
||||
('parent_state', '=', 'posted'),
|
||||
]
|
||||
if company_id:
|
||||
domain.append(('company_id', '=', company_id))
|
||||
|
||||
move_lines = Line.search(domain, limit=limit, order='date asc, id asc')
|
||||
rows = []
|
||||
for ml in move_lines:
|
||||
rows.append(
|
||||
DrillDownRow(
|
||||
move_line_id=ml.id,
|
||||
move_id=ml.move_id.id,
|
||||
move_name=ml.move_id.name or '',
|
||||
date=ml.date,
|
||||
account_code=ml.account_id.code,
|
||||
account_name=ml.account_id.name,
|
||||
partner_name=ml.partner_id.name if ml.partner_id else None,
|
||||
label=ml.name or '',
|
||||
debit=ml.debit,
|
||||
credit=ml.credit,
|
||||
balance=ml.balance,
|
||||
).to_dict()
|
||||
)
|
||||
return rows
|
||||
143
fusion_accounting_reports/services/line_resolver.py
Normal file
143
fusion_accounting_reports/services/line_resolver.py
Normal file
@@ -0,0 +1,143 @@
|
||||
"""Resolve a fusion.report definition into report rows.
|
||||
|
||||
Pure-Python: takes line_specs (list of dicts), a period, and aggregated
|
||||
move-line data (per-account totals) - returns ordered list of report row
|
||||
dicts ready for the OWL frontend or PDF rendering.
|
||||
|
||||
Row shape:
|
||||
{
|
||||
'id': 'line_<index>',
|
||||
'label': str,
|
||||
'level': int, # indentation depth
|
||||
'is_subtotal': bool,
|
||||
'amount': float,
|
||||
'amount_comparison': float | None,
|
||||
'variance_pct': float | None,
|
||||
'account_id': int | None, # for drill-down (None for subtotals)
|
||||
'children': list[dict], # populated when expanded
|
||||
}"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .totaling import TotalLine
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReportRow:
|
||||
id: str
|
||||
label: str
|
||||
level: int = 0
|
||||
is_subtotal: bool = False
|
||||
amount: float = 0.0
|
||||
amount_comparison: float | None = None
|
||||
variance_pct: float | None = None
|
||||
account_id: int | None = None
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
'id': self.id,
|
||||
'label': self.label,
|
||||
'level': self.level,
|
||||
'is_subtotal': self.is_subtotal,
|
||||
'amount': self.amount,
|
||||
'amount_comparison': self.amount_comparison,
|
||||
'variance_pct': self.variance_pct,
|
||||
'account_id': self.account_id,
|
||||
}
|
||||
|
||||
|
||||
def resolve(
|
||||
line_specs: list[dict],
|
||||
*,
|
||||
account_totals: dict[int, TotalLine],
|
||||
accounts_by_id: dict[int, dict],
|
||||
comparison_totals: dict[int, TotalLine] | None = None,
|
||||
) -> list[dict]:
|
||||
"""Resolve line_specs against actual account totals -> list of row dicts.
|
||||
|
||||
Args:
|
||||
line_specs: report definition line specs (from fusion.report.line_specs).
|
||||
account_totals: {account_id: TotalLine} for the period.
|
||||
accounts_by_id: {account_id: {code, name, account_type, ...}}.
|
||||
comparison_totals: optional {account_id: TotalLine} for comparison period.
|
||||
|
||||
Returns: list of row dicts."""
|
||||
rows: list[ReportRow] = []
|
||||
|
||||
for idx, spec in enumerate(line_specs):
|
||||
if spec.get('compute') == 'subtotal':
|
||||
n = spec.get('above', 1)
|
||||
sign = spec.get('sign', 1)
|
||||
recent = [r.amount for r in rows[-n:] if not r.is_subtotal]
|
||||
row = ReportRow(
|
||||
id=f'line_{idx}',
|
||||
label=spec.get('label', 'Subtotal'),
|
||||
level=spec.get('level', 0),
|
||||
is_subtotal=True,
|
||||
amount=sum(recent) * sign,
|
||||
)
|
||||
if comparison_totals is not None:
|
||||
comp_recent = [
|
||||
r.amount_comparison
|
||||
for r in rows[-n:]
|
||||
if not r.is_subtotal and r.amount_comparison is not None
|
||||
]
|
||||
row.amount_comparison = (
|
||||
sum(comp_recent) * sign if comp_recent else None
|
||||
)
|
||||
rows.append(row)
|
||||
|
||||
elif spec.get('account_type_prefix'):
|
||||
prefix = spec['account_type_prefix']
|
||||
sign = spec.get('sign', 1)
|
||||
matched_ids = [
|
||||
aid for aid, info in accounts_by_id.items()
|
||||
if info.get('account_type', '').startswith(prefix)
|
||||
]
|
||||
amount = sum(
|
||||
account_totals.get(aid, TotalLine()).balance * sign
|
||||
for aid in matched_ids
|
||||
)
|
||||
row = ReportRow(
|
||||
id=f'line_{idx}',
|
||||
label=spec.get('label', prefix),
|
||||
level=spec.get('level', 0),
|
||||
amount=amount,
|
||||
)
|
||||
if comparison_totals is not None:
|
||||
comp_amount = sum(
|
||||
comparison_totals.get(aid, TotalLine()).balance * sign
|
||||
for aid in matched_ids
|
||||
)
|
||||
row.amount_comparison = comp_amount
|
||||
if comp_amount != 0:
|
||||
row.variance_pct = (
|
||||
(amount - comp_amount) / abs(comp_amount)
|
||||
) * 100
|
||||
rows.append(row)
|
||||
|
||||
elif spec.get('account_id'):
|
||||
aid = spec['account_id']
|
||||
sign = spec.get('sign', 1)
|
||||
tot = account_totals.get(aid, TotalLine())
|
||||
label = spec.get('label') or accounts_by_id.get(aid, {}).get(
|
||||
'name', f'Account {aid}'
|
||||
)
|
||||
row = ReportRow(
|
||||
id=f'line_{idx}',
|
||||
label=label,
|
||||
level=spec.get('level', 0),
|
||||
amount=tot.balance * sign,
|
||||
account_id=aid,
|
||||
)
|
||||
if comparison_totals is not None:
|
||||
comp = comparison_totals.get(aid, TotalLine())
|
||||
row.amount_comparison = comp.balance * sign
|
||||
if row.amount_comparison and row.amount_comparison != 0:
|
||||
row.variance_pct = (
|
||||
(row.amount - row.amount_comparison)
|
||||
/ abs(row.amount_comparison)
|
||||
) * 100
|
||||
rows.append(row)
|
||||
|
||||
return [r.to_dict() for r in rows]
|
||||
49
fusion_accounting_reports/services/totaling.py
Normal file
49
fusion_accounting_reports/services/totaling.py
Normal file
@@ -0,0 +1,49 @@
|
||||
"""Move-line aggregation primitives for report totaling.
|
||||
|
||||
Pure-Python helpers - callers pass dicts with debit/credit/balance/currency keys,
|
||||
no Odoo recordsets needed. Keeps the math testable without an ORM."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class TotalLine:
|
||||
debit: float = 0.0
|
||||
credit: float = 0.0
|
||||
balance: float = 0.0
|
||||
debit_currency: float = 0.0
|
||||
credit_currency: float = 0.0
|
||||
balance_currency: float = 0.0
|
||||
line_count: int = 0
|
||||
|
||||
|
||||
def aggregate(move_lines: list[dict]) -> TotalLine:
|
||||
"""Aggregate a list of move-line dicts into a TotalLine.
|
||||
|
||||
Each dict must have: debit, credit, balance (signed). Optional:
|
||||
debit_currency, credit_currency, balance_currency."""
|
||||
out = TotalLine()
|
||||
for ml in move_lines:
|
||||
out.debit += ml.get('debit', 0.0)
|
||||
out.credit += ml.get('credit', 0.0)
|
||||
out.balance += ml.get('balance', 0.0)
|
||||
out.debit_currency += ml.get('debit_currency', 0.0)
|
||||
out.credit_currency += ml.get('credit_currency', 0.0)
|
||||
out.balance_currency += ml.get('balance_currency', 0.0)
|
||||
out.line_count += 1
|
||||
return out
|
||||
|
||||
|
||||
def aggregate_per_account(move_lines: list[dict]) -> dict[int, TotalLine]:
|
||||
"""Group + aggregate by account_id. Returns {account_id: TotalLine}."""
|
||||
grouped: dict[int, list[dict]] = {}
|
||||
for ml in move_lines:
|
||||
acct = ml['account_id']
|
||||
grouped.setdefault(acct, []).append(ml)
|
||||
return {acct: aggregate(lines) for acct, lines in grouped.items()}
|
||||
|
||||
|
||||
def is_balanced(move_lines: list[dict], *, tolerance: float = 0.005) -> bool:
|
||||
"""True if total debits == total credits (within tolerance for rounding)."""
|
||||
agg = aggregate(move_lines)
|
||||
return abs(agg.debit - agg.credit) <= tolerance
|
||||
BIN
fusion_accounting_reports/static/description/icon.png
Normal file
BIN
fusion_accounting_reports/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
@@ -0,0 +1,10 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
|
||||
export class AiCommentaryPanel extends Component {
|
||||
static template = "fusion_accounting_reports.AiCommentaryPanel";
|
||||
static props = {
|
||||
commentary: { type: Object },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_accounting_reports.AiCommentaryPanel">
|
||||
<div class="o_fusion_commentary_panel">
|
||||
<h4>📊 AI Commentary</h4>
|
||||
|
||||
<div class="commentary-section" t-if="props.commentary.summary">
|
||||
<p style="margin: 0;"><t t-esc="props.commentary.summary"/></p>
|
||||
</div>
|
||||
|
||||
<div class="commentary-section" t-if="props.commentary.highlights and props.commentary.highlights.length">
|
||||
<h5>Highlights</h5>
|
||||
<ul>
|
||||
<li t-foreach="props.commentary.highlights" t-as="h" t-key="h_index">
|
||||
<t t-esc="h"/>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="commentary-section" t-if="props.commentary.concerns and props.commentary.concerns.length">
|
||||
<h5>Concerns</h5>
|
||||
<ul>
|
||||
<li t-foreach="props.commentary.concerns" t-as="c" t-key="c_index">
|
||||
<t t-esc="c"/>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="commentary-section" t-if="props.commentary.next_actions and props.commentary.next_actions.length">
|
||||
<h5>Next Actions</h5>
|
||||
<ul>
|
||||
<li t-foreach="props.commentary.next_actions" t-as="a" t-key="a_index">
|
||||
<t t-esc="a"/>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="text-muted" style="font-size: 0.75rem;" t-if="props.commentary.cached">
|
||||
Cached • <t t-esc="props.commentary.generated_at"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,18 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
|
||||
export class AnomalyStrip extends Component {
|
||||
static template = "fusion_accounting_reports.AnomalyStrip";
|
||||
static props = {
|
||||
anomaly: { type: Object },
|
||||
};
|
||||
|
||||
formatAmount(amount) {
|
||||
if (amount === null || amount === undefined) return "";
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
minimumFractionDigits: 2, maximumFractionDigits: 2,
|
||||
signDisplay: 'always',
|
||||
}).format(amount);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_accounting_reports.AnomalyStrip">
|
||||
<div class="o_fusion_anomaly_strip" t-att-data-severity="props.anomaly.severity">
|
||||
<strong><t t-esc="props.anomaly.label"/></strong>
|
||||
<span class="ms-2">
|
||||
<t t-esc="props.anomaly.direction === 'increase' ? '↑' : '↓'"/>
|
||||
<t t-esc="props.anomaly.variance_pct.toFixed(1)"/>%
|
||||
(<t t-esc="formatAmount(props.anomaly.variance_amount)"/>)
|
||||
</span>
|
||||
<span class="ms-3 text-muted">
|
||||
severity: <t t-esc="props.anomaly.severity"/>
|
||||
</span>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,24 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
|
||||
export class DrillDownDialog extends Component {
|
||||
static template = "fusion_accounting_reports.DrillDownDialog";
|
||||
static props = {
|
||||
drill: { type: Object },
|
||||
onClose: { type: Function },
|
||||
};
|
||||
|
||||
formatAmount(amount) {
|
||||
if (amount === null || amount === undefined) return "";
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
minimumFractionDigits: 2, maximumFractionDigits: 2,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
onBackdropClick(ev) {
|
||||
if (ev.target.classList.contains('modal-backdrop')) {
|
||||
this.props.onClose();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_accounting_reports.DrillDownDialog">
|
||||
<div class="modal modal-backdrop"
|
||||
style="display: block; background: rgba(0,0,0,0.5); position: fixed; top:0; left:0; right:0; bottom:0; z-index: 1050;"
|
||||
t-on-click="onBackdropClick">
|
||||
<div class="modal-dialog modal-xl"
|
||||
style="margin: 5vh auto; max-width: 90%;">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5>Drill-down: <t t-esc="props.drill.label || ''"/></h5>
|
||||
<button class="btn-close" t-on-click="props.onClose">×</button>
|
||||
</div>
|
||||
<div class="modal-body" style="max-height: 70vh; overflow-y: auto;">
|
||||
<div t-if="!props.drill.rows or props.drill.rows.length === 0" class="text-muted">
|
||||
No journal items found.
|
||||
</div>
|
||||
<table t-else="" class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Move</th>
|
||||
<th>Account</th>
|
||||
<th>Partner</th>
|
||||
<th>Description</th>
|
||||
<th class="text-end">Debit</th>
|
||||
<th class="text-end">Credit</th>
|
||||
<th class="text-end">Balance</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr t-foreach="props.drill.rows" t-as="row" t-key="row.move_line_id">
|
||||
<td><t t-esc="row.date"/></td>
|
||||
<td><t t-esc="row.move_name"/></td>
|
||||
<td>
|
||||
<span t-att-title="row.account_name">
|
||||
<t t-esc="row.account_code"/>
|
||||
</span>
|
||||
</td>
|
||||
<td><t t-esc="row.partner_name || ''"/></td>
|
||||
<td><t t-esc="row.label"/></td>
|
||||
<td class="text-end"><t t-esc="formatAmount(row.debit)"/></td>
|
||||
<td class="text-end"><t t-esc="formatAmount(row.credit)"/></td>
|
||||
<td class="text-end"><t t-esc="formatAmount(row.balance)"/></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<span class="text-muted me-auto"><t t-esc="props.drill.count"/> rows</span>
|
||||
<button class="btn_report" t-on-click="props.onClose">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,37 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component, useState } from "@odoo/owl";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
export class PeriodFilter extends Component {
|
||||
static template = "fusion_accounting_reports.PeriodFilter";
|
||||
static props = {};
|
||||
|
||||
setup() {
|
||||
this.reports = useService("fusion_reports");
|
||||
this.state = useState(this.reports.state);
|
||||
}
|
||||
|
||||
async onReportTypeChange(ev) {
|
||||
const reportType = ev.target.value;
|
||||
if (reportType && this.state.dateFrom && this.state.dateTo) {
|
||||
await this.reports.runReport(
|
||||
reportType, this.state.dateFrom, this.state.dateTo,
|
||||
this.state.comparison);
|
||||
}
|
||||
}
|
||||
|
||||
async onDateChange(field, ev) {
|
||||
this.state[field] = ev.target.value;
|
||||
if (this.state.currentReportType && this.state.dateFrom && this.state.dateTo) {
|
||||
await this.reports.runReport(
|
||||
this.state.currentReportType,
|
||||
this.state.dateFrom, this.state.dateTo,
|
||||
this.state.comparison);
|
||||
}
|
||||
}
|
||||
|
||||
async onComparisonChange(ev) {
|
||||
await this.reports.setComparison(ev.target.value);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_accounting_reports.PeriodFilter">
|
||||
<div class="o_fusion_reports_filters">
|
||||
<select t-on-change="onReportTypeChange"
|
||||
class="form-select" style="max-width: 240px;">
|
||||
<option value="">— Select report —</option>
|
||||
<option t-foreach="state.availableReports" t-as="r" t-key="r.id"
|
||||
t-att-value="r.report_type"
|
||||
t-att-selected="r.report_type === state.currentReportType">
|
||||
<t t-esc="r.name"/>
|
||||
</option>
|
||||
</select>
|
||||
|
||||
<label>From</label>
|
||||
<input type="date" class="form-control" style="max-width: 160px;"
|
||||
t-att-value="state.dateFrom || ''"
|
||||
t-on-change="(ev) => onDateChange('dateFrom', ev)"/>
|
||||
|
||||
<label>To</label>
|
||||
<input type="date" class="form-control" style="max-width: 160px;"
|
||||
t-att-value="state.dateTo || ''"
|
||||
t-on-change="(ev) => onDateChange('dateTo', ev)"/>
|
||||
|
||||
<label>Comparison</label>
|
||||
<select class="form-select" style="max-width: 200px;"
|
||||
t-on-change="onComparisonChange">
|
||||
<option value="none" t-att-selected="state.comparison === 'none'">None</option>
|
||||
<option value="previous_period"
|
||||
t-att-selected="state.comparison === 'previous_period'">Previous Period</option>
|
||||
<option value="previous_year"
|
||||
t-att-selected="state.comparison === 'previous_year'">Previous Year</option>
|
||||
</select>
|
||||
|
||||
<span t-if="state.isLoading" class="text-muted ms-3">Loading...</span>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,36 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
|
||||
export class ReportTable extends Component {
|
||||
static template = "fusion_accounting_reports.ReportTable";
|
||||
static props = {
|
||||
result: { type: Object },
|
||||
onDrillDown: { type: Function, optional: true },
|
||||
};
|
||||
|
||||
formatAmount(amount) {
|
||||
if (amount === null || amount === undefined) return "";
|
||||
return new Intl.NumberFormat(undefined, {
|
||||
minimumFractionDigits: 2, maximumFractionDigits: 2,
|
||||
}).format(amount);
|
||||
}
|
||||
|
||||
onRowClick(row) {
|
||||
if (row.account_id && this.props.onDrillDown) {
|
||||
this.props.onDrillDown(row.account_id, row.label);
|
||||
}
|
||||
}
|
||||
|
||||
rowClass(row) {
|
||||
const classes = ['report-row', `level-${row.level || 0}`];
|
||||
if (row.is_subtotal) classes.push('subtotal');
|
||||
if (row.account_id) classes.push('drillable');
|
||||
return classes.join(' ');
|
||||
}
|
||||
|
||||
varianceClass(pct) {
|
||||
if (pct === null || pct === undefined) return "";
|
||||
return pct > 0 ? 'variance-pos' : pct < 0 ? 'variance-neg' : '';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_accounting_reports.ReportTable">
|
||||
<div class="o_fusion_reports_table">
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Line</th>
|
||||
<th class="amount">Amount</th>
|
||||
<t t-if="props.result.comparison_period">
|
||||
<th class="amount">
|
||||
<t t-esc="props.result.comparison_period.label"/>
|
||||
</th>
|
||||
<th class="amount">Variance %</th>
|
||||
</t>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr t-foreach="props.result.rows" t-as="row" t-key="row.id"
|
||||
t-att-class="rowClass(row)"
|
||||
t-on-click="() => onRowClick(row)">
|
||||
<td>
|
||||
<span><t t-esc="row.label"/></span>
|
||||
</td>
|
||||
<td class="amount">
|
||||
<t t-esc="formatAmount(row.amount)"/>
|
||||
</td>
|
||||
<t t-if="props.result.comparison_period">
|
||||
<td class="amount">
|
||||
<t t-esc="formatAmount(row.amount_comparison)"/>
|
||||
</td>
|
||||
<td class="amount" t-att-class="varianceClass(row.variance_pct)">
|
||||
<t t-if="row.variance_pct !== null and row.variance_pct !== undefined">
|
||||
<t t-esc="row.variance_pct.toFixed(1)"/>%
|
||||
</t>
|
||||
</td>
|
||||
</t>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
49
fusion_accounting_reports/static/src/scss/_variables.scss
Normal file
49
fusion_accounting_reports/static/src/scss/_variables.scss
Normal file
@@ -0,0 +1,49 @@
|
||||
// Fusion reports design tokens (extends Phase 1's bank_rec tokens for consistency).
|
||||
|
||||
// Colors — semantic
|
||||
$report-bg-primary: #ffffff;
|
||||
$report-bg-secondary: #f9fafb;
|
||||
$report-bg-tertiary: #f3f4f6;
|
||||
$report-border: #e5e7eb;
|
||||
$report-text-primary: #111827;
|
||||
$report-text-secondary: #6b7280;
|
||||
$report-text-muted: #9ca3af;
|
||||
$report-accent: #3b82f6;
|
||||
$report-accent-bg: #eff6ff;
|
||||
|
||||
// Severity colors (mirrors bank_rec)
|
||||
$report-severity-high: #ef4444;
|
||||
$report-severity-high-bg: #fef2f2;
|
||||
$report-severity-medium: #f59e0b;
|
||||
$report-severity-medium-bg: #fffbeb;
|
||||
$report-severity-low: #10b981;
|
||||
$report-severity-low-bg: #ecfdf5;
|
||||
|
||||
// Variance indicators
|
||||
$report-variance-positive: #10b981;
|
||||
$report-variance-negative: #ef4444;
|
||||
|
||||
// Spacing
|
||||
$report-space-1: 0.25rem;
|
||||
$report-space-2: 0.5rem;
|
||||
$report-space-3: 0.75rem;
|
||||
$report-space-4: 1rem;
|
||||
$report-space-5: 1.25rem;
|
||||
$report-space-6: 1.5rem;
|
||||
$report-space-8: 2rem;
|
||||
|
||||
// Typography
|
||||
$report-font-size-xs: 0.75rem;
|
||||
$report-font-size-sm: 0.875rem;
|
||||
$report-font-size-base: 1rem;
|
||||
$report-font-size-lg: 1.125rem;
|
||||
$report-font-size-xl: 1.25rem;
|
||||
$report-font-mono: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
|
||||
// Borders + radii
|
||||
$report-border-radius: 0.375rem;
|
||||
$report-border-radius-md: 0.5rem;
|
||||
$report-border-radius-lg: 0.75rem;
|
||||
|
||||
// Subtotal indentation
|
||||
$report-indent-per-level: 1.5rem;
|
||||
34
fusion_accounting_reports/static/src/scss/dark_mode.scss
Normal file
34
fusion_accounting_reports/static/src/scss/dark_mode.scss
Normal file
@@ -0,0 +1,34 @@
|
||||
@import "variables";
|
||||
|
||||
[data-color-scheme="dark"] .o_fusion_reports {
|
||||
background: #1f2937;
|
||||
color: #f9fafb;
|
||||
|
||||
&_header, &_table, &_filters, .o_fusion_commentary_panel {
|
||||
background: #111827;
|
||||
border-color: #374151;
|
||||
color: #f9fafb;
|
||||
}
|
||||
|
||||
&_table {
|
||||
th { background: #1f2937; color: #d1d5db; }
|
||||
td { border-color: #374151; }
|
||||
tr.subtotal { background: #1f2937; }
|
||||
tr.drillable:hover { background: #1e3a8a; }
|
||||
}
|
||||
|
||||
.btn_report {
|
||||
background: #374151;
|
||||
border-color: #4b5563;
|
||||
color: #f9fafb;
|
||||
|
||||
&:hover { background: #4b5563; }
|
||||
&.primary { background: #3b82f6; }
|
||||
}
|
||||
|
||||
.o_fusion_anomaly_strip {
|
||||
&[data-severity="high"] { background: rgba(239, 68, 68, 0.15); }
|
||||
&[data-severity="medium"] { background: rgba(245, 158, 11, 0.15); }
|
||||
&[data-severity="low"] { background: rgba(16, 185, 129, 0.15); }
|
||||
}
|
||||
}
|
||||
161
fusion_accounting_reports/static/src/scss/reports.scss
Normal file
161
fusion_accounting_reports/static/src/scss/reports.scss
Normal file
@@ -0,0 +1,161 @@
|
||||
@import "variables";
|
||||
|
||||
.o_fusion_reports {
|
||||
background: $report-bg-secondary;
|
||||
min-height: 100vh;
|
||||
|
||||
&_header {
|
||||
background: $report-bg-primary;
|
||||
border-bottom: 1px solid $report-border;
|
||||
padding: $report-space-4 $report-space-6;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
|
||||
h1 {
|
||||
font-size: $report-font-size-xl;
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&_table {
|
||||
background: $report-bg-primary;
|
||||
border: 1px solid $report-border;
|
||||
border-radius: $report-border-radius-md;
|
||||
margin: $report-space-4;
|
||||
overflow: hidden;
|
||||
font-family: $report-font-mono;
|
||||
font-size: $report-font-size-sm;
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
th {
|
||||
background: $report-bg-tertiary;
|
||||
padding: $report-space-3 $report-space-4;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
color: $report-text-secondary;
|
||||
border-bottom: 1px solid $report-border;
|
||||
}
|
||||
|
||||
th.amount, td.amount {
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td {
|
||||
padding: $report-space-2 $report-space-4;
|
||||
border-bottom: 1px solid lighten($report-border, 5%);
|
||||
}
|
||||
|
||||
tr.subtotal {
|
||||
font-weight: 600;
|
||||
background: $report-bg-secondary;
|
||||
border-top: 1px solid $report-text-muted;
|
||||
}
|
||||
|
||||
tr.subtotal td {
|
||||
border-bottom: 1px solid $report-text-muted;
|
||||
}
|
||||
|
||||
tr.drillable {
|
||||
cursor: pointer;
|
||||
&:hover { background: $report-accent-bg; }
|
||||
}
|
||||
|
||||
.level-1 { padding-left: $report-space-4 + $report-indent-per-level; }
|
||||
.level-2 { padding-left: $report-space-4 + $report-indent-per-level * 2; }
|
||||
.level-3 { padding-left: $report-space-4 + $report-indent-per-level * 3; }
|
||||
|
||||
.variance-pos { color: $report-variance-positive; }
|
||||
.variance-neg { color: $report-variance-negative; }
|
||||
}
|
||||
|
||||
&_filters {
|
||||
background: $report-bg-primary;
|
||||
padding: $report-space-3 $report-space-4;
|
||||
border-bottom: 1px solid $report-border;
|
||||
display: flex;
|
||||
gap: $report-space-3;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.btn_report {
|
||||
padding: $report-space-2 $report-space-4;
|
||||
border-radius: $report-border-radius;
|
||||
background: $report-bg-primary;
|
||||
border: 1px solid $report-border;
|
||||
color: $report-text-primary;
|
||||
font-size: $report-font-size-sm;
|
||||
cursor: pointer;
|
||||
transition: all 150ms ease-in-out;
|
||||
|
||||
&:hover { background: $report-bg-tertiary; }
|
||||
|
||||
&.primary {
|
||||
background: $report-accent;
|
||||
border-color: $report-accent;
|
||||
color: white;
|
||||
|
||||
&:hover { background: darken($report-accent, 8%); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.o_fusion_anomaly_strip {
|
||||
margin: $report-space-3;
|
||||
padding: $report-space-3;
|
||||
border-radius: $report-border-radius;
|
||||
border: 1px solid;
|
||||
font-size: $report-font-size-sm;
|
||||
|
||||
&[data-severity="high"] {
|
||||
background: $report-severity-high-bg;
|
||||
border-color: $report-severity-high;
|
||||
}
|
||||
&[data-severity="medium"] {
|
||||
background: $report-severity-medium-bg;
|
||||
border-color: $report-severity-medium;
|
||||
}
|
||||
&[data-severity="low"] {
|
||||
background: $report-severity-low-bg;
|
||||
border-color: $report-severity-low;
|
||||
}
|
||||
}
|
||||
|
||||
.o_fusion_commentary_panel {
|
||||
background: $report-bg-primary;
|
||||
border: 1px solid $report-border;
|
||||
border-radius: $report-border-radius-md;
|
||||
margin: $report-space-3;
|
||||
padding: $report-space-4;
|
||||
|
||||
h4 {
|
||||
margin: 0 0 $report-space-3;
|
||||
font-size: $report-font-size-base;
|
||||
color: $report-text-primary;
|
||||
}
|
||||
|
||||
.commentary-section {
|
||||
margin-bottom: $report-space-3;
|
||||
|
||||
h5 {
|
||||
font-size: $report-font-size-sm;
|
||||
color: $report-text-secondary;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin-bottom: $report-space-2;
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 0;
|
||||
padding-left: $report-space-4;
|
||||
|
||||
li { margin: $report-space-1 0; }
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user