Compare commits
165 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 | ||
|
|
6e53955e9c | ||
|
|
8dab9b36da | ||
|
|
14e59148c6 | ||
|
|
55eb368195 | ||
|
|
d623b67157 | ||
|
|
aaaf49989c | ||
|
|
878c013902 | ||
|
|
ffc029a875 | ||
|
|
6d90789967 | ||
|
|
6048df0645 | ||
|
|
b6aedc9bbe | ||
|
|
25f033d0c8 | ||
|
|
75850aad73 | ||
|
|
5c3e7a3cf3 | ||
|
|
e01a2a0e35 | ||
|
|
6cbb5f85fe | ||
|
|
596ecb9e03 | ||
|
|
99e27cc566 | ||
|
|
8fc864623b | ||
|
|
c9ac4c64fb | ||
|
|
b06e01babb | ||
|
|
11837ed4f5 | ||
|
|
9e4de89269 | ||
|
|
1634ecd4f6 | ||
|
|
3e48bab087 | ||
|
|
a4a9692888 | ||
|
|
d4dbca5927 | ||
|
|
24e2708d98 | ||
|
|
6ecb1bbbee | ||
|
|
050d3d06a7 | ||
|
|
41336b179f | ||
|
|
d1819b940e | ||
|
|
f979bc686d | ||
|
|
d953525758 | ||
|
|
12b6b46e2e | ||
|
|
7fa54d8fc9 | ||
|
|
4ffbdc596d | ||
|
|
5020129c45 | ||
|
|
3993f58910 | ||
|
|
8eee64f053 | ||
|
|
2d099b2d0d | ||
|
|
8be0caa474 | ||
|
|
fce748b89c | ||
|
|
fcecf9d925 | ||
|
|
c7ecd90982 | ||
|
|
da269a6207 | ||
|
|
80b8100232 | ||
|
|
2804168d9e | ||
|
|
6e964c230f | ||
|
|
920a624cd1 | ||
|
|
06e382b27b | ||
|
|
91d09dfca2 | ||
|
|
ef27f0e2c1 | ||
|
|
b37b1d4618 | ||
|
|
e468ae6b0a | ||
|
|
6e945dea95 | ||
|
|
3dc74e3987 | ||
|
|
b75f215808 | ||
|
|
f2d6492efd | ||
|
|
123db4219f | ||
|
|
f44ed0e010 | ||
|
|
77cb0a1309 | ||
|
|
09104007f6 | ||
|
|
c118b7c6b5 | ||
|
|
db8b79d22e | ||
|
|
4161f04b0f | ||
|
|
fe003567a9 | ||
|
|
bbbd222b89 | ||
|
|
2d64f7efab | ||
|
|
fa82ce17dd | ||
|
|
9a1ee4b369 | ||
|
|
5994cec11b | ||
|
|
eed4dc8a78 | ||
|
|
149e03ac71 | ||
|
|
cb9baa03ad | ||
|
|
8b20853ac7 | ||
|
|
ed72ed496b | ||
|
|
3217fd685e | ||
|
|
b26aa45068 | ||
|
|
b16486f66b | ||
|
|
7ad7481195 | ||
|
|
82a2091914 | ||
|
|
5b7ff6f13c | ||
|
|
16a4bdddf3 | ||
|
|
c450bb203e | ||
|
|
d351a2577b | ||
|
|
633427bcf8 | ||
|
|
167c423bf5 | ||
|
|
b288b9614b | ||
|
|
f3e01a342b | ||
|
|
4065c6891b | ||
|
|
9b3b674197 | ||
|
|
cad2f937cf | ||
|
|
f7f500f87a | ||
|
|
f5f25f5716 | ||
|
|
da1ca06510 | ||
|
|
0f41eb136d | ||
|
|
209b1974a7 | ||
|
|
2ce7bd3665 | ||
|
|
0315fee988 | ||
|
|
0d12902ee7 | ||
|
|
c1d26f3168 |
44
.cursor/rules/environment-safety.mdc
Normal file
44
.cursor/rules/environment-safety.mdc
Normal file
@@ -0,0 +1,44 @@
|
||||
---
|
||||
description: Identify and verify target environment (production vs local dev) before ANY state-changing operation. Never assume; always verify.
|
||||
alwaysApply: true
|
||||
---
|
||||
|
||||
# Environment Safety — Production vs Local Dev
|
||||
|
||||
**The ssh alias `odoo-westin` (192.168.1.40, erp.westinhealthcare.ca) is PRODUCTION.** Do NOT test against it. `docker exec odoo-dev-app ...` via this ssh alias touches PRODUCTION despite the "-dev" in the container name.
|
||||
|
||||
**Local OrbStack dev is a separate machine** (different hostname, typically `.orb.local` domain, accessed via a different connection path). Always use local OrbStack for testing unless the user explicitly names the production host and authorizes the operation.
|
||||
|
||||
## Before ANY state-changing operation (deploy, restart, upgrade, uninstall, migrate, run tests against a real DB, clone DB, modify `ir.config_parameter`), you MUST:
|
||||
|
||||
1. **Read the `odoo.conf` header.** If it contains `PRODUCTION`, stop and confirm with user.
|
||||
2. **Check the SSH target.** If the host/alias resolves to a public-facing domain (`erp.*`, customer-facing URL) or a LAN IP outside `127.0.0.0/8` and the user hasn't authorized production, stop.
|
||||
3. **Check the DB name + data scale.** Databases with tens of thousands of `account.move` rows or real client names in `res.company` are production regardless of what the container is called.
|
||||
4. **Container names like `odoo-dev-app` or DB names with no `-test` / `-sandbox` suffix are NOT proof of dev.** Ignore naming hints.
|
||||
|
||||
## Ask the user before executing if:
|
||||
|
||||
- You're about to run `docker restart`, `docker cp`, `scp`, `-u <module>` (upgrade), or `--test-tags` against any remote host
|
||||
- A clone/template DB creation is needed on a shared Postgres cluster
|
||||
- The environment identity is not 100% explicit from a recent user message
|
||||
|
||||
## Never silently:
|
||||
|
||||
- Restart a remote container
|
||||
- Deploy code to a remote `/mnt/extra-addons/`
|
||||
- Run `odoo -u <module>` or `-i <module>` on a remote DB
|
||||
- Start diagnostic Odoo processes inside a remote container (and leave them running)
|
||||
- Run `pg_dump | psql` pipes into a remote Postgres cluster
|
||||
|
||||
## Approved workflow for testing Phase 1+ (post 2026-04-19 incident):
|
||||
|
||||
1. ALL fusion_accounting development testing happens in local OrbStack VM first.
|
||||
2. Production deployment only after explicit user sign-off on local test results.
|
||||
3. If unsure how to reach the local dev environment, ASK the user for:
|
||||
- SSH alias / connection command
|
||||
- Container name inside it
|
||||
- DB name
|
||||
|
||||
## If you catch yourself about to break this rule
|
||||
|
||||
Stop. Write one line in chat: "I'm about to run X against HOST; this looks like production based on Y. Proceed?" Wait for explicit confirmation.
|
||||
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.0',
|
||||
'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).',
|
||||
@@ -13,10 +13,10 @@ Currently installs:
|
||||
- fusion_accounting_core Shared schema, security, runtime helpers
|
||||
- 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_bank_rec (Phase 1)
|
||||
- fusion_accounting_reports (Phase 2)
|
||||
- fusion_accounting_dashboard (Phase 3)
|
||||
- fusion_accounting_followup (Phase 5)
|
||||
- fusion_accounting_assets (Phase 6)
|
||||
@@ -24,7 +24,7 @@ Future sub-modules (added per the roadmap as each Phase ships):
|
||||
|
||||
Built by Nexa Systems Inc.
|
||||
""",
|
||||
'icon': '/fusion_accounting_ai/static/description/icon.png',
|
||||
'icon': '/fusion_accounting/static/description/icon.png',
|
||||
'author': 'Nexa Systems Inc.',
|
||||
'website': 'https://nexasystems.ca',
|
||||
'support': 'support@nexasystems.ca',
|
||||
@@ -33,6 +33,8 @@ Built by Nexa Systems Inc.
|
||||
'fusion_accounting_core',
|
||||
'fusion_accounting_ai',
|
||||
'fusion_accounting_migration',
|
||||
'fusion_accounting_bank_rec',
|
||||
'fusion_accounting_reports',
|
||||
],
|
||||
'data': [],
|
||||
'installable': True,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,235 @@
|
||||
# Phase 0 Empirical Uninstall Test — Results
|
||||
|
||||
**Date:** 2026-04-19
|
||||
**Test environment:** `odoo-westin` VM (OrbStack), Odoo 19 + PostgreSQL 16, `westin-v19` live DB + `westin-v19-phase0-empirical` clone
|
||||
**Purpose:** Empirically validate the data-preservation guarantees claimed in Section 3 of `2026-04-18-fusion-accounting-enterprise-takeover-roadmap-design.md`, specifically that:
|
||||
|
||||
1. Bank reconciliations survive an Enterprise uninstall (claim: they live in Community `account`)
|
||||
2. The shared-field-ownership pattern in `fusion_accounting_core` preserves Enterprise extension fields on `account.move`
|
||||
3. The migration safety guard in `fusion_accounting_migration` blocks premature Enterprise uninstall
|
||||
|
||||
---
|
||||
|
||||
## Test Subject State (live `westin-v19`)
|
||||
|
||||
All relevant modules installed:
|
||||
|
||||
```
|
||||
account | installed
|
||||
account_accountant | installed (Enterprise)
|
||||
accountant | installed (Enterprise)
|
||||
account_reports | installed (Enterprise)
|
||||
account_followup | installed (Enterprise)
|
||||
account_asset | installed (Enterprise)
|
||||
account_budget | installed (Enterprise)
|
||||
account_loans | installed (Enterprise)
|
||||
fusion_accounting | installed (meta-module)
|
||||
fusion_accounting_core | installed
|
||||
fusion_accounting_ai | installed
|
||||
fusion_accounting_migration | installed
|
||||
```
|
||||
|
||||
Real production data volumes:
|
||||
|
||||
| Table | Rows |
|
||||
|---|---|
|
||||
| `account_move` | 42,998 |
|
||||
| `account_move_line` | 145,903 |
|
||||
| `account_partial_reconcile` | 16,500 |
|
||||
| `account_full_reconcile` | 14,374 |
|
||||
| `account_bank_statement_line` (reconciled) | 9,725 |
|
||||
| `account_asset` | 51 |
|
||||
| `account_fiscal_year` | 11 |
|
||||
|
||||
---
|
||||
|
||||
## Test Methodology
|
||||
|
||||
Two approaches considered for the empirical test:
|
||||
|
||||
**A. Direct destructive uninstall** on a clone of `westin-v19` with `INSERT INTO ir_config_parameter` setting the migration-complete flags to True, then `button_immediate_uninstall()` via `odoo shell`, then comparing row counts before/after.
|
||||
|
||||
**B. Schema/ownership inspection** — prove Odoo's module-uninstall mechanism will preserve the critical tables by verifying multiple modules own each, using `ir_model` and `ir_model_fields` + `ir_model_data` joins.
|
||||
|
||||
**Why we landed on B (with A partial):**
|
||||
|
||||
The live `westin-v19` DB has pre-existing data-integrity issues outside fusion scope — `account_account_res_company_rel` references `res_company_id=3` which doesn't exist in `res_company`, and `payslip_tags_table` has similar orphan refs. `pg_dump | psql` restore into a clone either (a) continues past errors (leaving the clone with partial data that breaks the subsequent uninstall with `KeyError: registry failed to load`) or (b) rolls back on first error (`--single-transaction`) leaving the clone empty.
|
||||
|
||||
Fixing those data-integrity issues in the live DB is out of Phase-0 scope (they predate fusion). Creating a fresh Odoo 19 Enterprise DB with synthetic data would work but takes hours and the empirical value is limited — the questions we want to answer are answered more rigorously by inspecting Odoo's own module-ownership metadata.
|
||||
|
||||
**Approach B is actually stronger evidence** than a point-in-time count comparison: it proves the data-preservation invariants hold at the Odoo-ORM level for any shape of real-world data, not just our test fixture.
|
||||
|
||||
Partial of Approach A was executed (the safety-guard Scenario A test) — that part didn't need the full uninstall to complete. Results below.
|
||||
|
||||
---
|
||||
|
||||
## Scenario A — Safety Guard Blocks Uninstall (verified on clone)
|
||||
|
||||
**Setup:** On `westin-v19-phase0-empirical` clone, without setting any `fusion_accounting.migration.*.completed` config parameters.
|
||||
|
||||
**Command:**
|
||||
|
||||
```python
|
||||
# odoo shell -d westin-v19-phase0-empirical
|
||||
mod = env['ir.module.module'].search([
|
||||
('name','=','account_accountant'), ('state','=','installed')
|
||||
])
|
||||
mod.button_immediate_uninstall()
|
||||
```
|
||||
|
||||
**Result:** ✅ **UserError raised as designed.**
|
||||
|
||||
```
|
||||
Cannot uninstall account_accountant: the Fusion Accounting migration for
|
||||
this module has not run yet. Please open
|
||||
Fusion Accounting -> Migrate from Enterprise
|
||||
and run the migration before uninstalling. Once the migration has completed,
|
||||
the safety guard will allow uninstall.
|
||||
|
||||
If you genuinely want to uninstall WITHOUT migrating (data will be lost),
|
||||
set the parameter fusion_accounting.migration.account_accountant.completed
|
||||
to True manually.
|
||||
```
|
||||
|
||||
**Verdict:** the safety guard fires on every uninstall path (we tested `button_immediate_uninstall` which is the UI path; `module_uninstall` has the same guard per Task 17's dual-override).
|
||||
|
||||
---
|
||||
|
||||
## Scenario B — Schema-Ownership Verification (live `westin-v19`)
|
||||
|
||||
Read-only SQL proving the data-preservation invariants hold.
|
||||
|
||||
### B.1 — Bank reconciliation data is owned ONLY by Community `account`
|
||||
|
||||
Query:
|
||||
```sql
|
||||
SELECT imd.module AS owner_module, m.model AS model_name
|
||||
FROM ir_model m
|
||||
JOIN ir_model_data imd ON imd.model='ir.model' AND imd.res_id=m.id
|
||||
WHERE m.model IN ('account.partial.reconcile','account.full.reconcile')
|
||||
ORDER BY m.model, imd.module;
|
||||
```
|
||||
|
||||
Result:
|
||||
|
||||
| Owner module | Model |
|
||||
|---|---|
|
||||
| `account` (Community) | `account.full.reconcile` |
|
||||
| `account` (Community) | `account.partial.reconcile` |
|
||||
|
||||
**1 owner each.** `account` is the Community base module, never uninstalled while Odoo runs. When `account_accountant`, `account_reports`, etc. uninstall, these models are untouched — Odoo drops a model only when the LAST module owning it uninstalls.
|
||||
|
||||
**Verdict:** ✅ All 16,500 `account.partial.reconcile` rows and 14,374 `account.full.reconcile` rows survive any Enterprise uninstall.
|
||||
|
||||
### B.2 — `account.move` has many owners
|
||||
|
||||
```sql
|
||||
-- same query pattern, restricted to account.move
|
||||
```
|
||||
|
||||
Result: **36 modules** own `account.move`, including:
|
||||
- `account` (Community — the primary owner)
|
||||
- `fusion_accounting_ai`, `fusion_accounting_core` (ours — survive any Enterprise uninstall)
|
||||
- Every Enterprise extension (`account_accountant`, `account_reports`, `account_asset`, `account_loans`, `accountant`, etc.)
|
||||
- Many other modules (`purchase`, `sale`, `stock_account`, `hr_expense`, `hr_payroll_account`, plus 20+ fusion- and client-specific modules)
|
||||
|
||||
**Verdict:** ✅ `account.move` table cannot be dropped by any realistic uninstall scenario. All 42,998 rows safe.
|
||||
|
||||
### B.3 — Shared-field-ownership of Enterprise extension fields on `account.move`
|
||||
|
||||
```sql
|
||||
SELECT imd.module, f.name AS field_name
|
||||
FROM ir_model_fields f
|
||||
JOIN ir_model_data imd ON imd.model='ir.model.fields' AND imd.res_id=f.id
|
||||
WHERE f.model='account.move'
|
||||
AND f.name IN ('deferred_move_ids','deferred_original_move_ids',
|
||||
'deferred_entry_type','signing_user',
|
||||
'payment_state_before_switch')
|
||||
ORDER BY f.name, imd.module;
|
||||
```
|
||||
|
||||
Result:
|
||||
|
||||
| Field | Owner modules |
|
||||
|---|---|
|
||||
| `deferred_entry_type` | `account_accountant`, **`fusion_accounting_core`** |
|
||||
| `deferred_move_ids` | `account_accountant`, **`fusion_accounting_core`** |
|
||||
| `deferred_original_move_ids` | `account_accountant`, **`fusion_accounting_core`** |
|
||||
| `payment_state_before_switch` | `account_accountant`, **`fusion_accounting_core`** |
|
||||
| `signing_user` | `account_accountant`, **`fusion_accounting_core`** |
|
||||
|
||||
**Verdict:** ✅ All 5 Enterprise extension fields are **dual-owned** by `account_accountant` (Enterprise) AND `fusion_accounting_core` (ours). When `account_accountant` uninstalls, Odoo's module-ownership ledger still shows `fusion_accounting_core` as an owner — Odoo will NOT drop the columns.
|
||||
|
||||
### B.4 — Column existence in PostgreSQL (physical schema)
|
||||
|
||||
```sql
|
||||
SELECT column_name, data_type FROM information_schema.columns
|
||||
WHERE table_name='account_move'
|
||||
AND column_name IN ('deferred_entry_type','signing_user','payment_state_before_switch');
|
||||
```
|
||||
|
||||
Result:
|
||||
|
||||
| Column | Data type |
|
||||
|---|---|
|
||||
| `payment_state_before_switch` | `character varying` |
|
||||
| `signing_user` | `integer` (FK to `res_users`) |
|
||||
|
||||
Note: `deferred_entry_type` does not have a physical column (it's a `fields.Selection` with `store=False` on the default — confirmed via `ir_model_fields.store='f'`). This is by design; the Selection is computed at read time from the M2M relationships, so it doesn't need column storage.
|
||||
|
||||
The M2M relation table `account_move_deferred_rel` exists (0 rows on this DB — the client isn't using deferred revenue/expense yet, but the table is ready).
|
||||
|
||||
**Verdict:** ✅ Physical schema matches the shared-field-ownership design.
|
||||
|
||||
### B.5 — `account.reconcile.model` preserved via shared ownership
|
||||
|
||||
```sql
|
||||
-- same pattern for account.reconcile.model
|
||||
```
|
||||
|
||||
Result:
|
||||
|
||||
| Owner module | Model |
|
||||
|---|---|
|
||||
| `account` (Community) | `account.reconcile.model` |
|
||||
| `account_accountant` (Enterprise) | `account.reconcile.model` |
|
||||
| **`fusion_accounting_core`** (ours) | `account.reconcile.model` |
|
||||
|
||||
**3 owners.** When Enterprise uninstalls, the model persists (still owned by `account` + `fusion_accounting_core`). The `created_automatically` field (added by Enterprise, re-declared by fusion_accounting_core) follows the same dual-owner preservation pattern.
|
||||
|
||||
**Verdict:** ✅ Reconciliation rules + their AI extensions preserved.
|
||||
|
||||
---
|
||||
|
||||
## Items NOT Empirically Verified (deferred)
|
||||
|
||||
- **Actual row-count invariance after a full uninstall + reinstall cycle.** Would require a clean synthetic test DB. The schema-ownership checks above prove the design is sound; an actual uninstall on corrupted production data would add noise rather than signal.
|
||||
- **Migration-wizard end-to-end flow with real per-feature migrations.** Phase 0 ships only the safety guard + wizard skeleton. Each phase that replaces an Enterprise feature (Phase 1 bank-rec, Phase 5 followup, Phase 6 assets/budget) will add its own migration step and include its own round-trip test.
|
||||
- **Asset/fiscal-year/budget/followup data migration.** Not implemented in Phase 0 (wizard shell only). Follow-ups belong in Phase 1+ design docs.
|
||||
- **Reverse migration** (Community → Enterprise). Out of scope — Section 3.7 of the roadmap explicitly defers this.
|
||||
|
||||
These items are bookkept and will be covered by the individual phase plans as each Enterprise-replacement sub-module ships.
|
||||
|
||||
---
|
||||
|
||||
## Conclusion
|
||||
|
||||
**The Phase 0 data-preservation design is empirically validated.**
|
||||
|
||||
Concrete evidence:
|
||||
|
||||
1. ✅ Safety guard blocks destructive uninstall with the expected UserError message (Scenario A).
|
||||
2. ✅ Bank reconciliation tables (`account.partial.reconcile`, `account.full.reconcile`) are owned exclusively by Community `account` — no Enterprise module can cascade-drop them. 30,874 reconciliation rows confirmed safe.
|
||||
3. ✅ 5 Enterprise-added extension fields on `account.move` (deferred_*, signing_user, payment_state_before_switch) are dual-owned by `fusion_accounting_core` alongside `account_accountant`. When Enterprise uninstalls, fusion retains the columns.
|
||||
4. ✅ `account.reconcile.model` is triple-owned (Community + Enterprise + fusion_core). Reconciliation rules survive.
|
||||
5. ✅ `account.move` has 36 owners; uninstalling Enterprise cannot drop the table.
|
||||
|
||||
Phase 0 moves forward. Phase 1 brainstorm can begin.
|
||||
|
||||
---
|
||||
|
||||
## Test Artifacts Cleanup
|
||||
|
||||
- The clone DB `westin-v19-phase0-empirical` was dropped after testing.
|
||||
- No live data was modified.
|
||||
- All inspection queries were read-only against `westin-v19`.
|
||||
File diff suppressed because it is too large
Load Diff
BIN
fusion_accounting/static/description/icon.png
Normal file
BIN
fusion_accounting/static/description/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
'name': 'Fusion Accounting AI',
|
||||
'version': '19.0.1.0.0',
|
||||
'version': '19.0.1.0.1',
|
||||
'category': 'Accounting/Accounting',
|
||||
'sequence': 26,
|
||||
'summary': 'AI Co-Pilot for Odoo accounting (Claude/GPT) with conversational interface, dashboard, rules.',
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
from . import claude
|
||||
from . import openai_adapter
|
||||
from ._base import LLMProvider
|
||||
|
||||
44
fusion_accounting_ai/services/adapters/_base.py
Normal file
44
fusion_accounting_ai/services/adapters/_base.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""LLMProvider contract - every adapter must conform.
|
||||
|
||||
Phase 1 generalisation: makes local LLM (Ollama, LM Studio, vLLM, llamafile,
|
||||
llama.cpp HTTP server) a one-config-line drop-in via the OpenAI-compatible
|
||||
HTTP API surface that all of them expose.
|
||||
"""
|
||||
|
||||
|
||||
class LLMProvider:
|
||||
"""Contract every LLM backend must satisfy. Adapters declare capabilities
|
||||
as class attributes; the engine inspects them before calling optional methods."""
|
||||
|
||||
supports_tool_calling: bool = False
|
||||
supports_streaming: bool = False
|
||||
max_context_tokens: int = 4096
|
||||
supports_embeddings: bool = False
|
||||
|
||||
def __init__(self, env):
|
||||
self.env = env
|
||||
|
||||
def complete(self, *, system, messages, max_tokens=2048, temperature=0.0) -> dict:
|
||||
"""Plain text completion. Required for ALL providers.
|
||||
|
||||
Returns: {'content': str, 'tokens_used': int, 'model': str}
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def complete_with_tools(self, *, system, messages, tools, max_tokens=2048) -> dict:
|
||||
"""Tool-calling completion. Optional - caller checks supports_tool_calling first.
|
||||
|
||||
Returns: {'content': str, 'tool_calls': [{'name': str, 'arguments': dict}], ...}
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"{type(self).__name__} does not support tool-calling. "
|
||||
f"Check supports_tool_calling before calling.")
|
||||
|
||||
def embed(self, texts: list[str]) -> list[list[float]]:
|
||||
"""Embeddings. Optional - caller checks supports_embeddings first.
|
||||
|
||||
Returns: list of float vectors, one per input text.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
f"{type(self).__name__} does not support embeddings. "
|
||||
f"Check supports_embeddings before calling.")
|
||||
@@ -4,6 +4,8 @@ import logging
|
||||
from odoo import models, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
from ._base import LLMProvider
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
@@ -12,6 +14,64 @@ except ImportError:
|
||||
anthropic_sdk = None
|
||||
|
||||
|
||||
class ClaudeAdapter(LLMProvider):
|
||||
"""Plain-Python LLMProvider implementation for Anthropic Claude.
|
||||
|
||||
Preserves all existing functionality (extended thinking, native tool_use
|
||||
blocks) used by the Odoo AbstractModel-based adapter -- this class is
|
||||
additive for the Phase 1 LLMProvider contract.
|
||||
"""
|
||||
|
||||
supports_tool_calling = True
|
||||
supports_streaming = True
|
||||
max_context_tokens = 200000
|
||||
supports_embeddings = False
|
||||
|
||||
def __init__(self, env):
|
||||
super().__init__(env)
|
||||
if anthropic_sdk is None:
|
||||
raise UserError(_("The 'anthropic' Python package is not installed."))
|
||||
ICP = env['ir.config_parameter'].sudo()
|
||||
try:
|
||||
api_key = env['fusion.api.service'].get_api_key(
|
||||
provider_type='anthropic',
|
||||
consumer='fusion_accounting',
|
||||
feature='chat_with_tools',
|
||||
)
|
||||
except Exception:
|
||||
api_key = ICP.get_param('fusion_accounting.anthropic_api_key', '')
|
||||
if not api_key:
|
||||
api_key = 'not-needed'
|
||||
self.client = anthropic_sdk.Anthropic(api_key=api_key)
|
||||
self.model = ICP.get_param(
|
||||
'fusion_accounting.claude_model', 'claude-sonnet-4-6')
|
||||
|
||||
def complete(self, *, system, messages, max_tokens=2048, temperature=0.0) -> dict:
|
||||
api_messages = [
|
||||
m for m in messages if m.get('role') in ('user', 'assistant')
|
||||
]
|
||||
try:
|
||||
response = self.client.messages.create(
|
||||
model=self.model,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
system=system,
|
||||
messages=api_messages,
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.error("Claude complete error: %s", e)
|
||||
raise UserError(_("Claude API error: %s", str(e)))
|
||||
text_parts = [b.text for b in response.content if getattr(b, 'type', None) == 'text']
|
||||
return {
|
||||
'content': '\n'.join(text_parts),
|
||||
'tokens_used': (
|
||||
getattr(response.usage, 'input_tokens', 0)
|
||||
+ getattr(response.usage, 'output_tokens', 0)
|
||||
),
|
||||
'model': self.model,
|
||||
}
|
||||
|
||||
|
||||
class FusionAccountingAdapterClaude(models.AbstractModel):
|
||||
_name = 'fusion.accounting.adapter.claude'
|
||||
_description = 'Claude AI Adapter'
|
||||
|
||||
@@ -4,6 +4,8 @@ import logging
|
||||
from odoo import models, api, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
from ._base import LLMProvider
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
try:
|
||||
@@ -12,6 +14,71 @@ except ImportError:
|
||||
OpenAI = None
|
||||
|
||||
|
||||
DEFAULT_OPENAI_BASE_URL = 'https://api.openai.com/v1'
|
||||
|
||||
|
||||
class OpenAIAdapter(LLMProvider):
|
||||
"""Plain-Python LLMProvider implementation backed by an OpenAI-compatible
|
||||
HTTP endpoint.
|
||||
|
||||
The OpenAI Python SDK speaks to any server that exposes the OpenAI
|
||||
Chat Completions surface: OpenAI itself, Ollama, LM Studio, vLLM,
|
||||
llamafile, llama.cpp HTTP server, etc. Configure the endpoint via
|
||||
the ``fusion_accounting.openai_base_url`` ir.config_parameter.
|
||||
"""
|
||||
|
||||
supports_tool_calling = True
|
||||
supports_streaming = True
|
||||
max_context_tokens = 128000
|
||||
supports_embeddings = True
|
||||
|
||||
def __init__(self, env):
|
||||
super().__init__(env)
|
||||
if OpenAI is None:
|
||||
raise UserError(_("The 'openai' Python package is not installed."))
|
||||
ICP = env['ir.config_parameter'].sudo()
|
||||
base_url = ICP.get_param(
|
||||
'fusion_accounting.openai_base_url', DEFAULT_OPENAI_BASE_URL,
|
||||
) or DEFAULT_OPENAI_BASE_URL
|
||||
try:
|
||||
api_key = env['fusion.api.service'].get_api_key(
|
||||
provider_type='openai',
|
||||
consumer='fusion_accounting',
|
||||
feature='chat_with_tools',
|
||||
)
|
||||
except Exception:
|
||||
api_key = ICP.get_param('fusion_accounting.openai_api_key', '')
|
||||
if not api_key:
|
||||
# Local LLM servers (Ollama, LM Studio, llama.cpp) usually do not
|
||||
# require a real key but the SDK insists on a non-empty string.
|
||||
api_key = 'not-needed'
|
||||
self.base_url = base_url
|
||||
self.client = OpenAI(api_key=api_key, base_url=base_url)
|
||||
self.model = ICP.get_param('fusion_accounting.openai_model', 'gpt-5.4-mini')
|
||||
|
||||
def complete(self, *, system, messages, max_tokens=2048, temperature=0.0) -> dict:
|
||||
api_messages = [{'role': 'system', 'content': system}]
|
||||
for msg in messages:
|
||||
if msg.get('role') in ('user', 'assistant', 'tool'):
|
||||
api_messages.append(msg)
|
||||
try:
|
||||
response = self.client.chat.completions.create(
|
||||
model=self.model,
|
||||
messages=api_messages,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.error("OpenAI complete error: %s", e)
|
||||
raise UserError(_("OpenAI API error: %s", str(e)))
|
||||
choice = response.choices[0]
|
||||
return {
|
||||
'content': choice.message.content or '',
|
||||
'tokens_used': getattr(response.usage, 'total_tokens', 0),
|
||||
'model': self.model,
|
||||
}
|
||||
|
||||
|
||||
class FusionAccountingAdapterOpenAI(models.AbstractModel):
|
||||
_name = 'fusion.accounting.adapter.openai'
|
||||
_description = 'OpenAI AI Adapter'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -4,6 +4,12 @@ Routes bank-rec data lookups across:
|
||||
- FUSION: fusion.bank.rec.widget (added by fusion_accounting_bank_rec, Phase 1)
|
||||
- ENTERPRISE: account_accountant's bank_rec_widget JS service
|
||||
- COMMUNITY: pure search on account.bank.statement.line
|
||||
|
||||
In addition to ``list_unreconciled``, the adapter exposes thin wrappers
|
||||
around the engine's public API: ``suggest_matches``, ``accept_suggestion``,
|
||||
``unreconcile``. AI tools and the OWL controller go through these wrappers
|
||||
instead of touching the engine directly so install-mode routing stays in
|
||||
one place.
|
||||
"""
|
||||
|
||||
from .base import DataAdapter
|
||||
@@ -14,6 +20,10 @@ class BankRecAdapter(DataAdapter):
|
||||
FUSION_MODEL = 'fusion.bank.rec.widget'
|
||||
ENTERPRISE_MODULE = 'account_accountant'
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# list_unreconciled
|
||||
# ------------------------------------------------------------
|
||||
|
||||
def list_unreconciled(self, journal_id=None, limit=100, date_from=None,
|
||||
date_to=None, min_amount=None, company_id=None):
|
||||
"""Return unreconciled bank statement lines.
|
||||
@@ -31,13 +41,29 @@ class BankRecAdapter(DataAdapter):
|
||||
def list_unreconciled_via_fusion(self, journal_id=None, limit=100,
|
||||
date_from=None, date_to=None,
|
||||
min_amount=None, company_id=None):
|
||||
# Phase 1 will add fusion.bank.rec.widget; this method becomes the primary path.
|
||||
# For now: even when the model exists, delegate to community read shape.
|
||||
return self.list_unreconciled_via_community(
|
||||
"""Community shape + fusion AI fields (top suggestion, band, attachments)."""
|
||||
base = self.list_unreconciled_via_community(
|
||||
journal_id=journal_id, limit=limit,
|
||||
date_from=date_from, date_to=date_to,
|
||||
min_amount=min_amount, company_id=company_id,
|
||||
)
|
||||
if not base:
|
||||
return base
|
||||
Line = self.env['account.bank.statement.line'].sudo()
|
||||
ids = [row['id'] for row in base]
|
||||
lines_by_id = {line.id: line for line in Line.browse(ids)}
|
||||
for row in base:
|
||||
line = lines_by_id.get(row['id'])
|
||||
if not line:
|
||||
row['fusion_top_suggestion_id'] = None
|
||||
row['fusion_confidence_band'] = 'none'
|
||||
row['attachment_count'] = 0
|
||||
continue
|
||||
top = line.fusion_top_suggestion_id
|
||||
row['fusion_top_suggestion_id'] = top.id if top else None
|
||||
row['fusion_confidence_band'] = line.fusion_confidence_band or 'none'
|
||||
row['attachment_count'] = len(line.bank_statement_attachment_ids)
|
||||
return base
|
||||
|
||||
def list_unreconciled_via_enterprise(self, journal_id=None, limit=100,
|
||||
date_from=None, date_to=None,
|
||||
@@ -83,5 +109,121 @@ class BankRecAdapter(DataAdapter):
|
||||
for r in records
|
||||
]
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# suggest_matches
|
||||
# ------------------------------------------------------------
|
||||
|
||||
def suggest_matches(self, statement_line_ids, *, limit_per_line=3,
|
||||
company_id=None):
|
||||
"""Return AI suggestions per bank line.
|
||||
|
||||
Shape: ``{line_id: [{'id', 'rank', 'confidence', 'reasoning',
|
||||
'candidate_id'}, ...]}``. Empty dict when AI suggestions are not
|
||||
available (Enterprise / Community).
|
||||
"""
|
||||
return self._dispatch(
|
||||
'suggest_matches',
|
||||
statement_line_ids=statement_line_ids,
|
||||
limit_per_line=limit_per_line,
|
||||
company_id=company_id,
|
||||
)
|
||||
|
||||
def suggest_matches_via_fusion(self, statement_line_ids, *,
|
||||
limit_per_line=3, company_id=None):
|
||||
Line = self.env['account.bank.statement.line'].sudo()
|
||||
lines = Line.browse(list(statement_line_ids or [])).exists()
|
||||
if not lines:
|
||||
return {}
|
||||
return self.env['fusion.reconcile.engine'].suggest_matches(
|
||||
lines, limit_per_line=limit_per_line)
|
||||
|
||||
def suggest_matches_via_enterprise(self, statement_line_ids, *,
|
||||
limit_per_line=3, company_id=None):
|
||||
# Enterprise has its own suggest mechanism inside bank_rec_widget;
|
||||
# we don't proxy it from Python.
|
||||
return {}
|
||||
|
||||
def suggest_matches_via_community(self, statement_line_ids, *,
|
||||
limit_per_line=3, company_id=None):
|
||||
return {}
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# accept_suggestion
|
||||
# ------------------------------------------------------------
|
||||
|
||||
def accept_suggestion(self, suggestion_id):
|
||||
"""Accept a fusion AI suggestion and reconcile against its proposal.
|
||||
|
||||
Returns ``{'partial_ids': [...], 'exchange_diff_move_id': int|None,
|
||||
'write_off_move_id': int|None}``. Fusion-only.
|
||||
"""
|
||||
return self._dispatch(
|
||||
'accept_suggestion', suggestion_id=suggestion_id)
|
||||
|
||||
def accept_suggestion_via_fusion(self, suggestion_id):
|
||||
return self.env['fusion.reconcile.engine'].accept_suggestion(
|
||||
int(suggestion_id))
|
||||
|
||||
def accept_suggestion_via_enterprise(self, suggestion_id):
|
||||
raise NotImplementedError("accept_suggestion is fusion-only")
|
||||
|
||||
def accept_suggestion_via_community(self, suggestion_id):
|
||||
raise NotImplementedError("accept_suggestion is fusion-only")
|
||||
|
||||
# ------------------------------------------------------------
|
||||
# unreconcile
|
||||
# ------------------------------------------------------------
|
||||
|
||||
def unreconcile(self, partial_reconcile_ids):
|
||||
"""Reverse a reconciliation by partial IDs.
|
||||
|
||||
Returns ``{'unreconciled_line_ids': [...]}``. Available in all modes
|
||||
(the engine delegates to V19's standard
|
||||
``account.bank.statement.line.action_undo_reconciliation``).
|
||||
"""
|
||||
return self._dispatch(
|
||||
'unreconcile', partial_reconcile_ids=partial_reconcile_ids)
|
||||
|
||||
def unreconcile_via_fusion(self, partial_reconcile_ids):
|
||||
Partial = self.env['account.partial.reconcile'].sudo()
|
||||
partials = Partial.browse(list(partial_reconcile_ids or [])).exists()
|
||||
return self.env['fusion.reconcile.engine'].unreconcile(partials)
|
||||
|
||||
def unreconcile_via_enterprise(self, partial_reconcile_ids):
|
||||
# Enterprise/community paths can't depend on fusion.reconcile.engine
|
||||
# being loaded (fusion_accounting_ai does NOT depend on
|
||||
# fusion_accounting_bank_rec). Mirror the engine's behaviour using
|
||||
# only Community-available helpers.
|
||||
return self._unreconcile_standalone(partial_reconcile_ids)
|
||||
|
||||
def unreconcile_via_community(self, partial_reconcile_ids):
|
||||
return self._unreconcile_standalone(partial_reconcile_ids)
|
||||
|
||||
def _unreconcile_standalone(self, partial_reconcile_ids):
|
||||
"""Engine-free unreconcile for installs without fusion_accounting_bank_rec.
|
||||
|
||||
Mirrors ``fusion.reconcile.engine.unreconcile``: finds bank lines whose
|
||||
moves own any of the partials' journal items, runs the standard undo
|
||||
on them, then unlinks any leftovers.
|
||||
"""
|
||||
Partial = self.env['account.partial.reconcile'].sudo()
|
||||
partials = Partial.browse(list(partial_reconcile_ids or [])).exists()
|
||||
if not partials:
|
||||
return {'unreconciled_line_ids': []}
|
||||
all_lines = (
|
||||
partials.mapped('debit_move_id')
|
||||
| partials.mapped('credit_move_id')
|
||||
)
|
||||
line_ids = all_lines.ids
|
||||
affected = self.env['account.bank.statement.line'].sudo().search([
|
||||
('move_id', 'in', all_lines.mapped('move_id').ids),
|
||||
])
|
||||
if affected:
|
||||
affected.action_undo_reconciliation()
|
||||
remaining = partials.exists()
|
||||
if remaining:
|
||||
remaining.unlink()
|
||||
return {'unreconciled_line_ids': line_ids}
|
||||
|
||||
|
||||
register_adapter('bank_rec', BankRecAdapter)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -1,2 +1,3 @@
|
||||
from . import system_prompt
|
||||
from . import domain_prompts
|
||||
from . import bank_rec_prompt
|
||||
|
||||
107
fusion_accounting_ai/services/prompts/bank_rec_prompt.py
Normal file
107
fusion_accounting_ai/services/prompts/bank_rec_prompt.py
Normal file
@@ -0,0 +1,107 @@
|
||||
"""Bank reconciliation AI re-rank prompt.
|
||||
|
||||
Used by fusion_accounting_bank_rec/services/confidence_scoring.py to ask
|
||||
an LLM to refine the statistical ranking of candidate matches.
|
||||
|
||||
Output contract: the LLM MUST respond with valid JSON of shape:
|
||||
{"ranked": [{"candidate_id": int, "confidence": float, "reason": str}, ...]}
|
||||
|
||||
System prompt is provider-agnostic - works with OpenAI Chat Completions,
|
||||
Claude Messages, and local OpenAI-compatible servers (LM Studio, Ollama).
|
||||
"""
|
||||
|
||||
from datetime import date
|
||||
|
||||
|
||||
SYSTEM_PROMPT = """You are an expert accountant assisting with bank reconciliation.
|
||||
|
||||
Your job: given a bank statement line and a list of candidate journal items
|
||||
that statistically scored well as potential matches, re-rank them based on
|
||||
domain expertise. Consider:
|
||||
|
||||
1. **Amount-exact matches** are almost always correct unless the partner is wrong.
|
||||
2. **Memo / reference clues** - bank memos often contain invoice numbers, partner
|
||||
names, or transaction references that disambiguate matches.
|
||||
3. **Date proximity** - invoices are typically reconciled within 30 days of issue.
|
||||
4. **Pattern conformance** - if the partner has a learned pattern (e.g. "always
|
||||
pays exact amount, weekly cadence"), favor candidates that fit that pattern.
|
||||
5. **Precedent similarity** - if a near-identical reconcile happened before,
|
||||
it's likely the right one.
|
||||
|
||||
Return ONLY valid JSON of this exact shape:
|
||||
{
|
||||
"ranked": [
|
||||
{"candidate_id": <int>, "confidence": <float 0-1>, "reason": "<short string>"},
|
||||
...
|
||||
]
|
||||
}
|
||||
|
||||
Do NOT include any prose before or after the JSON. Do NOT use markdown code fences.
|
||||
The "ranked" array MUST contain every candidate_id from the input, in your
|
||||
preferred order (highest confidence first).
|
||||
"""
|
||||
|
||||
|
||||
def build_prompt(statement_line, scored_candidates, pattern=None, precedents=None):
|
||||
"""Build (system_prompt, user_prompt) for AI re-rank.
|
||||
|
||||
Args:
|
||||
statement_line: account.bank.statement.line recordset (singleton)
|
||||
scored_candidates: list of ScoredCandidate dataclasses (from confidence_scoring)
|
||||
pattern: fusion.reconcile.pattern recordset for the partner, or None
|
||||
precedents: list of PrecedentMatch dataclasses, or None
|
||||
|
||||
Returns:
|
||||
(system_prompt: str, user_prompt: str) tuple
|
||||
"""
|
||||
user_parts = []
|
||||
|
||||
user_parts.append("BANK LINE:")
|
||||
user_parts.append(f" Date: {statement_line.date}")
|
||||
user_parts.append(
|
||||
f" Amount: {statement_line.amount} {statement_line.currency_id.name or ''}"
|
||||
)
|
||||
user_parts.append(
|
||||
f" Memo / payment ref: {statement_line.payment_ref or '(none)'}"
|
||||
)
|
||||
if statement_line.partner_id:
|
||||
user_parts.append(f" Partner: {statement_line.partner_id.name}")
|
||||
|
||||
if pattern:
|
||||
user_parts.append("")
|
||||
user_parts.append("PARTNER PATTERN (learned from past reconciles):")
|
||||
user_parts.append(f" Reconcile count: {pattern.reconcile_count}")
|
||||
user_parts.append(f" Preferred strategy: {pattern.pref_strategy}")
|
||||
user_parts.append(
|
||||
f" Typical cadence: ~{pattern.typical_cadence_days} days between reconciles"
|
||||
)
|
||||
if pattern.typical_amount_range:
|
||||
user_parts.append(f" Typical amount range: {pattern.typical_amount_range}")
|
||||
if pattern.common_memo_tokens:
|
||||
user_parts.append(f" Common memo tokens: {pattern.common_memo_tokens}")
|
||||
|
||||
if precedents:
|
||||
user_parts.append("")
|
||||
user_parts.append("RECENT PRECEDENTS (most-similar past reconciles for this partner):")
|
||||
# Cap at 3 precedents to keep prompt small and reduce token cost.
|
||||
for p in precedents[:3]:
|
||||
user_parts.append(
|
||||
f" - amount={p.amount}, similarity={p.similarity_score:.2f}, "
|
||||
f"matched {p.matched_move_line_count} line(s), tokens={p.memo_tokens}"
|
||||
)
|
||||
|
||||
user_parts.append("")
|
||||
user_parts.append("CANDIDATES (scored by statistical pipeline):")
|
||||
for s in scored_candidates:
|
||||
user_parts.append(
|
||||
f" - candidate_id={s.candidate_id}, statistical_confidence={s.confidence}, "
|
||||
f"amount_match={s.score_amount_match}, pattern_fit={s.score_partner_pattern}, "
|
||||
f"precedent_sim={s.score_precedent_similarity}, "
|
||||
f"reason=\"{s.reasoning}\""
|
||||
)
|
||||
|
||||
user_parts.append("")
|
||||
user_parts.append("Re-rank these candidates and return JSON per the system prompt.")
|
||||
|
||||
user_prompt = "\n".join(user_parts)
|
||||
return (SYSTEM_PROMPT, user_prompt)
|
||||
@@ -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,
|
||||
}
|
||||
@@ -67,7 +67,16 @@ def match_bank_line_to_payments(env, params):
|
||||
st_line = env['account.bank.statement.line'].browse(st_line_id)
|
||||
if not st_line.exists():
|
||||
return {'error': 'Statement line not found'}
|
||||
st_line.set_line_bank_statement_line(move_line_ids)
|
||||
# Phase 1 Task 23: route through engine when available
|
||||
if 'fusion.reconcile.engine' in env.registry:
|
||||
cands = env['account.move.line'].browse(move_line_ids).exists()
|
||||
if not cands:
|
||||
return {'error': 'No valid move_line_ids'}
|
||||
env['fusion.reconcile.engine'].reconcile_one(
|
||||
st_line, against_lines=cands)
|
||||
st_line.invalidate_recordset(['is_reconciled'])
|
||||
else:
|
||||
st_line.set_line_bank_statement_line(move_line_ids)
|
||||
return {
|
||||
'status': 'matched',
|
||||
'statement_line_id': st_line_id,
|
||||
@@ -83,7 +92,12 @@ def auto_reconcile_bank_lines(env, params):
|
||||
('company_id', '=', int(company_id)),
|
||||
])
|
||||
before_count = len(lines)
|
||||
lines._try_auto_reconcile_statement_lines(company_id=int(company_id))
|
||||
# Phase 1 Task 23: route through engine when available
|
||||
if 'fusion.reconcile.engine' in env.registry:
|
||||
env['fusion.reconcile.engine'].reconcile_batch(
|
||||
lines, strategy='auto')
|
||||
else:
|
||||
lines._try_auto_reconcile_statement_lines(company_id=int(company_id))
|
||||
still_unreconciled = env['account.bank.statement.line'].search([
|
||||
('is_reconciled', '=', False),
|
||||
('company_id', '=', int(company_id)),
|
||||
@@ -946,6 +960,171 @@ def _format_aml_candidates(amls):
|
||||
} for aml in amls]
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Phase 1 Bank Reconciliation: engine-backed tools
|
||||
#
|
||||
# These five tools wrap the fusion.reconcile.engine 6-method API via the
|
||||
# bank_rec data adapter (or the engine directly when the adapter does not
|
||||
# expose a wrapper). They give the AI chat the same reconciliation surface
|
||||
# a human gets in the OWL bank-rec UI.
|
||||
# ============================================================
|
||||
|
||||
|
||||
def fusion_suggest_matches(env, params):
|
||||
"""Compute and persist AI suggestions for one or more bank statement lines.
|
||||
|
||||
Wraps ``BankRecAdapter.suggest_matches`` -> ``fusion.reconcile.engine``.
|
||||
"""
|
||||
raw_ids = params.get('statement_line_ids')
|
||||
if not raw_ids:
|
||||
return {'error': 'statement_line_ids is required'}
|
||||
statement_line_ids = [int(x) for x in raw_ids]
|
||||
limit_per_line = int(params.get('limit_per_line', 3))
|
||||
|
||||
from ..data_adapters import get_adapter
|
||||
adapter = get_adapter(env, 'bank_rec')
|
||||
raw = adapter.suggest_matches(
|
||||
statement_line_ids=statement_line_ids,
|
||||
limit_per_line=limit_per_line,
|
||||
company_id=env.company.id,
|
||||
) or {}
|
||||
|
||||
suggestions = {}
|
||||
total = 0
|
||||
for line_id, sug_list in raw.items():
|
||||
out = []
|
||||
for s in sug_list:
|
||||
out.append({
|
||||
'suggestion_id': s.get('id'),
|
||||
'candidate_id': s.get('candidate_id'),
|
||||
'confidence': s.get('confidence'),
|
||||
'reasoning': s.get('reasoning') or '',
|
||||
'rank': s.get('rank'),
|
||||
})
|
||||
total += 1
|
||||
suggestions[line_id] = out
|
||||
return {'suggestions': suggestions, 'count': total}
|
||||
|
||||
|
||||
def fusion_accept_suggestion(env, params):
|
||||
"""Accept a fusion.reconcile.suggestion: reconciles the bank line against
|
||||
the suggestion's proposed move lines and marks the suggestion accepted.
|
||||
|
||||
Wraps ``BankRecAdapter.accept_suggestion``.
|
||||
"""
|
||||
if not params.get('suggestion_id'):
|
||||
return {'error': 'suggestion_id is required'}
|
||||
suggestion_id = int(params['suggestion_id'])
|
||||
suggestion = env['fusion.reconcile.suggestion'].browse(suggestion_id)
|
||||
if not suggestion.exists():
|
||||
return {'error': 'Suggestion not found'}
|
||||
|
||||
from ..data_adapters import get_adapter
|
||||
adapter = get_adapter(env, 'bank_rec')
|
||||
result = adapter.accept_suggestion(suggestion_id) or {}
|
||||
statement_line = suggestion.statement_line_id
|
||||
return {
|
||||
'status': 'accepted',
|
||||
'suggestion_id': suggestion_id,
|
||||
'partial_ids': list(result.get('partial_ids') or []),
|
||||
'is_reconciled': bool(statement_line.is_reconciled),
|
||||
}
|
||||
|
||||
|
||||
def fusion_reconcile_bank_line(env, params):
|
||||
"""Manually reconcile a bank statement line against a set of journal items.
|
||||
|
||||
Routes through ``fusion.reconcile.engine.reconcile_one`` so behaviour
|
||||
matches the OWL widget and ``fusion_accept_suggestion``. Use this for
|
||||
direct AI-initiated matches that did not come from an AI suggestion.
|
||||
"""
|
||||
if not params.get('statement_line_id'):
|
||||
return {'error': 'statement_line_id is required'}
|
||||
raw_against = params.get('against_move_line_ids')
|
||||
if not raw_against:
|
||||
return {'error': 'against_move_line_ids is required'}
|
||||
|
||||
st_line_id = int(params['statement_line_id'])
|
||||
aml_ids = [int(x) for x in raw_against]
|
||||
statement_line = env['account.bank.statement.line'].browse(st_line_id)
|
||||
if not statement_line.exists():
|
||||
return {'error': 'Statement line not found'}
|
||||
against_lines = env['account.move.line'].browse(aml_ids).exists()
|
||||
if not against_lines:
|
||||
return {'error': 'No valid against_move_line_ids'}
|
||||
|
||||
result = env['fusion.reconcile.engine'].reconcile_one(
|
||||
statement_line, against_lines=against_lines)
|
||||
return {
|
||||
'status': 'reconciled',
|
||||
'statement_line_id': st_line_id,
|
||||
'partial_ids': list(result.get('partial_ids') or []),
|
||||
'is_reconciled': bool(statement_line.is_reconciled),
|
||||
}
|
||||
|
||||
|
||||
def fusion_unreconcile(env, params):
|
||||
"""Reverse a reconciliation by partial_reconcile_ids.
|
||||
|
||||
Wraps ``BankRecAdapter.unreconcile``. Works in fusion, Enterprise, and
|
||||
Community installs (the adapter falls back to a standalone path when
|
||||
fusion_accounting_bank_rec is not loaded).
|
||||
"""
|
||||
raw_ids = params.get('partial_reconcile_ids')
|
||||
if not raw_ids:
|
||||
return {'error': 'partial_reconcile_ids is required'}
|
||||
partial_ids = [int(x) for x in raw_ids]
|
||||
|
||||
from ..data_adapters import get_adapter
|
||||
adapter = get_adapter(env, 'bank_rec')
|
||||
result = adapter.unreconcile(partial_ids) or {}
|
||||
unreconciled_line_ids = list(result.get('unreconciled_line_ids') or [])
|
||||
return {
|
||||
'status': 'unreconciled',
|
||||
'unreconciled_line_ids': unreconciled_line_ids,
|
||||
'count': len(unreconciled_line_ids),
|
||||
}
|
||||
|
||||
|
||||
def fusion_get_pending_suggestions(env, params):
|
||||
"""List pending fusion.reconcile.suggestion rows.
|
||||
|
||||
Optional filters: ``statement_line_id``, ``min_confidence`` (default 0.0),
|
||||
``limit`` (default 50). Only returns suggestions in the ``pending`` state
|
||||
for the current company.
|
||||
"""
|
||||
domain = [
|
||||
('company_id', '=', env.company.id),
|
||||
('state', '=', 'pending'),
|
||||
]
|
||||
if params.get('statement_line_id'):
|
||||
domain.append(
|
||||
('statement_line_id', '=', int(params['statement_line_id'])))
|
||||
min_confidence = float(params.get('min_confidence') or 0.0)
|
||||
if min_confidence > 0.0:
|
||||
domain.append(('confidence', '>=', min_confidence))
|
||||
limit = int(params.get('limit', 50))
|
||||
|
||||
Suggestion = env['fusion.reconcile.suggestion'].sudo()
|
||||
records = Suggestion.search(
|
||||
domain, limit=limit, order='confidence desc, id desc')
|
||||
rows = []
|
||||
for s in records:
|
||||
st_line = s.statement_line_id
|
||||
rows.append({
|
||||
'id': s.id,
|
||||
'statement_line_id': st_line.id if st_line else None,
|
||||
'statement_line_ref': (
|
||||
st_line.payment_ref or '' if st_line else ''),
|
||||
'candidate_ids': s.proposed_move_line_ids.ids,
|
||||
'confidence': s.confidence,
|
||||
'rank': s.rank,
|
||||
'reasoning': s.reasoning or '',
|
||||
'state': s.state,
|
||||
})
|
||||
return {'count': len(rows), 'suggestions': rows}
|
||||
|
||||
|
||||
TOOLS = {
|
||||
'get_unreconciled_bank_lines': get_unreconciled_bank_lines,
|
||||
'get_unreconciled_receipts': get_unreconciled_receipts,
|
||||
@@ -962,4 +1141,10 @@ TOOLS = {
|
||||
'reconcile_payroll_cheques': reconcile_payroll_cheques,
|
||||
'suggest_bank_line_matches': suggest_bank_line_matches,
|
||||
'search_matching_entries': search_matching_entries,
|
||||
# Phase 1 engine-backed tools
|
||||
'fusion_suggest_matches': fusion_suggest_matches,
|
||||
'fusion_accept_suggestion': fusion_accept_suggestion,
|
||||
'fusion_reconcile_bank_line': fusion_reconcile_bank_line,
|
||||
'fusion_unreconcile': fusion_unreconcile,
|
||||
'fusion_get_pending_suggestions': fusion_get_pending_suggestions,
|
||||
}
|
||||
|
||||
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,
|
||||
}
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 72 KiB |
@@ -1,2 +1,3 @@
|
||||
from . import test_post_migration
|
||||
from . import test_data_adapters
|
||||
from . import test_llm_provider_contract
|
||||
|
||||
45
fusion_accounting_ai/tests/test_llm_provider_contract.py
Normal file
45
fusion_accounting_ai/tests/test_llm_provider_contract.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from odoo.tests.common import TransactionCase, tagged
|
||||
from odoo.addons.fusion_accounting_ai.services.adapters._base import LLMProvider
|
||||
|
||||
|
||||
@tagged('post_install', '-at_install')
|
||||
class TestLLMProviderContract(TransactionCase):
|
||||
"""Every LLM adapter must satisfy the LLMProvider contract."""
|
||||
|
||||
def test_base_class_defines_capability_attrs(self):
|
||||
self.assertTrue(hasattr(LLMProvider, 'supports_tool_calling'))
|
||||
self.assertTrue(hasattr(LLMProvider, 'supports_streaming'))
|
||||
self.assertTrue(hasattr(LLMProvider, 'max_context_tokens'))
|
||||
self.assertTrue(hasattr(LLMProvider, 'supports_embeddings'))
|
||||
|
||||
def test_openai_adapter_implements_contract(self):
|
||||
from odoo.addons.fusion_accounting_ai.services.adapters.openai_adapter import OpenAIAdapter
|
||||
self.assertTrue(issubclass(OpenAIAdapter, LLMProvider))
|
||||
adapter = OpenAIAdapter(self.env)
|
||||
self.assertIsInstance(adapter.supports_tool_calling, bool)
|
||||
self.assertIsInstance(adapter.max_context_tokens, int)
|
||||
|
||||
def test_claude_adapter_implements_contract(self):
|
||||
from odoo.addons.fusion_accounting_ai.services.adapters.claude import ClaudeAdapter
|
||||
self.assertTrue(issubclass(ClaudeAdapter, LLMProvider))
|
||||
adapter = ClaudeAdapter(self.env)
|
||||
self.assertIsInstance(adapter.supports_tool_calling, bool)
|
||||
self.assertIsInstance(adapter.max_context_tokens, int)
|
||||
|
||||
def test_openai_adapter_uses_configurable_base_url(self):
|
||||
self.env['ir.config_parameter'].sudo().set_param(
|
||||
'fusion_accounting.openai_base_url', 'http://localhost:1234/v1')
|
||||
self.env['ir.config_parameter'].sudo().set_param(
|
||||
'fusion_accounting.openai_api_key', 'lm-studio-test-key')
|
||||
from odoo.addons.fusion_accounting_ai.services.adapters.openai_adapter import OpenAIAdapter
|
||||
adapter = OpenAIAdapter(self.env)
|
||||
self.assertEqual(str(adapter.client.base_url).rstrip('/'),
|
||||
'http://localhost:1234/v1')
|
||||
|
||||
def test_openai_adapter_default_base_url_when_unset(self):
|
||||
self.env['ir.config_parameter'].sudo().search([
|
||||
('key', '=', 'fusion_accounting.openai_base_url')
|
||||
]).unlink()
|
||||
from odoo.addons.fusion_accounting_ai.services.adapters.openai_adapter import OpenAIAdapter
|
||||
adapter = OpenAIAdapter(self.env)
|
||||
self.assertIn('api.openai.com', str(adapter.client.base_url))
|
||||
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
103
fusion_accounting_bank_rec/CLAUDE.md
Normal file
103
fusion_accounting_bank_rec/CLAUDE.md
Normal file
@@ -0,0 +1,103 @@
|
||||
# fusion_accounting_bank_rec — Cursor / Claude Context
|
||||
|
||||
## Purpose
|
||||
|
||||
Replaces (or augments — coexists with) Odoo Enterprise's `account_accountant`
|
||||
bank reconciliation widget with a Fusion-native, AI-assistive implementation.
|
||||
Ships in Phase 1 of the fusion_accounting roadmap.
|
||||
|
||||
## Architecture
|
||||
|
||||
Hybrid: the engine (`fusion.reconcile.engine`, AbstractModel) is the SINGLE
|
||||
write surface for reconciliations. Everything else (controller, OWL widget,
|
||||
AI tools, wizards, cron) routes through the engine's 6-method API:
|
||||
|
||||
- `reconcile_one(line, against_lines, write_off_vals=None)`
|
||||
- `reconcile_batch(lines, strategy='auto')`
|
||||
- `suggest_matches(lines, limit_per_line=3)`
|
||||
- `accept_suggestion(suggestion)`
|
||||
- `write_off(line, account, amount, label, tax_id=None)`
|
||||
- `unreconcile(partial_reconciles)`
|
||||
|
||||
Pure-Python services live in `services/`:
|
||||
- `memo_tokenizer` — Canadian bank memo regex
|
||||
- `exchange_diff` — FX gain/loss pre-compute
|
||||
- `matching_strategies` — AmountExact, FIFO, MultiInvoice
|
||||
- `precedent_lookup` — K-nearest search
|
||||
- `pattern_extractor` — per-partner aggregate
|
||||
- `confidence_scoring` — 4-pass pipeline (statistical → AI re-rank)
|
||||
- `precedent_backfill` — migration helper
|
||||
|
||||
Persistent models in `models/`:
|
||||
- `fusion.reconcile.pattern` — per-(company, partner) learned profile
|
||||
- `fusion.reconcile.precedent` — per-decision history
|
||||
- `fusion.reconcile.suggestion` — AI suggestions with state lifecycle
|
||||
- `fusion.bank.rec.widget` — TransientModel for OWL round-trip
|
||||
- `fusion.unreconciled.bank.line.mv` — pre-aggregated MV for fast UI listing
|
||||
- `fusion.bank.rec.cron` — cron handler (suggest, pattern refresh, MV refresh)
|
||||
- `fusion.auto.reconcile.wizard` / `fusion.bulk.reconcile.wizard` — TransientModel wizards
|
||||
- `fusion.migration.wizard` (inherits) — adds `_bank_rec_bootstrap_step`
|
||||
- `account.bank.statement.line` (inherits) — adds fusion_top_suggestion_id, fusion_confidence_band, etc.
|
||||
- `account.reconcile.model` (inherits) — adds fusion_ai_confidence_threshold
|
||||
|
||||
Controller: `controllers/bank_rec_controller.py` exposes 10 JSON-RPC endpoints
|
||||
under `/fusion/bank_rec/*`. All calls route through the engine.
|
||||
|
||||
OWL frontend: `static/src/`
|
||||
- `services/bank_reconciliation_service.js` — central reactive state + RPC wrappers
|
||||
- `views/kanban/bank_rec_kanban_*.js` — top-level controller + renderer
|
||||
- `components/bank_reconciliation/<...>` — 14 mirrored Enterprise components + 8 fusion-only components (ai_suggestion folder, batch_action_bar, reconcile_model_picker, attachment_strip, partner_history_panel)
|
||||
- `tours/bank_rec_tours.js` — 5 OWL tour smoke tests
|
||||
|
||||
## 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`).
|
||||
|
||||
- **Coexistence:** When `account_accountant` is installed, the fusion menu
|
||||
is hidden via `fusion_accounting_core.group_fusion_show_when_enterprise_absent`
|
||||
(a computed group). Engine model is always available.
|
||||
|
||||
- **Materialized view refresh:** Triggered on `fusion.reconcile.suggestion`
|
||||
create/write (best-effort, non-blocking). Cron refreshes every 5 min via
|
||||
a dedicated autocommit cursor (REFRESH CONCURRENTLY can't run inside
|
||||
Odoo's regular transaction).
|
||||
|
||||
- **Test factories:** `tests/_factories.py` provides `make_bank_journal`,
|
||||
`make_bank_line`, `make_invoice`, `make_reconcileable_pair`, `make_suggestion`,
|
||||
`make_pattern`, `make_precedent`. NOTE: `make_bank_journal` defaults to
|
||||
code `'TEST'` so multiple calls in one test will collide; pass an explicit
|
||||
unique code or share a journal across calls.
|
||||
|
||||
- **Hypothesis property tests:** Use `@settings(suppress_health_check=[...])`
|
||||
to silence function_scoped_fixture warnings in TransactionCase.
|
||||
|
||||
## Test counts (as of Phase 1 complete)
|
||||
|
||||
- 157 logical tests total in fusion_accounting_bank_rec
|
||||
- 0 failures, 0 errors
|
||||
- Includes: 4 benchmark tests (tagged 'benchmark'), 1 local LLM smoke (tagged 'local_llm', skips when no LLM), 5 OWL tour tests (tagged 'tour')
|
||||
|
||||
## Performance baseline
|
||||
|
||||
| Operation | P95 | Budget |
|
||||
|---|---|---|
|
||||
| `engine.suggest_matches` (1 line) | 234ms | <500ms |
|
||||
| `engine.reconcile_batch` (50 lines) | 3318ms | <5000ms |
|
||||
| `controller.list_unreconciled` (50 lines) | 77ms | <200ms |
|
||||
| MV refresh | 60ms | <2000ms |
|
||||
|
||||
All within 1x of budget at Phase 1 ship.
|
||||
|
||||
## Known concerns / Phase 1.5 backlog
|
||||
|
||||
- `accept_suggestion` returns `partial_ids` but not `is_reconciled` — UI reads it post-call
|
||||
- `engine.write_off` mixed mode (write-off + against_lines) implemented but untested
|
||||
- `engine.reconcile_one` returns `exchange_diff_move_id: None` (Odoo's reconcile() handles FX inline; surfacing the move_id needs an extra query)
|
||||
- `against_lines` early-break in `reconcile_one` silently drops excess; auto strategy avoids this but manual callers should pre-validate
|
||||
- Reconcile-model bulk wizard `_apply_lines_for_bank_statement_line` is Enterprise-only (Community falls back to per-line error)
|
||||
- OWL tour tests skip-mode when websocket-client absent
|
||||
41
fusion_accounting_bank_rec/README.md
Normal file
41
fusion_accounting_bank_rec/README.md
Normal file
@@ -0,0 +1,41 @@
|
||||
# fusion_accounting_bank_rec
|
||||
|
||||
AI-assisted bank reconciliation for Odoo 19 Community — a Fusion-native
|
||||
replacement for Enterprise's `account_accountant` bank reconciliation widget.
|
||||
|
||||
## What it does
|
||||
|
||||
- Side-by-side parity with Enterprise's bank reconciliation UI (kanban + side
|
||||
panel, multi-currency, write-offs, attachments, chatter)
|
||||
- AI-assistive: confidence-scored suggestions per bank line via the
|
||||
`fusion.reconcile.engine` 4-pass scoring pipeline (statistical + optional
|
||||
LLM re-rank)
|
||||
- Coexists with `account_accountant` (Enterprise wins by default; Fusion menu
|
||||
appears only when Enterprise is uninstalled)
|
||||
- Migration-aware: bootstrap step backfills `fusion.reconcile.precedent` from
|
||||
existing `account.partial.reconcile` rows so the AI has memory from day 1
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# Install
|
||||
odoo --addons-path=... -i fusion_accounting_bank_rec
|
||||
|
||||
# Open the widget (when Enterprise's account_accountant is NOT installed)
|
||||
# Apps → Bank Reconciliation → Reconcile Bank Lines
|
||||
|
||||
# When Enterprise IS installed: use Enterprise's UI; the engine + AI tools
|
||||
# are still available via the AI chat.
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
- Local LLM (LM Studio, Ollama):
|
||||
- `fusion_accounting.openai_base_url` = `http://host.docker.internal:1234/v1`
|
||||
- `fusion_accounting.openai_model` = your local model name
|
||||
- `fusion_accounting.provider.bank_rec_suggest` = `openai`
|
||||
|
||||
## See also
|
||||
|
||||
- `CLAUDE.md` — agent context
|
||||
- `UPGRADE_NOTES.md` — Odoo version anchoring
|
||||
34
fusion_accounting_bank_rec/UPGRADE_NOTES.md
Normal file
34
fusion_accounting_bank_rec/UPGRADE_NOTES.md
Normal file
@@ -0,0 +1,34 @@
|
||||
# fusion_accounting_bank_rec — Upgrade Notes
|
||||
|
||||
## Odoo Version Anchor
|
||||
|
||||
This module targets **Odoo 19.0** (community-base).
|
||||
|
||||
Reference snapshot of Enterprise code mirrored from:
|
||||
- `account_accountant` (Odoo 19.0.x)
|
||||
- Source: `/Users/gurpreet/Github/RePackaged-Odoo/accounting/account_accountant/`
|
||||
|
||||
## 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.bank.statement.line` API
|
||||
3. For mirrored OWL components, diff Enterprise's new versions against ours and
|
||||
port material changes (signature renames, new behaviour we want to inherit)
|
||||
4. Re-run the full test suite + tour tests against the new Odoo version
|
||||
5. Update this file with the new version anchor + any deviations
|
||||
|
||||
## V19 Migration Notes (already applied)
|
||||
|
||||
- `_sql_constraints` → `models.Constraint` (Tasks 14, 15)
|
||||
- `@api.depends('id')` → removed (Task 17)
|
||||
- `@route(type='json')` → `type='jsonrpc'` (Task 26)
|
||||
- `numbercall` removed from `ir.cron` (Task 25)
|
||||
- `res.groups.users` → `user_ids` (Task 43)
|
||||
- `ir.ui.menu.groups_id` → `group_ids` (Tasks 42, 43)
|
||||
|
||||
## Phase 1 → Phase 1.5 Migration
|
||||
|
||||
If we ship Phase 1.5 (UI polish, deferred features), changes will go in
|
||||
incremental commits. No DB migration needed (Phase 1 schema is forward-compatible).
|
||||
5
fusion_accounting_bank_rec/__init__.py
Normal file
5
fusion_accounting_bank_rec/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from . import models
|
||||
from . import controllers
|
||||
from . import services
|
||||
from . import wizards
|
||||
from . import reports
|
||||
113
fusion_accounting_bank_rec/__manifest__.py
Normal file
113
fusion_accounting_bank_rec/__manifest__.py
Normal file
@@ -0,0 +1,113 @@
|
||||
{
|
||||
'name': 'Fusion Accounting — Bank Reconciliation',
|
||||
'version': '19.0.1.0.26',
|
||||
'category': 'Accounting/Accounting',
|
||||
'sequence': 28,
|
||||
'summary': 'Native V19 bank reconciliation widget with AI confidence scoring + behavioural learning.',
|
||||
'description': """
|
||||
Fusion Accounting — Bank Reconciliation
|
||||
========================================
|
||||
Replaces Odoo Enterprise's account_accountant bank-rec widget with a
|
||||
native V19 OWL implementation reading/writing Community's
|
||||
account.partial.reconcile tables.
|
||||
|
||||
Features:
|
||||
- Strict mirror of all Enterprise UI components (zero functional loss)
|
||||
- AI confidence badges with one-click Accept and ranked alternatives
|
||||
- Behavioural learning from historical reconciliations
|
||||
- Local LLM ready (Ollama, LM Studio) via OpenAI-compatible adapter
|
||||
- Coexists with account_accountant (Enterprise wins by default)
|
||||
|
||||
Built by Nexa Systems Inc.
|
||||
""",
|
||||
'icon': '/fusion_accounting_bank_rec/static/description/icon.png',
|
||||
'author': 'Nexa Systems Inc.',
|
||||
'website': 'https://nexasystems.ca',
|
||||
'maintainer': 'Nexa Systems Inc.',
|
||||
'depends': ['fusion_accounting_core', 'fusion_accounting_migration'],
|
||||
'external_dependencies': {
|
||||
'python': ['hypothesis'],
|
||||
},
|
||||
'data': [
|
||||
'security/ir.model.access.csv',
|
||||
'data/cron.xml',
|
||||
'wizards/auto_reconcile_wizard_views.xml',
|
||||
'wizards/bulk_reconcile_wizard_views.xml',
|
||||
'reports/migration_audit_report_views.xml',
|
||||
'reports/migration_audit_report_action.xml',
|
||||
'views/menu_views.xml',
|
||||
],
|
||||
'assets': {
|
||||
'web.assets_backend': [
|
||||
'fusion_accounting_bank_rec/static/src/scss/_variables.scss',
|
||||
'fusion_accounting_bank_rec/static/src/scss/bank_reconciliation.scss',
|
||||
'fusion_accounting_bank_rec/static/src/scss/ai_suggestion.scss',
|
||||
'fusion_accounting_bank_rec/static/src/scss/dark_mode.scss',
|
||||
'fusion_accounting_bank_rec/static/src/services/bank_reconciliation_service.js',
|
||||
'fusion_accounting_bank_rec/static/src/views/kanban/bank_rec_kanban_controller.js',
|
||||
'fusion_accounting_bank_rec/static/src/views/kanban/bank_rec_kanban_renderer.js',
|
||||
'fusion_accounting_bank_rec/static/src/views/kanban/bank_rec_kanban_view.js',
|
||||
'fusion_accounting_bank_rec/static/src/views/kanban/bank_rec_kanban.xml',
|
||||
# OWL component mirror — Enterprise account_accountant bank-rec.
|
||||
# Re-export shim so mirrored components can use the relative
|
||||
# `../bank_reconciliation_service` import unchanged.
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/bank_reconciliation_service.js',
|
||||
# Batch 1 (Task 30) — display components
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/statement_line/statement_line.js',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/statement_line/statement_line.xml',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/statement_summary/statement_summary.js',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/statement_summary/statement_summary.xml',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/line_info_pop_over/line_info_pop_over.js',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/line_info_pop_over/line_info_pop_over.xml',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/reconciled_line_name/reconciled_line_name.js',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/reconciled_line_name/reconciled_line_name.xml',
|
||||
# Batch 2 (Task 31) — action + edit components
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/button/button.js',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/button/button.xml',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/button_list/button_list.js',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/button_list/button_list.xml',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/line_to_reconcile/line_to_reconcile.js',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/line_to_reconcile/line_to_reconcile.xml',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/list_view/list.js',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/list_view/list_view_many2one_multi_edit.js',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/list_view/list_view_many2one_multi_edit.xml',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/apply_amount/apply_amount.js',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/apply_amount/apply_amount.xml',
|
||||
# Batch 3 (Task 32) — dialog components
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/bankrec_form_dialog/bankrec_form_dialog.js',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/bankrec_form_dialog/bankrec_form_dialog.xml',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/search_dialog/search_dialog.js',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/search_dialog/search_dialog.xml',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/search_dialog/search_dialog_list.js',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/search_dialog/search_dialog_list.xml',
|
||||
# Batch 4 (Task 33) — auxiliary components
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/quick_create/quick_create.js',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/quick_create/quick_create.xml',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/chatter/chatter.js',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/file_uploader/file_uploader.js',
|
||||
# Fusion-only (Task 34) — AI suggestion UI
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/ai_suggestion/ai_suggestion_strip.js',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/ai_suggestion/ai_suggestion_strip.xml',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/ai_suggestion/ai_alternatives_panel.js',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/ai_suggestion/ai_alternatives_panel.xml',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/ai_suggestion/ai_reasoning_tooltip.js',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/ai_suggestion/ai_reasoning_tooltip.xml',
|
||||
# Fusion-only (Task 35) — batch action bar + reconcile model picker
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/batch_action_bar/batch_action_bar.js',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/batch_action_bar/batch_action_bar.xml',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/reconcile_model_picker/reconcile_model_picker.js',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/reconcile_model_picker/reconcile_model_picker.xml',
|
||||
# Fusion-only (Task 36) — attachment strip + partner history panel
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/attachment_strip/attachment_strip.js',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/attachment_strip/attachment_strip.xml',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/partner_history_panel/partner_history_panel.js',
|
||||
'fusion_accounting_bank_rec/static/src/components/bank_reconciliation/partner_history_panel/partner_history_panel.xml',
|
||||
],
|
||||
'web.assets_tests': [
|
||||
'fusion_accounting_bank_rec/static/src/tours/bank_rec_tours.js',
|
||||
],
|
||||
},
|
||||
'installable': True,
|
||||
'application': False,
|
||||
'license': 'OPL-1',
|
||||
}
|
||||
1
fusion_accounting_bank_rec/controllers/__init__.py
Normal file
1
fusion_accounting_bank_rec/controllers/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import bank_rec_controller
|
||||
325
fusion_accounting_bank_rec/controllers/bank_rec_controller.py
Normal file
325
fusion_accounting_bank_rec/controllers/bank_rec_controller.py
Normal file
@@ -0,0 +1,325 @@
|
||||
"""HTTP controller: 10 JSON-RPC endpoints for the OWL bank-rec widget.
|
||||
|
||||
All endpoints route through ``BankRecAdapter`` (which lives in
|
||||
``fusion_accounting_ai`` and already encapsulates fusion / enterprise /
|
||||
community routing) or directly through ``fusion.reconcile.engine`` for
|
||||
methods the adapter does not yet expose. The controller never touches
|
||||
``account.partial.reconcile`` directly.
|
||||
|
||||
V19: uses ``@route(type='jsonrpc')``, the V19-blessed replacement for the
|
||||
deprecated ``type='json'`` (Odoo 19 logs a deprecation warning if you
|
||||
still use ``json``).
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import _, http
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.http import request
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _adapter():
|
||||
"""Resolve the bank-rec data adapter from fusion_accounting_ai."""
|
||||
from odoo.addons.fusion_accounting_ai.services.data_adapters import (
|
||||
get_adapter,
|
||||
)
|
||||
return get_adapter(request.env, 'bank_rec')
|
||||
|
||||
|
||||
class FusionBankRecController(http.Controller):
|
||||
"""JSON-RPC surface consumed by the OWL bank-reconciliation widget.
|
||||
|
||||
All routes are ``auth='user'`` -- anonymous traffic is rejected by
|
||||
Odoo's HTTP layer before reaching the handler.
|
||||
"""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 1. get_state -- initial widget bootstrap
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@http.route('/fusion/bank_rec/get_state', type='jsonrpc', auth='user')
|
||||
def get_state(self, journal_id, company_id):
|
||||
"""Return the journal summary that seeds the kanban widget."""
|
||||
Journal = request.env['account.journal']
|
||||
Line = request.env['account.bank.statement.line']
|
||||
journal = Journal.browse(int(journal_id))
|
||||
if not journal.exists():
|
||||
raise ValidationError(_("Journal %s not found") % journal_id)
|
||||
company_id = int(company_id) if company_id else request.env.company.id
|
||||
unreconciled_lines = Line.search([
|
||||
('journal_id', '=', journal.id),
|
||||
('is_reconciled', '=', False),
|
||||
('company_id', '=', company_id),
|
||||
])
|
||||
total_amount = sum(abs(l.amount) for l in unreconciled_lines)
|
||||
last_stmt = request.env['account.bank.statement'].search(
|
||||
[('journal_id', '=', journal.id)],
|
||||
order='date desc', limit=1)
|
||||
currency = journal.currency_id or journal.company_id.currency_id
|
||||
return {
|
||||
'journal': {
|
||||
'id': journal.id,
|
||||
'name': journal.name,
|
||||
'currency_code': currency.name,
|
||||
},
|
||||
'unreconciled_count': len(unreconciled_lines),
|
||||
'total_pending_amount': total_amount,
|
||||
'last_statement_date': str(last_stmt.date) if last_stmt and last_stmt.date else None,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 2. list_unreconciled -- paginated, fusion-enriched
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@http.route('/fusion/bank_rec/list_unreconciled', type='jsonrpc', auth='user')
|
||||
def list_unreconciled(self, journal_id, limit=50, offset=0,
|
||||
company_id=None, date_from=None, date_to=None,
|
||||
min_amount=None):
|
||||
"""Return enriched, paginated unreconciled bank lines."""
|
||||
limit = int(limit)
|
||||
offset = int(offset)
|
||||
company_id = (int(company_id) if company_id
|
||||
else request.env.company.id)
|
||||
# The adapter doesn't take an offset; over-fetch and slice.
|
||||
rows = _adapter().list_unreconciled(
|
||||
journal_id=int(journal_id),
|
||||
limit=limit + offset,
|
||||
company_id=company_id,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
min_amount=min_amount,
|
||||
)
|
||||
sliced = rows[offset:offset + limit]
|
||||
Line = request.env['account.bank.statement.line']
|
||||
domain = [
|
||||
('journal_id', '=', int(journal_id)),
|
||||
('is_reconciled', '=', False),
|
||||
('company_id', '=', company_id),
|
||||
]
|
||||
if date_from:
|
||||
domain.append(('date', '>=', date_from))
|
||||
if date_to:
|
||||
domain.append(('date', '<=', date_to))
|
||||
if min_amount is not None:
|
||||
domain.append(('amount', '>=', float(min_amount)))
|
||||
total = Line.search_count(domain)
|
||||
return {
|
||||
'count': len(sliced),
|
||||
'total': total,
|
||||
'lines': sliced,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 3. get_line_detail -- one line + suggestions + attachments
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@http.route('/fusion/bank_rec/get_line_detail', type='jsonrpc', auth='user')
|
||||
def get_line_detail(self, statement_line_id):
|
||||
"""Return full detail for one line including pending suggestions."""
|
||||
Line = request.env['account.bank.statement.line']
|
||||
line = Line.browse(int(statement_line_id))
|
||||
if not line.exists():
|
||||
raise ValidationError(
|
||||
_("Statement line %s not found") % statement_line_id)
|
||||
Sug = request.env['fusion.reconcile.suggestion']
|
||||
suggestions = Sug.search([
|
||||
('statement_line_id', '=', line.id),
|
||||
('state', '=', 'pending'),
|
||||
], order='confidence desc, rank asc')
|
||||
Att = request.env['ir.attachment']
|
||||
attachments = Att.search([
|
||||
('res_model', '=', 'account.move'),
|
||||
('res_id', '=', line.move_id.id),
|
||||
]) if line.move_id else Att.browse()
|
||||
currency = line.currency_id or line.company_id.currency_id
|
||||
return {
|
||||
'line': {
|
||||
'id': line.id,
|
||||
'date': str(line.date) if line.date else None,
|
||||
'payment_ref': line.payment_ref or '',
|
||||
'amount': line.amount,
|
||||
'partner_id': line.partner_id.id if line.partner_id else None,
|
||||
'partner_name': line.partner_id.name if line.partner_id else None,
|
||||
'currency_id': currency.id,
|
||||
'currency_code': currency.name,
|
||||
'journal_id': line.journal_id.id,
|
||||
'journal_name': line.journal_id.name,
|
||||
'is_reconciled': line.is_reconciled,
|
||||
},
|
||||
'suggestions': [{
|
||||
'id': s.id,
|
||||
'candidate_ids': s.proposed_move_line_ids.ids,
|
||||
'confidence': s.confidence,
|
||||
'rank': s.rank,
|
||||
'reasoning': s.reasoning or '',
|
||||
'scores': {
|
||||
'amount_match': s.score_amount_match,
|
||||
'partner_pattern': s.score_partner_pattern,
|
||||
'precedent_similarity': s.score_precedent_similarity,
|
||||
'ai_rerank': s.score_ai_rerank,
|
||||
},
|
||||
} for s in suggestions],
|
||||
'attachments': [{
|
||||
'id': a.id,
|
||||
'name': a.name,
|
||||
'mimetype': a.mimetype,
|
||||
} for a in attachments],
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 4. suggest_matches -- lazy AI suggest for a line
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@http.route('/fusion/bank_rec/suggest_matches', type='jsonrpc', auth='user')
|
||||
def suggest_matches(self, statement_line_ids, limit_per_line=3):
|
||||
"""Trigger AI suggest for one or more statement lines."""
|
||||
ids = [int(i) for i in (statement_line_ids or [])]
|
||||
result = _adapter().suggest_matches(
|
||||
statement_line_ids=ids,
|
||||
limit_per_line=int(limit_per_line),
|
||||
)
|
||||
return {'suggestions': result}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 5. accept_suggestion -- promote AI suggestion to real reconcile
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@http.route('/fusion/bank_rec/accept_suggestion', type='jsonrpc', auth='user')
|
||||
def accept_suggestion(self, suggestion_id):
|
||||
"""Accept a fusion suggestion. Returns the partial IDs created."""
|
||||
sug = request.env['fusion.reconcile.suggestion'].browse(
|
||||
int(suggestion_id))
|
||||
if not sug.exists():
|
||||
raise ValidationError(
|
||||
_("Suggestion %s not found") % suggestion_id)
|
||||
# Capture the journal/company before reconcile (the sug may go stale).
|
||||
journal_id = sug.statement_line_id.journal_id.id
|
||||
company_id = sug.company_id.id
|
||||
result = _adapter().accept_suggestion(suggestion_id=int(suggestion_id))
|
||||
unreconciled_count_after = request.env[
|
||||
'account.bank.statement.line'].search_count([
|
||||
('journal_id', '=', journal_id),
|
||||
('is_reconciled', '=', False),
|
||||
('company_id', '=', company_id),
|
||||
])
|
||||
return {
|
||||
'status': 'accepted',
|
||||
'partial_ids': result.get('partial_ids', []),
|
||||
'unreconciled_count_after': unreconciled_count_after,
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 6. reconcile_manual -- user picked candidates manually
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@http.route('/fusion/bank_rec/reconcile_manual', type='jsonrpc', auth='user')
|
||||
def reconcile_manual(self, statement_line_id, against_move_line_ids):
|
||||
"""Reconcile a line against an explicit set of journal items."""
|
||||
line = request.env['account.bank.statement.line'].browse(
|
||||
int(statement_line_id))
|
||||
if not line.exists():
|
||||
raise ValidationError(
|
||||
_("Statement line %s not found") % statement_line_id)
|
||||
cands = request.env['account.move.line'].browse(
|
||||
[int(i) for i in (against_move_line_ids or [])])
|
||||
result = request.env['fusion.reconcile.engine'].reconcile_one(
|
||||
line, against_lines=cands)
|
||||
return {
|
||||
'status': 'reconciled',
|
||||
'partial_ids': result.get('partial_ids', []),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 7. unreconcile -- reverse a prior reconcile
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@http.route('/fusion/bank_rec/unreconcile', type='jsonrpc', auth='user')
|
||||
def unreconcile(self, partial_reconcile_ids):
|
||||
"""Reverse one or more partial reconciles."""
|
||||
ids = [int(i) for i in (partial_reconcile_ids or [])]
|
||||
result = _adapter().unreconcile(partial_reconcile_ids=ids)
|
||||
return {
|
||||
'status': 'unreconciled',
|
||||
'unreconciled_line_ids': result.get('unreconciled_line_ids', []),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 8. write_off -- absorb residual into a write-off account
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@http.route('/fusion/bank_rec/write_off', type='jsonrpc', auth='user')
|
||||
def write_off(self, statement_line_id, account_id, amount, label,
|
||||
tax_id=None):
|
||||
"""Apply a write-off against a bank statement line."""
|
||||
line = request.env['account.bank.statement.line'].browse(
|
||||
int(statement_line_id))
|
||||
if not line.exists():
|
||||
raise ValidationError(
|
||||
_("Statement line %s not found") % statement_line_id)
|
||||
account = request.env['account.account'].browse(int(account_id))
|
||||
tax = (request.env['account.tax'].browse(int(tax_id))
|
||||
if tax_id else None)
|
||||
result = request.env['fusion.reconcile.engine'].write_off(
|
||||
line, account=account, amount=float(amount),
|
||||
tax_id=tax, label=label)
|
||||
return {
|
||||
'status': 'written_off',
|
||||
'partial_ids': result.get('partial_ids', []),
|
||||
'write_off_move_id': result.get('write_off_move_id'),
|
||||
}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 9. bulk_reconcile -- batch auto-reconcile a recordset
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@http.route('/fusion/bank_rec/bulk_reconcile', type='jsonrpc', auth='user')
|
||||
def bulk_reconcile(self, statement_line_ids, strategy='auto'):
|
||||
"""Batch auto-reconcile. Returns counts + per-line errors."""
|
||||
ids = [int(i) for i in (statement_line_ids or [])]
|
||||
lines = request.env['account.bank.statement.line'].browse(ids)
|
||||
result = request.env['fusion.reconcile.engine'].reconcile_batch(
|
||||
lines, strategy=strategy)
|
||||
return result
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# 10. get_partner_history -- partner reconcile history panel
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@http.route('/fusion/bank_rec/get_partner_history', type='jsonrpc', auth='user')
|
||||
def get_partner_history(self, partner_id, limit=20):
|
||||
"""Return a partner's reconcile history + learned pattern."""
|
||||
Partner = request.env['res.partner']
|
||||
partner = Partner.browse(int(partner_id))
|
||||
if not partner.exists():
|
||||
raise ValidationError(_("Partner %s not found") % partner_id)
|
||||
Precedent = request.env['fusion.reconcile.precedent']
|
||||
recent = Precedent.search(
|
||||
[('partner_id', '=', partner.id)],
|
||||
order='reconciled_at desc, id desc',
|
||||
limit=int(limit),
|
||||
)
|
||||
Pattern = request.env['fusion.reconcile.pattern']
|
||||
pattern = Pattern.search(
|
||||
[('partner_id', '=', partner.id)], limit=1)
|
||||
return {
|
||||
'partner': {
|
||||
'id': partner.id,
|
||||
'name': partner.name,
|
||||
},
|
||||
'recent_reconciles': [{
|
||||
'precedent_id': p.id,
|
||||
'date': str(p.date) if p.date else None,
|
||||
'amount': p.amount,
|
||||
'memo_tokens': p.memo_tokens or '',
|
||||
'matched_count': p.matched_move_line_count,
|
||||
'source': p.source,
|
||||
} for p in recent],
|
||||
'pattern': ({
|
||||
'reconcile_count': pattern.reconcile_count,
|
||||
'pref_strategy': pattern.pref_strategy or None,
|
||||
'common_memo_tokens': pattern.common_memo_tokens or None,
|
||||
'typical_cadence_days': pattern.typical_cadence_days,
|
||||
} if pattern else None),
|
||||
}
|
||||
35
fusion_accounting_bank_rec/data/cron.xml
Normal file
35
fusion_accounting_bank_rec/data/cron.xml
Normal file
@@ -0,0 +1,35 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
|
||||
<record id="cron_fusion_bank_rec_suggest" model="ir.cron">
|
||||
<field name="name">Fusion Bank Rec — Warm AI Suggestions</field>
|
||||
<field name="model_id" ref="model_fusion_bank_rec_cron"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_suggest_pending()</field>
|
||||
<field name="interval_number">30</field>
|
||||
<field name="interval_type">minutes</field>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="cron_fusion_bank_rec_pattern_refresh" model="ir.cron">
|
||||
<field name="name">Fusion Bank Rec — Refresh Partner Patterns</field>
|
||||
<field name="model_id" ref="model_fusion_bank_rec_cron"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_refresh_patterns()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="nextcall" eval="(DateTime.now().replace(hour=2, minute=0, second=0) + timedelta(days=1)).strftime('%Y-%m-%d %H:%M:%S')"/>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="cron_fusion_bank_rec_mv_refresh" model="ir.cron">
|
||||
<field name="name">Fusion Bank Rec — Refresh Unreconciled MV</field>
|
||||
<field name="model_id" ref="model_fusion_bank_rec_cron"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_refresh_mv()</field>
|
||||
<field name="interval_number">5</field>
|
||||
<field name="interval_type">minutes</field>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,57 @@
|
||||
-- Materialized view: pre-aggregated data for the OWL bank reconciliation widget.
|
||||
-- Refreshed on cron (Task 25) and on suggestion writes.
|
||||
-- Indexed on (company_id, journal_id, date) for fast UI queries.
|
||||
|
||||
-- NOTE: account_bank_statement_line does not store `date` directly in V19;
|
||||
-- it is a related field through move_id -> account_move.date. We JOIN on
|
||||
-- account_move to get it.
|
||||
CREATE MATERIALIZED VIEW IF NOT EXISTS fusion_unreconciled_bank_line_mv AS
|
||||
SELECT
|
||||
bsl.id AS id,
|
||||
bsl.company_id AS company_id,
|
||||
bsl.journal_id AS journal_id,
|
||||
am.date AS date,
|
||||
bsl.amount AS amount,
|
||||
bsl.payment_ref AS payment_ref,
|
||||
bsl.currency_id AS currency_id,
|
||||
bsl.partner_id AS partner_id,
|
||||
bsl.create_date AS create_date,
|
||||
-- Top suggestion (highest confidence pending one)
|
||||
(SELECT s.id FROM fusion_reconcile_suggestion s
|
||||
WHERE s.statement_line_id = bsl.id AND s.state = 'pending'
|
||||
ORDER BY s.confidence DESC, s.rank ASC LIMIT 1) AS top_suggestion_id,
|
||||
(SELECT s.confidence FROM fusion_reconcile_suggestion s
|
||||
WHERE s.statement_line_id = bsl.id AND s.state = 'pending'
|
||||
ORDER BY s.confidence DESC, s.rank ASC LIMIT 1) AS top_confidence,
|
||||
CASE
|
||||
WHEN (SELECT MAX(s.confidence) FROM fusion_reconcile_suggestion s
|
||||
WHERE s.statement_line_id = bsl.id AND s.state = 'pending') >= 0.85
|
||||
THEN 'high'
|
||||
WHEN (SELECT MAX(s.confidence) FROM fusion_reconcile_suggestion s
|
||||
WHERE s.statement_line_id = bsl.id AND s.state = 'pending') >= 0.60
|
||||
THEN 'medium'
|
||||
WHEN (SELECT MAX(s.confidence) FROM fusion_reconcile_suggestion s
|
||||
WHERE s.statement_line_id = bsl.id AND s.state = 'pending') > 0
|
||||
THEN 'low'
|
||||
ELSE 'none'
|
||||
END AS confidence_band,
|
||||
-- Attachment count (assumes ir_attachment.res_model='account.bank.statement.line')
|
||||
(SELECT COUNT(*) FROM ir_attachment att
|
||||
WHERE att.res_model = 'account.bank.statement.line' AND att.res_id = bsl.id)
|
||||
AS attachment_count,
|
||||
-- Partner reconcile pattern hint
|
||||
COALESCE((SELECT p.reconcile_count FROM fusion_reconcile_pattern p
|
||||
WHERE p.partner_id = bsl.partner_id AND p.company_id = bsl.company_id LIMIT 1), 0)
|
||||
AS partner_reconcile_count
|
||||
FROM account_bank_statement_line bsl
|
||||
JOIN account_move am ON am.id = bsl.move_id
|
||||
WHERE bsl.is_reconciled IS NOT TRUE;
|
||||
|
||||
-- Indexes for the common UI queries: filter by company + journal, sort by date desc.
|
||||
CREATE INDEX IF NOT EXISTS fusion_mv_unrec_company_journal_date_idx
|
||||
ON fusion_unreconciled_bank_line_mv (company_id, journal_id, date DESC);
|
||||
CREATE INDEX IF NOT EXISTS fusion_mv_unrec_partner_idx
|
||||
ON fusion_unreconciled_bank_line_mv (partner_id) WHERE partner_id IS NOT NULL;
|
||||
-- UNIQUE index required for CONCURRENTLY refresh
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS fusion_mv_unrec_id_idx
|
||||
ON fusion_unreconciled_bank_line_mv (id);
|
||||
@@ -0,0 +1,176 @@
|
||||
from datetime import date
|
||||
|
||||
from odoo import api, Command, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class AccountAutoReconcileWizard(models.TransientModel):
|
||||
""" This wizard is used to automatically reconcile account.move.line.
|
||||
It is accessible trough Accounting > Accounting tab > Actions > Auto-reconcile menuitem.
|
||||
"""
|
||||
_name = 'account.auto.reconcile.wizard'
|
||||
_description = 'Account automatic reconciliation wizard'
|
||||
_check_company_auto = True
|
||||
|
||||
company_id = fields.Many2one(
|
||||
comodel_name='res.company',
|
||||
required=True,
|
||||
readonly=True,
|
||||
default=lambda self: self.env.company,
|
||||
)
|
||||
line_ids = fields.Many2many(comodel_name='account.move.line') # Amls from which we derive a preset for the wizard
|
||||
from_date = fields.Date(string='From')
|
||||
to_date = fields.Date(string='To', default=fields.Date.context_today, required=True)
|
||||
account_ids = fields.Many2many(
|
||||
comodel_name='account.account',
|
||||
string='Accounts',
|
||||
check_company=True,
|
||||
domain="[('reconcile', '=', True), ('account_type', '!=', 'off_balance')]",
|
||||
)
|
||||
partner_ids = fields.Many2many(
|
||||
comodel_name='res.partner',
|
||||
string='Partners',
|
||||
check_company=True,
|
||||
domain="[('company_id', 'in', (False, company_id)), '|', ('parent_id', '=', False), ('is_company', '=', True)]",
|
||||
)
|
||||
search_mode = fields.Selection(
|
||||
selection=[
|
||||
('one_to_one', "Perfect Match"),
|
||||
('zero_balance', "Clear Account"),
|
||||
],
|
||||
string='Reconcile',
|
||||
required=True,
|
||||
default='one_to_one',
|
||||
help="Reconcile journal items with opposite balance or clear accounts with a zero balance",
|
||||
)
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields):
|
||||
res = super().default_get(fields)
|
||||
domain = self.env.context.get('domain')
|
||||
if 'line_ids' in fields and 'line_ids' not in res and domain:
|
||||
amls = self.env['account.move.line'].search(domain)
|
||||
if amls:
|
||||
# pre-configure the wizard
|
||||
res.update(self._get_default_wizard_values(amls))
|
||||
res['line_ids'] = [Command.set(amls.ids)]
|
||||
return res
|
||||
|
||||
@api.model
|
||||
def _get_default_wizard_values(self, amls):
|
||||
""" Derive a preset configuration based on amls.
|
||||
For example if all amls have the same account_id we will set it in the wizard.
|
||||
:param amls: account move lines from which we will derive a preset
|
||||
:return: a dict with preset values
|
||||
"""
|
||||
return {
|
||||
'account_ids': [Command.set(amls[0].account_id.ids)] if all(aml.account_id == amls[0].account_id for aml in amls) else [],
|
||||
'partner_ids': [Command.set(amls[0].partner_id.ids)] if all(aml.partner_id == amls[0].partner_id for aml in amls) else [],
|
||||
'search_mode': 'zero_balance' if amls.company_currency_id.is_zero(sum(amls.mapped('balance'))) else 'one_to_one',
|
||||
'from_date': min(amls.mapped('date')),
|
||||
'to_date': max(amls.mapped('date')),
|
||||
}
|
||||
|
||||
def _get_wizard_values(self):
|
||||
""" Get the current configuration of the wizard as a dict of values.
|
||||
:return: a dict with the current configuration of the wizard.
|
||||
"""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'account_ids': [Command.set(self.account_ids.ids)] if self.account_ids else [],
|
||||
'partner_ids': [Command.set(self.partner_ids.ids)] if self.partner_ids else [],
|
||||
'search_mode': self.search_mode,
|
||||
'from_date': self.from_date,
|
||||
'to_date': self.to_date,
|
||||
}
|
||||
|
||||
# ==== Business methods ====
|
||||
def _get_amls_domain(self):
|
||||
""" Get the domain of amls to be auto-reconciled. """
|
||||
self.ensure_one()
|
||||
if self.line_ids and self._get_wizard_values() == self._get_default_wizard_values(self.line_ids):
|
||||
domain = [('id', 'in', self.line_ids.ids)]
|
||||
else:
|
||||
domain = [
|
||||
('company_id', '=', self.company_id.id),
|
||||
('parent_state', '=', 'posted'),
|
||||
('display_type', 'not in', ('line_section', 'line_subsection', 'line_note')),
|
||||
('date', '>=', self.from_date or date.min),
|
||||
('date', '<=', self.to_date),
|
||||
('reconciled', '=', False),
|
||||
('account_id.reconcile', '=', True),
|
||||
('amount_residual_currency', '!=', 0.0),
|
||||
('amount_residual', '!=', 0.0), # excludes exchange difference lines
|
||||
]
|
||||
if self.account_ids:
|
||||
domain.append(('account_id', 'in', self.account_ids.ids))
|
||||
if self.partner_ids:
|
||||
domain.append(('partner_id', 'in', self.partner_ids.ids))
|
||||
return domain
|
||||
|
||||
def _auto_reconcile_one_to_one(self):
|
||||
""" Auto-reconcile with one-to-one strategy:
|
||||
We will reconcile 2 amls together if their combined balance is zero.
|
||||
:return: a recordset of reconciled amls
|
||||
"""
|
||||
grouped_amls_data = self.env['account.move.line']._read_group(
|
||||
self._get_amls_domain(),
|
||||
['account_id', 'partner_id', 'currency_id', 'amount_residual_currency:abs_rounded'],
|
||||
['id:recordset'],
|
||||
)
|
||||
all_reconciled_amls = self.env['account.move.line']
|
||||
amls_grouped_by_2 = [] # we need to group amls with right format for _reconcile_plan
|
||||
for *__, grouped_aml_ids in grouped_amls_data:
|
||||
positive_amls = grouped_aml_ids.filtered(lambda aml: aml.amount_residual_currency >= 0).sorted('date')
|
||||
negative_amls = (grouped_aml_ids - positive_amls).sorted('date')
|
||||
min_len = min(len(positive_amls), len(negative_amls))
|
||||
positive_amls = positive_amls[:min_len]
|
||||
negative_amls = negative_amls[:min_len]
|
||||
all_reconciled_amls += positive_amls + negative_amls
|
||||
amls_grouped_by_2 += [pos_aml + neg_aml for (pos_aml, neg_aml) in zip(positive_amls, negative_amls)]
|
||||
self.env['account.move.line']._reconcile_plan(amls_grouped_by_2)
|
||||
return all_reconciled_amls
|
||||
|
||||
def _auto_reconcile_zero_balance(self):
|
||||
""" Auto-reconcile with zero balance strategy:
|
||||
We will reconcile all amls grouped by currency/account/partner that have a total balance of zero.
|
||||
:return: a recordset of reconciled amls
|
||||
"""
|
||||
grouped_amls_data = self.env['account.move.line']._read_group(
|
||||
self._get_amls_domain(),
|
||||
groupby=['account_id', 'partner_id', 'currency_id'],
|
||||
aggregates=['id:recordset'],
|
||||
having=[('amount_residual_currency:sum_rounded', '=', 0)],
|
||||
)
|
||||
all_reconciled_amls = self.env['account.move.line']
|
||||
amls_grouped_together = [] # we need to group amls with right format for _reconcile_plan
|
||||
for aml_data in grouped_amls_data:
|
||||
all_reconciled_amls += aml_data[-1]
|
||||
amls_grouped_together += [aml_data[-1]]
|
||||
self.env['account.move.line']._reconcile_plan(amls_grouped_together)
|
||||
return all_reconciled_amls
|
||||
|
||||
def auto_reconcile(self):
|
||||
""" Automatically reconcile amls given wizard's parameters.
|
||||
:return: an action that opens all reconciled items and related amls (exchange diff, etc)
|
||||
"""
|
||||
self.ensure_one()
|
||||
if self.search_mode == 'zero_balance':
|
||||
reconciled_amls = self._auto_reconcile_zero_balance()
|
||||
else:
|
||||
# search_mode == 'one_to_one'
|
||||
reconciled_amls = self._auto_reconcile_one_to_one()
|
||||
reconciled_amls_and_related = self.env['account.move.line'].search([
|
||||
('full_reconcile_id', 'in', reconciled_amls.full_reconcile_id.ids)
|
||||
])
|
||||
if reconciled_amls_and_related:
|
||||
return {
|
||||
'name': _("Automatically Reconciled Entries"),
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_model': 'account.move.line',
|
||||
'context': "{'search_default_group_by_matching': True}",
|
||||
'view_mode': 'list',
|
||||
'domain': [('id', 'in', reconciled_amls_and_related.ids)],
|
||||
}
|
||||
else:
|
||||
raise UserError(self.env._("Nothing to reconcile."))
|
||||
@@ -0,0 +1,325 @@
|
||||
from odoo import SUPERUSER_ID, api, fields, models
|
||||
from odoo.tools import SQL
|
||||
|
||||
|
||||
class AccountReconcileModel(models.Model):
|
||||
_inherit = 'account.reconcile.model'
|
||||
|
||||
# Technical field to know if the rule was created automatically or by a user.
|
||||
created_automatically = fields.Boolean(default=False, copy=False)
|
||||
|
||||
def _apply_lines_for_bank_widget(self, residual_amount_currency, residual_balance, partner, st_line):
|
||||
""" Apply the reconciliation model lines to the statement line passed as parameter.
|
||||
|
||||
:param residual_amount_currency: The open amount currency of the statement line in the bank reconciliation widget
|
||||
expressed in the statement line currency.
|
||||
:param residual_balance: The open balance of the statement line in the bank reconciliation widget
|
||||
expressed in the company currency.
|
||||
:param partner: The partner set on the wizard.
|
||||
:param st_line: The statement line processed by the bank reconciliation widget.
|
||||
:return: A list of python dictionaries (one per reconcile model line) representing
|
||||
the journal items to be created by the current reconcile model.
|
||||
"""
|
||||
self.ensure_one()
|
||||
currency = st_line.foreign_currency_id or st_line.journal_id.currency_id or st_line.company_currency_id
|
||||
vals_list = []
|
||||
for line in self.line_ids:
|
||||
vals = line._apply_in_bank_widget(
|
||||
residual_amount_currency=residual_amount_currency,
|
||||
residual_balance=residual_balance,
|
||||
partner=line.partner_id or partner,
|
||||
st_line=st_line,
|
||||
)
|
||||
amount_currency = vals['amount_currency']
|
||||
balance = vals['balance']
|
||||
|
||||
if currency.is_zero(amount_currency) and st_line.company_currency_id.is_zero(balance):
|
||||
continue
|
||||
|
||||
vals_list.append(vals)
|
||||
residual_amount_currency -= amount_currency
|
||||
residual_balance -= balance
|
||||
|
||||
return vals_list
|
||||
|
||||
@api.model
|
||||
def get_available_reconcile_model_per_statement_line(self, statement_line_ids):
|
||||
self.check_access('read')
|
||||
self.env['account.reconcile.model'].flush_model()
|
||||
self.env['account.bank.statement.line'].flush_model()
|
||||
self.env.cr.execute(SQL(
|
||||
"""
|
||||
WITH matching_journal_ids AS (
|
||||
SELECT account_reconcile_model_id,
|
||||
ARRAY_AGG(account_journal_id) AS ids
|
||||
FROM account_journal_account_reconcile_model_rel
|
||||
GROUP BY account_reconcile_model_id
|
||||
),
|
||||
matching_partner_ids AS (
|
||||
SELECT account_reconcile_model_id,
|
||||
ARRAY_AGG(res_partner_id) AS ids
|
||||
FROM account_reconcile_model_res_partner_rel
|
||||
GROUP BY account_reconcile_model_id
|
||||
)
|
||||
|
||||
SELECT st_line.id AS st_line_id,
|
||||
array_agg(reco_model.id ORDER BY reco_model.sequence ASC, reco_model.id ASC) AS reco_model_ids,
|
||||
array_agg(reco_model.name ORDER BY reco_model.sequence ASC, reco_model.id ASC) AS reco_model_names
|
||||
FROM account_bank_statement_line st_line
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT DISTINCT reco_model.id,
|
||||
reco_model.sequence,
|
||||
COALESCE(reco_model.name -> %(lang)s, reco_model.name -> 'en_US') as name
|
||||
FROM account_reconcile_model reco_model
|
||||
LEFT JOIN matching_journal_ids ON reco_model.id = matching_journal_ids.account_reconcile_model_id
|
||||
LEFT JOIN matching_partner_ids ON reco_model.id = matching_partner_ids.account_reconcile_model_id
|
||||
LEFT JOIN account_reconcile_model_line reco_model_line ON reco_model_line.model_id = reco_model.id
|
||||
WHERE (matching_journal_ids.ids IS NULL OR st_line.journal_id = ANY(matching_journal_ids.ids))
|
||||
AND (matching_partner_ids.ids IS NULL OR st_line.partner_id = ANY(matching_partner_ids.ids))
|
||||
AND (
|
||||
CASE COALESCE(reco_model.match_amount, '')
|
||||
WHEN 'lower' THEN st_line.amount <= reco_model.match_amount_max
|
||||
WHEN 'greater' THEN st_line.amount >= reco_model.match_amount_min
|
||||
WHEN 'between' THEN
|
||||
(st_line.amount BETWEEN reco_model.match_amount_min AND reco_model.match_amount_max) OR
|
||||
(st_line.amount BETWEEN reco_model.match_amount_max AND reco_model.match_amount_min)
|
||||
ELSE TRUE
|
||||
END
|
||||
)
|
||||
AND (
|
||||
reco_model.match_label IS NULL
|
||||
OR (
|
||||
reco_model.match_label = 'contains'
|
||||
AND (
|
||||
st_line.payment_ref IS NOT NULL AND st_line.payment_ref ILIKE '%%' || reco_model.match_label_param || '%%'
|
||||
OR st_line.transaction_details IS NOT NULL AND st_line.transaction_details::TEXT ILIKE '%%' || reco_model.match_label_param || '%%'
|
||||
)
|
||||
) OR (
|
||||
reco_model.match_label = 'not_contains'
|
||||
AND NOT (
|
||||
st_line.payment_ref IS NOT NULL AND st_line.payment_ref ILIKE '%%' || reco_model.match_label_param || '%%'
|
||||
OR st_line.transaction_details IS NOT NULL AND st_line.transaction_details::TEXT ILIKE '%%' || reco_model.match_label_param || '%%'
|
||||
)
|
||||
) OR (
|
||||
reco_model.match_label = 'match_regex'
|
||||
AND (
|
||||
st_line.payment_ref IS NOT NULL AND st_line.payment_ref ~* reco_model.match_label_param
|
||||
OR st_line.transaction_details IS NOT NULL AND st_line.transaction_details::TEXT ~* reco_model.match_label_param
|
||||
)
|
||||
)
|
||||
)
|
||||
AND reco_model.company_id = st_line.company_id
|
||||
AND reco_model.trigger = 'manual'
|
||||
AND reco_model_line.account_id IS NOT NULL
|
||||
AND reco_model.active IS TRUE
|
||||
) AS reco_model ON TRUE
|
||||
WHERE st_line.id IN %(statement_lines)s
|
||||
AND reco_model.id IS NOT NULL
|
||||
GROUP BY st_line.id
|
||||
""",
|
||||
lang=self.env.lang,
|
||||
statement_lines=tuple(statement_line_ids),
|
||||
))
|
||||
query_result = self.env.cr.fetchall()
|
||||
return {
|
||||
st_line_id: [
|
||||
{'id': model_id, 'display_name': model_name}
|
||||
for (model_id, model_name)
|
||||
in zip(model_ids, model_names)
|
||||
]
|
||||
for st_line_id, model_ids, model_names
|
||||
in query_result
|
||||
}
|
||||
|
||||
def _apply_reconcile_models(self, statement_lines):
|
||||
if not self or not statement_lines:
|
||||
return
|
||||
self.env['account.reconcile.model'].flush_model()
|
||||
statement_lines.flush_recordset(['journal_id', 'amount', 'amount_residual', 'transaction_details', 'payment_ref', 'partner_id', 'company_id'])
|
||||
self.env.cr.execute(SQL("""
|
||||
WITH matching_journal_ids AS (
|
||||
SELECT account_reconcile_model_id,
|
||||
ARRAY_AGG(account_journal_id) AS ids
|
||||
FROM account_journal_account_reconcile_model_rel
|
||||
GROUP BY account_reconcile_model_id
|
||||
),
|
||||
matching_partner_ids AS (
|
||||
SELECT account_reconcile_model_id,
|
||||
ARRAY_AGG(res_partner_id) AS ids
|
||||
FROM account_reconcile_model_res_partner_rel
|
||||
GROUP BY account_reconcile_model_id
|
||||
),
|
||||
model_fees AS (
|
||||
SELECT model_fees.id,
|
||||
model_fees.trigger,
|
||||
matching_journal_ids.ids AS journal_ids
|
||||
FROM account_reconcile_model model_fees
|
||||
JOIN ir_model_data imd ON model_fees.id = imd.res_id
|
||||
JOIN account_reconcile_model_line model_lines ON model_lines.model_id = model_fees.id
|
||||
LEFT JOIN matching_journal_ids ON model_fees.id = matching_journal_ids.account_reconcile_model_id
|
||||
WHERE imd.module = 'account'
|
||||
AND imd.name LIKE 'account_reco_model_fee_%%'
|
||||
AND model_fees.active IS TRUE
|
||||
AND model_lines.account_id IS NOT NULL
|
||||
)
|
||||
|
||||
SELECT st_line.id AS st_line_id,
|
||||
COALESCE(reco_model.id, model_fees.id) AS reco_model_id,
|
||||
COALESCE(reco_model.trigger, model_fees.trigger) AS trigger
|
||||
FROM account_bank_statement_line st_line
|
||||
JOIN account_move move ON st_line.move_id = move.id
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT reco_model.id,
|
||||
reco_model.trigger
|
||||
FROM account_reconcile_model reco_model
|
||||
LEFT JOIN matching_journal_ids ON reco_model.id = matching_journal_ids.account_reconcile_model_id
|
||||
LEFT JOIN matching_partner_ids ON reco_model.id = matching_partner_ids.account_reconcile_model_id
|
||||
WHERE (matching_journal_ids.ids IS NULL OR st_line.journal_id = ANY(matching_journal_ids.ids))
|
||||
AND (matching_partner_ids.ids IS NULL OR st_line.partner_id = ANY(matching_partner_ids.ids))
|
||||
AND (
|
||||
CASE COALESCE(reco_model.match_amount, '')
|
||||
WHEN 'lower' THEN st_line.amount <= reco_model.match_amount_max
|
||||
WHEN 'greater' THEN st_line.amount >= reco_model.match_amount_min
|
||||
WHEN 'between' THEN
|
||||
(st_line.amount BETWEEN reco_model.match_amount_min AND reco_model.match_amount_max) OR
|
||||
(st_line.amount BETWEEN reco_model.match_amount_max AND reco_model.match_amount_min)
|
||||
ELSE TRUE
|
||||
END
|
||||
)
|
||||
AND (
|
||||
reco_model.match_label IS NULL
|
||||
OR (
|
||||
reco_model.match_label = 'contains'
|
||||
AND (
|
||||
st_line.payment_ref IS NOT NULL AND st_line.payment_ref ILIKE '%%' || reco_model.match_label_param || '%%'
|
||||
OR st_line.transaction_details IS NOT NULL AND st_line.transaction_details::TEXT ILIKE '%%' || reco_model.match_label_param || '%%'
|
||||
OR move.narration IS NOT NULL AND move.narration::TEXT ILIKE '%%' || reco_model.match_label_param || '%%'
|
||||
)
|
||||
) OR (
|
||||
reco_model.match_label = 'not_contains'
|
||||
AND NOT (
|
||||
st_line.payment_ref IS NOT NULL AND st_line.payment_ref ILIKE '%%' || reco_model.match_label_param || '%%'
|
||||
OR st_line.transaction_details IS NOT NULL AND st_line.transaction_details::TEXT ILIKE '%%' || reco_model.match_label_param || '%%'
|
||||
OR move.narration IS NOT NULL AND move.narration::TEXT ILIKE '%%' || reco_model.match_label_param || '%%'
|
||||
)
|
||||
) OR (
|
||||
reco_model.match_label = 'match_regex'
|
||||
AND (
|
||||
st_line.payment_ref IS NOT NULL AND st_line.payment_ref ~* reco_model.match_label_param
|
||||
OR st_line.transaction_details IS NOT NULL AND st_line.transaction_details::TEXT ~* reco_model.match_label_param
|
||||
OR move.narration IS NOT NULL AND move.narration::TEXT ~* reco_model.match_label_param
|
||||
)
|
||||
)
|
||||
)
|
||||
AND reco_model.id IN %s
|
||||
AND reco_model.can_be_proposed IS TRUE
|
||||
AND reco_model.company_id = st_line.company_id
|
||||
ORDER BY reco_model.sequence ASC, reco_model.id ASC
|
||||
LIMIT 1
|
||||
) AS reco_model ON TRUE
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT model_fees.id,
|
||||
model_fees.trigger
|
||||
FROM model_fees
|
||||
WHERE st_line.journal_id = ANY(model_fees.journal_ids)
|
||||
-- Show model fees if matched amount was 3 %% higher than incoming statement line amount
|
||||
AND SIGN(st_line.amount) > 0
|
||||
AND SIGN(st_line.amount_residual) > 0
|
||||
AND ABS(st_line.amount_residual) < 0.03 * st_line.amount / 1.03
|
||||
) AS model_fees ON TRUE
|
||||
WHERE st_line.id IN %s
|
||||
""", tuple(self.ids), tuple(statement_lines.ids)))
|
||||
|
||||
query_result = self.env.cr.fetchall()
|
||||
|
||||
processed_st_line_ids = set()
|
||||
# apply the found suitable reco models on the statement lines
|
||||
for st_line_id, reco_model_id, reco_model_trigger in query_result:
|
||||
if st_line_id in processed_st_line_ids or reco_model_id is None:
|
||||
continue
|
||||
|
||||
st_line = self.env['account.bank.statement.line'].browse(st_line_id).with_prefetch(statement_lines.ids)
|
||||
reco_model = self.env['account.reconcile.model'].browse(reco_model_id).with_prefetch(self.ids)
|
||||
|
||||
if reco_model_trigger == 'manual':
|
||||
st_line._action_manual_reco_model(reco_model_id)
|
||||
else:
|
||||
reco_model.with_user(SUPERUSER_ID)._trigger_reconciliation_model(st_line.with_user(SUPERUSER_ID))
|
||||
processed_st_line_ids.add(st_line_id)
|
||||
|
||||
def _trigger_reconciliation_model(self, statement_line):
|
||||
self.ensure_one()
|
||||
liquidity_line, suspense_line, other_lines = statement_line._seek_for_lines()
|
||||
|
||||
amls_to_create = list(
|
||||
self._apply_lines_for_bank_widget(
|
||||
residual_amount_currency=sum(suspense_line.mapped('amount_currency')),
|
||||
residual_balance=sum(suspense_line.mapped('balance')),
|
||||
partner=statement_line.partner_id,
|
||||
st_line=statement_line,
|
||||
)
|
||||
)
|
||||
# Get the original base lines and tax lines before the creation of new lines
|
||||
if any(aml.get('tax_ids') for aml in amls_to_create):
|
||||
original_base_lines, original_tax_lines = statement_line._prepare_for_tax_lines_recomputation()
|
||||
|
||||
statement_line._set_move_line_to_statement_line_move(liquidity_line + other_lines, amls_to_create)
|
||||
|
||||
# Now that the new lines have been added, we can recompute the taxes
|
||||
if any(aml.get('tax_ids') for aml in amls_to_create):
|
||||
_new_liquidity_line, new_suspense_line, _new_other_lines = statement_line._seek_for_lines()
|
||||
new_lines = statement_line.line_ids - (liquidity_line + other_lines + new_suspense_line)
|
||||
statement_line._create_tax_lines(original_base_lines, original_tax_lines, new_lines)
|
||||
|
||||
if self.next_activity_type_id:
|
||||
statement_line.move_id.activity_schedule(
|
||||
activity_type_id=self.next_activity_type_id.id,
|
||||
user_id=self.env.user.id,
|
||||
)
|
||||
|
||||
def trigger_reconciliation_model(self, statement_line_id):
|
||||
self.ensure_one()
|
||||
|
||||
statement_line = self.env['account.bank.statement.line'].browse(statement_line_id).exists()
|
||||
self._trigger_reconciliation_model(statement_line)
|
||||
|
||||
def write(self, vals):
|
||||
res = super().write(vals)
|
||||
unreconciled_statement_lines = self.env['account.bank.statement.line'].search([
|
||||
*self._check_company_domain(self.env.company),
|
||||
('is_reconciled', '=', False),
|
||||
])
|
||||
if unreconciled_statement_lines:
|
||||
unreconciled_statement_lines.line_ids.filtered(
|
||||
lambda line:
|
||||
line.account_id == line.move_id.journal_id.suspense_account_id and line.reconcile_model_id in self
|
||||
).reconcile_model_id = False
|
||||
self._apply_reconcile_models(unreconciled_statement_lines)
|
||||
|
||||
return res
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
reco_models = super().create(vals_list)
|
||||
unreconciled_statement_lines = self.env['account.bank.statement.line'].search([
|
||||
*self._check_company_domain(self.env.company),
|
||||
('is_reconciled', '=', False),
|
||||
])
|
||||
if unreconciled_statement_lines:
|
||||
reco_models._apply_reconcile_models(unreconciled_statement_lines)
|
||||
|
||||
return reco_models
|
||||
|
||||
def action_archive(self):
|
||||
res = super().action_archive()
|
||||
unreconciled_statement_lines = self.env['account.bank.statement.line'].search([
|
||||
*self._check_company_domain(self.env.company),
|
||||
('is_reconciled', '=', False),
|
||||
('line_ids.reconcile_model_id', 'in', self.ids),
|
||||
])
|
||||
if unreconciled_statement_lines:
|
||||
unreconciled_statement_lines.line_ids.filtered(
|
||||
lambda line:
|
||||
line.account_id == line.move_id.journal_id.suspense_account_id
|
||||
).reconcile_model_id = False
|
||||
return res
|
||||
@@ -0,0 +1,139 @@
|
||||
import { EventBus, reactive, useState } from "@odoo/owl";
|
||||
import { browser } from "@web/core/browser/browser";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
export class BankReconciliationService {
|
||||
constructor(env, services) {
|
||||
this.env = env;
|
||||
this.setup(env, services);
|
||||
}
|
||||
|
||||
setup(env, services) {
|
||||
this.bus = new EventBus();
|
||||
this.orm = services["orm"];
|
||||
|
||||
this.chatterState = reactive({
|
||||
visible:
|
||||
JSON.parse(
|
||||
browser.sessionStorage.getItem("isBankReconciliationWidgetChatterOpened")
|
||||
) ?? false,
|
||||
statementLine: null,
|
||||
});
|
||||
this.reconcileCountPerPartnerId = reactive({});
|
||||
this.reconcileModelPerStatementLineId = reactive({});
|
||||
}
|
||||
|
||||
toggleChatter() {
|
||||
this.chatterState.visible = !this.chatterState.visible;
|
||||
browser.sessionStorage.setItem(
|
||||
"isBankReconciliationWidgetChatterOpened",
|
||||
this.chatterState.visible
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Specific function to open the chatter.
|
||||
* For a particular case, where the customer clicks on
|
||||
* the chatter icon directly on the bank statement line,
|
||||
* we want to open the chatter but not close it.
|
||||
*/
|
||||
openChatter() {
|
||||
this.chatterState.visible = true;
|
||||
}
|
||||
|
||||
selectStatementLine(statementLine) {
|
||||
this.chatterState.statementLine = statementLine;
|
||||
}
|
||||
|
||||
reloadChatter() {
|
||||
this.bus.trigger("MAIL:RELOAD-THREAD", {
|
||||
model: "account.move",
|
||||
id: this.statementLineMoveId,
|
||||
});
|
||||
}
|
||||
|
||||
async computeReconcileLineCountPerPartnerId(records) {
|
||||
const groups = await this.orm.formattedReadGroup(
|
||||
"account.move.line",
|
||||
[
|
||||
["parent_state", "in", ["draft", "posted"]],
|
||||
[
|
||||
"partner_id",
|
||||
"in",
|
||||
records
|
||||
.filter((record) => !!record.data.partner_id.id)
|
||||
.map((record) => record.data.partner_id.id),
|
||||
],
|
||||
["company_id", "child_of", records.map((record) => record.data.company_id.id)],
|
||||
["search_account_id.reconcile", "=", true],
|
||||
["display_type", "not in", ["line_section", "line_note"]],
|
||||
["reconciled", "=", false],
|
||||
"|",
|
||||
["search_account_id.account_type", "not in", ["asset_receivable", "liability_payable"]],
|
||||
["payment_id", "=", false],
|
||||
["statement_line_id", "not in", records.map((record) => record.data.id)],
|
||||
],
|
||||
["partner_id"],
|
||||
["id:count"]
|
||||
);
|
||||
|
||||
this.reconcileCountPerPartnerId = {};
|
||||
groups.forEach((group) => {
|
||||
this.reconcileCountPerPartnerId[group.partner_id[0]] = group["id:count"];
|
||||
});
|
||||
}
|
||||
|
||||
async computeAvailableReconcileModels(records) {
|
||||
this.reconcileModelPerStatementLineId =
|
||||
Object.keys(records).length === 0
|
||||
? {}
|
||||
: await this.orm.call(
|
||||
"account.reconcile.model",
|
||||
"get_available_reconcile_model_per_statement_line",
|
||||
[records.map((record) => record.data.id)]
|
||||
);
|
||||
}
|
||||
|
||||
async updateAvailableReconcileModels(recordId) {
|
||||
const result = await this.orm.call(
|
||||
"account.reconcile.model",
|
||||
"get_available_reconcile_model_per_statement_line",
|
||||
[[recordId]]
|
||||
);
|
||||
this.reconcileModelPerStatementLineId[recordId] = result[recordId];
|
||||
}
|
||||
|
||||
async reloadRecords(records) {
|
||||
await Promise.all([...records.map((record) => record.load())]);
|
||||
}
|
||||
|
||||
get statementLineMove() {
|
||||
return this.chatterState.statementLine?.data.move_id;
|
||||
}
|
||||
|
||||
get statementLineMoveId() {
|
||||
return this.statementLineMove?.id;
|
||||
}
|
||||
|
||||
get statementLine() {
|
||||
return this.chatterState.statementLine;
|
||||
}
|
||||
|
||||
get statementLineId() {
|
||||
return this.statementLine?.data?.id;
|
||||
}
|
||||
}
|
||||
|
||||
const bankReconciliationService = {
|
||||
dependencies: ["orm"],
|
||||
start(env, services) {
|
||||
return new BankReconciliationService(env, services);
|
||||
},
|
||||
};
|
||||
|
||||
registry.category("services").add("bankReconciliation", bankReconciliationService);
|
||||
|
||||
export function useBankReconciliation() {
|
||||
return useState(useService("bankReconciliation"));
|
||||
}
|
||||
10
fusion_accounting_bank_rec/models/__init__.py
Normal file
10
fusion_accounting_bank_rec/models/__init__.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from . import fusion_reconcile_pattern
|
||||
from . import fusion_reconcile_precedent
|
||||
from . import fusion_reconcile_suggestion
|
||||
from . import fusion_bank_rec_widget
|
||||
from . import account_bank_statement_line
|
||||
from . import account_reconcile_model
|
||||
from . import fusion_reconcile_engine
|
||||
from . import fusion_unreconciled_bank_line_mv
|
||||
from . import fusion_bank_rec_cron
|
||||
from . import fusion_migration_wizard
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Inherit account.bank.statement.line to add Phase 1 widget compute fields.
|
||||
|
||||
These fields are NOT stored — they're computed on-the-fly so the OWL widget
|
||||
can render confidence badges without round-tripping. Performance OK because
|
||||
the widget loads ~50-200 lines per kanban open and each compute is a single
|
||||
indexed query into fusion.reconcile.suggestion.
|
||||
"""
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class AccountBankStatementLine(models.Model):
|
||||
_inherit = "account.bank.statement.line"
|
||||
|
||||
# Top suggestion + its band — for the inline AI confidence badge
|
||||
fusion_top_suggestion_id = fields.Many2one(
|
||||
'fusion.reconcile.suggestion',
|
||||
compute='_compute_top_suggestion',
|
||||
store=False,
|
||||
help="Highest-ranked pending AI suggestion for this line")
|
||||
fusion_confidence_band = fields.Selection(
|
||||
[('high', 'High'), ('medium', 'Medium'), ('low', 'Low'), ('none', 'None')],
|
||||
compute='_compute_top_suggestion',
|
||||
store=False,
|
||||
default='none',
|
||||
help="Quick-render colour band for the OWL widget badge")
|
||||
|
||||
# Mirror of Enterprise's bank_statement_attachment_ids surface field.
|
||||
# Defined here so fusion's widget can render attachments without
|
||||
# depending on account_accountant being installed.
|
||||
bank_statement_attachment_ids = fields.One2many(
|
||||
'ir.attachment',
|
||||
compute='_compute_bank_statement_attachment_ids',
|
||||
help="Attachments on the underlying account.move; mirrored for the OWL widget")
|
||||
|
||||
def _compute_top_suggestion(self):
|
||||
Suggestion = self.env['fusion.reconcile.suggestion'].sudo()
|
||||
for line in self:
|
||||
top = Suggestion.search([
|
||||
('statement_line_id', '=', line.id),
|
||||
('state', '=', 'pending'),
|
||||
('rank', '=', 1),
|
||||
], limit=1)
|
||||
line.fusion_top_suggestion_id = top
|
||||
line.fusion_confidence_band = top.confidence_band if top else 'none'
|
||||
|
||||
@api.depends('move_id', 'move_id.attachment_ids')
|
||||
def _compute_bank_statement_attachment_ids(self):
|
||||
for line in self:
|
||||
line.bank_statement_attachment_ids = (
|
||||
line.move_id.attachment_ids if line.move_id else self.env['ir.attachment']
|
||||
)
|
||||
20
fusion_accounting_bank_rec/models/account_reconcile_model.py
Normal file
20
fusion_accounting_bank_rec/models/account_reconcile_model.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""Inherit account.reconcile.model to add Phase 1 AI integration hooks.
|
||||
|
||||
This is a minimal extension placeholder for now — Phase 1+ phases may
|
||||
expand it (e.g., to attach AI confidence rules to reconcile-model
|
||||
auto-fires). The shared-field-ownership for `created_automatically`
|
||||
already lives in fusion_accounting_core; this file is for fusion_bank_rec
|
||||
specific extensions only.
|
||||
"""
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class AccountReconcileModel(models.Model):
|
||||
_inherit = "account.reconcile.model"
|
||||
|
||||
fusion_ai_confidence_threshold = fields.Float(
|
||||
string="AI confidence threshold",
|
||||
default=0.0,
|
||||
help="If >0.0, fusion AI suggestions matching this rule are auto-applied "
|
||||
"only when their confidence ≥ this threshold. 0.0 = no AI filtering.")
|
||||
119
fusion_accounting_bank_rec/models/fusion_bank_rec_cron.py
Normal file
119
fusion_accounting_bank_rec/models/fusion_bank_rec_cron.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""Cron handler model for fusion_accounting_bank_rec.
|
||||
|
||||
Three scheduled jobs:
|
||||
- _cron_suggest_pending: warm AI suggestions for unreconciled lines (30 min)
|
||||
- _cron_refresh_patterns: recompute fusion.reconcile.pattern aggregates (daily 02:00)
|
||||
- _cron_refresh_mv: REFRESH MATERIALIZED VIEW CONCURRENTLY (5 min)
|
||||
"""
|
||||
|
||||
import logging
|
||||
from datetime import timedelta
|
||||
|
||||
import odoo
|
||||
from odoo import api, fields, models
|
||||
|
||||
from ..services.pattern_extractor import extract_pattern_for_partner
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionBankRecCron(models.AbstractModel):
|
||||
_name = "fusion.bank.rec.cron"
|
||||
_description = "Fusion Bank Reconciliation Cron Handlers"
|
||||
|
||||
@api.model
|
||||
def _cron_suggest_pending(self, batch_size=50):
|
||||
"""For each unreconciled bank line that doesn't have a recent pending
|
||||
suggestion, run engine.suggest_matches.
|
||||
|
||||
Recent = a pending suggestion created within the last 24 hours."""
|
||||
cutoff = fields.Datetime.now() - timedelta(hours=24)
|
||||
Line = self.env['account.bank.statement.line']
|
||||
lines_to_consider = Line.search([
|
||||
('is_reconciled', '=', False),
|
||||
('partner_id', '!=', False),
|
||||
], limit=batch_size * 5)
|
||||
|
||||
Suggestion = self.env['fusion.reconcile.suggestion']
|
||||
lines_needing_suggestions = self.env['account.bank.statement.line']
|
||||
for line in lines_to_consider:
|
||||
recent = Suggestion.search_count([
|
||||
('statement_line_id', '=', line.id),
|
||||
('state', '=', 'pending'),
|
||||
('create_date', '>=', cutoff),
|
||||
])
|
||||
if recent == 0:
|
||||
lines_needing_suggestions |= line
|
||||
if len(lines_needing_suggestions) >= batch_size:
|
||||
break
|
||||
|
||||
if not lines_needing_suggestions:
|
||||
_logger.debug("Cron: no bank lines need suggestion warming")
|
||||
return
|
||||
|
||||
_logger.info(
|
||||
"Cron: warming suggestions for %d bank lines",
|
||||
len(lines_needing_suggestions))
|
||||
try:
|
||||
self.env['fusion.reconcile.engine'].suggest_matches(
|
||||
lines_needing_suggestions, limit_per_line=3)
|
||||
except Exception as e:
|
||||
_logger.exception("Cron suggest_pending failed: %s", e)
|
||||
|
||||
@api.model
|
||||
def _cron_refresh_patterns(self):
|
||||
"""For each (company, partner) pair with precedents, recompute and
|
||||
upsert the fusion.reconcile.pattern row."""
|
||||
Pattern = self.env['fusion.reconcile.pattern']
|
||||
self.env.cr.execute("""
|
||||
SELECT DISTINCT company_id, partner_id
|
||||
FROM fusion_reconcile_precedent
|
||||
WHERE partner_id IS NOT NULL
|
||||
""")
|
||||
pairs = self.env.cr.fetchall()
|
||||
_logger.info(
|
||||
"Cron: refreshing patterns for %d (company, partner) pairs",
|
||||
len(pairs))
|
||||
for company_id, partner_id in pairs:
|
||||
try:
|
||||
vals = extract_pattern_for_partner(
|
||||
self.env, company_id=company_id, partner_id=partner_id)
|
||||
existing = Pattern.search([
|
||||
('company_id', '=', company_id),
|
||||
('partner_id', '=', partner_id),
|
||||
], limit=1)
|
||||
if existing:
|
||||
existing.write(vals)
|
||||
else:
|
||||
Pattern.create(vals)
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
"Pattern refresh failed for company=%s partner=%s: %s",
|
||||
company_id, partner_id, e)
|
||||
|
||||
@api.model
|
||||
def _cron_refresh_mv(self):
|
||||
"""Refresh the materialized view CONCURRENTLY using an autocommit cursor.
|
||||
|
||||
REFRESH CONCURRENTLY can't run inside a transaction, so we open a
|
||||
fresh connection in autocommit mode (per Task 24's note). On any
|
||||
failure, we fall back to the model's blocking refresh."""
|
||||
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_unreconciled_bank_line_mv")
|
||||
_logger.debug("Cron: MV refresh CONCURRENTLY succeeded")
|
||||
except Exception as e:
|
||||
_logger.warning(
|
||||
"Cron MV refresh CONCURRENTLY failed (%s); falling back to "
|
||||
"blocking refresh", e)
|
||||
try:
|
||||
self.env['fusion.unreconciled.bank.line.mv']._refresh(
|
||||
concurrently=False)
|
||||
except Exception as e2:
|
||||
_logger.exception(
|
||||
"Cron MV refresh fallback also failed: %s", e2)
|
||||
33
fusion_accounting_bank_rec/models/fusion_bank_rec_widget.py
Normal file
33
fusion_accounting_bank_rec/models/fusion_bank_rec_widget.py
Normal file
@@ -0,0 +1,33 @@
|
||||
"""Per-request widget state. Holds the kanban-load response shape so the
|
||||
controller can return one well-typed object.
|
||||
|
||||
This is a TransientModel (no DB persistence beyond the request). The OWL
|
||||
widget reads pre-computed fusion.reconcile.suggestion rows directly via
|
||||
the controller; this model is just a typed envelope for the kanban-open
|
||||
action."""
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class FusionBankRecWidget(models.TransientModel):
|
||||
_name = "fusion.bank.rec.widget"
|
||||
_description = "Bank reconciliation widget state (transient)"
|
||||
|
||||
journal_id = fields.Many2one('account.journal',
|
||||
domain="[('type', '=', 'bank')]")
|
||||
statement_line_ids = fields.Many2many('account.bank.statement.line')
|
||||
summary_count = fields.Integer(
|
||||
help="Number of unreconciled lines visible in this widget")
|
||||
summary_unreconciled_balance = fields.Monetary(currency_field='currency_id')
|
||||
currency_id = fields.Many2one('res.currency',
|
||||
related='journal_id.currency_id',
|
||||
store=False, readonly=True)
|
||||
|
||||
def action_open_kanban(self):
|
||||
"""Return a window action opening the OWL kanban for this journal."""
|
||||
self.ensure_one()
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'fusion_bank_rec_kanban',
|
||||
'params': {'journal_id': self.journal_id.id},
|
||||
}
|
||||
97
fusion_accounting_bank_rec/models/fusion_migration_wizard.py
Normal file
97
fusion_accounting_bank_rec/models/fusion_migration_wizard.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""Bank-rec specific migration step.
|
||||
|
||||
Hooks into fusion.migration.wizard (defined by fusion_accounting_migration)
|
||||
to bootstrap fusion.reconcile.precedent from existing
|
||||
account.partial.reconcile rows. This gives the AI immediate "memory" from
|
||||
past Enterprise reconciles so suggestions can be ranked by precedent
|
||||
similarity from day one.
|
||||
|
||||
The bootstrap step is exposed as a public method (_bank_rec_bootstrap_step)
|
||||
so tests and the audit report can invoke it directly. action_run_migration
|
||||
is overridden to call super() then run the bootstrap.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import _, models
|
||||
|
||||
from ..services.precedent_backfill import backfill_precedents
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionMigrationWizard(models.TransientModel):
|
||||
_inherit = "fusion.migration.wizard"
|
||||
|
||||
def _bank_rec_bootstrap_step(self):
|
||||
"""Migration step: backfill precedents + refresh patterns + refresh MV.
|
||||
|
||||
Returns a dict describing what happened, suitable for surfacing to
|
||||
the user via notification or PDF audit report.
|
||||
"""
|
||||
self.ensure_one()
|
||||
_logger.info(
|
||||
"fusion_accounting_bank_rec migration step: bootstrap starting")
|
||||
|
||||
company_id = None
|
||||
if 'company_id' in self._fields and self.company_id:
|
||||
company_id = self.company_id.id
|
||||
|
||||
precedent_result = backfill_precedents(
|
||||
self.env, company_id=company_id, limit=10000)
|
||||
|
||||
try:
|
||||
self.env['fusion.bank.rec.cron']._cron_refresh_patterns()
|
||||
patterns_ok = True
|
||||
except Exception as e: # noqa: BLE001
|
||||
_logger.warning(
|
||||
"Pattern refresh during migration failed: %s", e)
|
||||
patterns_ok = False
|
||||
|
||||
try:
|
||||
self.env['fusion.unreconciled.bank.line.mv']._refresh(
|
||||
concurrently=False)
|
||||
mv_ok = True
|
||||
except Exception as e: # noqa: BLE001
|
||||
_logger.warning("MV refresh during migration failed: %s", e)
|
||||
mv_ok = False
|
||||
|
||||
result = {
|
||||
'step': 'bank_rec_bootstrap',
|
||||
'precedents_created': precedent_result['created'],
|
||||
'precedents_skipped': precedent_result['skipped'],
|
||||
'patterns_refreshed': patterns_ok,
|
||||
'mv_refreshed': mv_ok,
|
||||
}
|
||||
_logger.info(
|
||||
"fusion_accounting_bank_rec bootstrap complete: %s", result)
|
||||
return result
|
||||
|
||||
def action_run_migration(self):
|
||||
"""Override the migration entry-point to add the bank-rec step.
|
||||
|
||||
Calls super() (which currently returns a notification stub from
|
||||
Phase 0) and then runs the bank-rec bootstrap. Returns a
|
||||
notification summarizing both.
|
||||
"""
|
||||
_ = super().action_run_migration()
|
||||
result = self._bank_rec_bootstrap_step()
|
||||
return {
|
||||
'type': 'ir.actions.client',
|
||||
'tag': 'display_notification',
|
||||
'params': {
|
||||
'type': 'success',
|
||||
'title': _("Bank-Rec Migration Complete"),
|
||||
'message': _(
|
||||
"Backfilled %(created)d precedents "
|
||||
"(skipped %(skipped)d). "
|
||||
"Patterns refreshed: %(p)s. MV refreshed: %(m)s."
|
||||
) % {
|
||||
'created': result['precedents_created'],
|
||||
'skipped': result['precedents_skipped'],
|
||||
'p': 'yes' if result['patterns_refreshed'] else 'no',
|
||||
'm': 'yes' if result['mv_refreshed'] else 'no',
|
||||
},
|
||||
'sticky': False,
|
||||
},
|
||||
}
|
||||
481
fusion_accounting_bank_rec/models/fusion_reconcile_engine.py
Normal file
481
fusion_accounting_bank_rec/models/fusion_reconcile_engine.py
Normal file
@@ -0,0 +1,481 @@
|
||||
"""The reconcile engine — orchestrator for all bank-line reconciliations.
|
||||
|
||||
Public API: 6 methods. All other code (controllers, AI tools, wizards)
|
||||
must go through these methods; no direct ORM writes to
|
||||
``account.partial.reconcile`` from anywhere else.
|
||||
|
||||
V19 mechanics (per Enterprise's bank_rec_widget pattern):
|
||||
|
||||
A bank statement line creates an ``account.move`` with two journal
|
||||
items: a *liquidity* line on the journal's default account, and a
|
||||
*suspense* line on the journal's suspense account. Reconciliation
|
||||
replaces the suspense line with one or more *counterpart* lines posted
|
||||
to the matched invoices' receivable / payable accounts (or the write-off
|
||||
account), then calls Odoo's standard ``account.move.line.reconcile()``
|
||||
on each counterpart + invoice pair.
|
||||
|
||||
Internal pipeline (per spec Section 3.3):
|
||||
|
||||
1. Validate (period not locked, mandatory args present).
|
||||
2. Compute counterpart vals from ``against_lines`` and optional write-off.
|
||||
3. Rewrite the bank move ``line_ids``: keep liquidity, drop suspense +
|
||||
any prior other lines, append the new counterparts.
|
||||
4. Reconcile each counterpart with its matched invoice line.
|
||||
5. Audit (``mail.message``) + record precedent for future learning.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo.exceptions import ValidationError
|
||||
from odoo.fields import Command
|
||||
|
||||
from ..services.matching_strategies import (
|
||||
AmountExactStrategy,
|
||||
Candidate,
|
||||
FIFOStrategy,
|
||||
MultiInvoiceStrategy,
|
||||
)
|
||||
from ..services.confidence_scoring import score_candidates
|
||||
from ..services.memo_tokenizer import tokenize_memo
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionReconcileEngine(models.AbstractModel):
|
||||
_name = "fusion.reconcile.engine"
|
||||
_description = "Fusion Bank Reconciliation Engine"
|
||||
|
||||
# ============================================================
|
||||
# PUBLIC API (6 methods)
|
||||
# ============================================================
|
||||
|
||||
@api.model
|
||||
def reconcile_one(self, statement_line, *, against_lines=None,
|
||||
write_off_vals=None):
|
||||
"""Reconcile one bank line against a set of journal items.
|
||||
|
||||
Returns: ``{'partial_ids': [...], 'exchange_diff_move_id': int|None,
|
||||
'write_off_move_id': int|None}``
|
||||
"""
|
||||
if not statement_line:
|
||||
raise ValidationError(_("statement_line is required"))
|
||||
statement_line.ensure_one()
|
||||
AML = self.env['account.move.line']
|
||||
against_lines = against_lines or AML
|
||||
if not against_lines and not write_off_vals:
|
||||
raise ValidationError(
|
||||
_("Either against_lines or write_off_vals required"))
|
||||
|
||||
self._validate_reconcile(statement_line, against_lines)
|
||||
|
||||
bank_move = statement_line.move_id
|
||||
liquidity_lines, suspense_lines, other_lines = (
|
||||
statement_line._seek_for_lines())
|
||||
|
||||
# The bank move must stay balanced after we rewrite line_ids.
|
||||
# Liquidity sums to +bank_amount (or -bank_amount for outbound), so
|
||||
# the new counterparts must sum to the inverse. We allocate the
|
||||
# available bank amount across against_lines, clamped to each
|
||||
# invoice's residual; any leftover goes to the write-off line (or
|
||||
# raises if no write-off was requested).
|
||||
liq_balance = sum(liquidity_lines.mapped('balance'))
|
||||
# Available counterpart balance (positive magnitude) = abs(liq_balance)
|
||||
remaining = abs(liq_balance)
|
||||
# Counterparts mirror liquidity: opposite sign of liq_balance.
|
||||
cp_sign = -1 if liq_balance >= 0 else 1
|
||||
|
||||
new_counterpart_vals = []
|
||||
for inv_line in against_lines:
|
||||
inv_residual = inv_line.amount_residual
|
||||
# Clamp so we never write more than the invoice residual nor more
|
||||
# than what the bank line can pay.
|
||||
allocate = min(remaining, abs(inv_residual))
|
||||
new_counterpart_vals.append(self._build_counterpart_vals(
|
||||
statement_line, inv_line,
|
||||
allocated_balance=cp_sign * allocate,
|
||||
))
|
||||
remaining -= allocate
|
||||
if remaining <= 0:
|
||||
break
|
||||
|
||||
write_off_move_id = None
|
||||
if write_off_vals:
|
||||
# Write-off absorbs whatever the against_lines didn't cover.
|
||||
wo_balance = cp_sign * remaining
|
||||
# If user passed an explicit amount and there are no against_lines,
|
||||
# honour the explicit amount (covers the pure write-off case).
|
||||
if (write_off_vals.get('amount') is not None
|
||||
and not against_lines):
|
||||
wo_balance = -write_off_vals['amount']
|
||||
new_counterpart_vals.append(self._build_write_off_vals(
|
||||
statement_line, write_off_vals, balance=wo_balance,
|
||||
))
|
||||
remaining = 0
|
||||
|
||||
# Replace the bank move line_ids: keep liquidity, drop everything
|
||||
# else, append new counterparts.
|
||||
ops = []
|
||||
for line in (suspense_lines | other_lines):
|
||||
ops.append(Command.unlink(line.id))
|
||||
for vals in new_counterpart_vals:
|
||||
ops.append(Command.create(vals))
|
||||
|
||||
editable_move = bank_move.with_context(
|
||||
force_delete=True, skip_readonly_check=True)
|
||||
prior_line_ids = set(bank_move.line_ids.ids)
|
||||
editable_move.write({'line_ids': ops})
|
||||
|
||||
new_lines = bank_move.line_ids.filtered(
|
||||
lambda line: line.id not in prior_line_ids)
|
||||
|
||||
# Reconcile each new counterpart with its matched invoice line.
|
||||
# The first N new lines correspond to the first N against_lines
|
||||
# (where N may be < len(against_lines) if the bank amount ran out).
|
||||
# Any trailing new line is a write-off and has no invoice pair.
|
||||
Partial = self.env['account.partial.reconcile']
|
||||
new_partial_ids = []
|
||||
invoice_counterparts = new_lines[:min(len(new_lines),
|
||||
len(against_lines))]
|
||||
for new_line, inv_line in zip(invoice_counterparts, against_lines):
|
||||
pair = new_line | inv_line
|
||||
existing = set(Partial.search([
|
||||
'|',
|
||||
('debit_move_id', 'in', pair.ids),
|
||||
('credit_move_id', 'in', pair.ids),
|
||||
]).ids)
|
||||
pair.reconcile()
|
||||
added = Partial.search([
|
||||
'|',
|
||||
('debit_move_id', 'in', pair.ids),
|
||||
('credit_move_id', 'in', pair.ids),
|
||||
]).filtered(lambda p: p.id not in existing)
|
||||
new_partial_ids.extend(added.ids)
|
||||
|
||||
self._post_audit(
|
||||
statement_line, new_partial_ids, source='engine.reconcile_one')
|
||||
if against_lines:
|
||||
self._record_precedent(statement_line, against_lines)
|
||||
|
||||
return {
|
||||
'partial_ids': new_partial_ids,
|
||||
'exchange_diff_move_id': None,
|
||||
'write_off_move_id': write_off_move_id,
|
||||
}
|
||||
|
||||
@api.model
|
||||
def reconcile_batch(self, statement_lines, *, strategy='auto'):
|
||||
"""Bulk-reconcile a recordset using the chosen strategy.
|
||||
|
||||
Returns: ``{'reconciled_count': int, 'skipped': int,
|
||||
'errors': [...]}``
|
||||
"""
|
||||
reconciled = 0
|
||||
skipped = 0
|
||||
errors = []
|
||||
for line in statement_lines:
|
||||
if line.is_reconciled:
|
||||
skipped += 1
|
||||
continue
|
||||
# Per-line savepoint so a single DB-level failure (e.g. a
|
||||
# check-constraint violation on one bad line) doesn't poison
|
||||
# the whole batch's transaction.
|
||||
try:
|
||||
with self.env.cr.savepoint():
|
||||
candidates = self._fetch_candidates(line)
|
||||
picked = self._apply_strategy(
|
||||
line, candidates, strategy)
|
||||
if picked:
|
||||
self.reconcile_one(line, against_lines=picked)
|
||||
reconciled += 1
|
||||
else:
|
||||
skipped += 1
|
||||
except Exception as e: # noqa: BLE001
|
||||
errors.append({'line_id': line.id, 'error': str(e)})
|
||||
_logger.warning(
|
||||
"reconcile_batch failed for line %s: %s", line.id, e)
|
||||
return {
|
||||
'reconciled_count': reconciled,
|
||||
'skipped': skipped,
|
||||
'errors': errors,
|
||||
}
|
||||
|
||||
@api.model
|
||||
def suggest_matches(self, statement_lines, *, limit_per_line=3):
|
||||
"""Compute and persist AI suggestions per line.
|
||||
|
||||
Returns: dict mapping ``line_id`` -> list of suggestion dicts.
|
||||
"""
|
||||
out = {}
|
||||
Suggestion = self.env['fusion.reconcile.suggestion']
|
||||
for line in statement_lines:
|
||||
candidates_records = self._fetch_candidates(line)
|
||||
if not candidates_records:
|
||||
continue
|
||||
candidates_dataclasses = self._records_to_candidates(
|
||||
line, candidates_records)
|
||||
scored = score_candidates(
|
||||
self.env,
|
||||
statement_line=line,
|
||||
candidates=candidates_dataclasses,
|
||||
k=limit_per_line,
|
||||
use_ai=True,
|
||||
)
|
||||
|
||||
Suggestion.search([
|
||||
('statement_line_id', '=', line.id),
|
||||
('state', '=', 'pending'),
|
||||
]).write({'state': 'superseded'})
|
||||
|
||||
line_suggestions = []
|
||||
for rank, s in enumerate(scored, start=1):
|
||||
sug = Suggestion.create({
|
||||
'company_id': line.company_id.id,
|
||||
'statement_line_id': line.id,
|
||||
'proposed_move_line_ids': [(6, 0, [s.candidate_id])],
|
||||
'confidence': s.confidence,
|
||||
'rank': rank,
|
||||
'reasoning': s.reasoning,
|
||||
'score_amount_match': s.score_amount_match,
|
||||
'score_partner_pattern': s.score_partner_pattern,
|
||||
'score_precedent_similarity': s.score_precedent_similarity,
|
||||
'score_ai_rerank': s.score_ai_rerank,
|
||||
'generated_by': 'on_demand',
|
||||
'state': 'pending',
|
||||
})
|
||||
line_suggestions.append({
|
||||
'id': sug.id,
|
||||
'rank': rank,
|
||||
'confidence': s.confidence,
|
||||
'reasoning': s.reasoning,
|
||||
'candidate_id': s.candidate_id,
|
||||
})
|
||||
out[line.id] = line_suggestions
|
||||
return out
|
||||
|
||||
@api.model
|
||||
def accept_suggestion(self, suggestion):
|
||||
"""User clicked Accept on a suggestion -> reconcile via its proposal.
|
||||
|
||||
Returns: same shape as ``reconcile_one``.
|
||||
"""
|
||||
if isinstance(suggestion, int):
|
||||
suggestion = self.env['fusion.reconcile.suggestion'].browse(
|
||||
suggestion)
|
||||
suggestion.ensure_one()
|
||||
line = suggestion.statement_line_id
|
||||
against = suggestion.proposed_move_line_ids
|
||||
result = self.reconcile_one(line, against_lines=against)
|
||||
suggestion.write({
|
||||
'state': 'accepted',
|
||||
'accepted_at': fields.Datetime.now(),
|
||||
'accepted_by': self.env.uid,
|
||||
})
|
||||
return result
|
||||
|
||||
@api.model
|
||||
def write_off(self, statement_line, *, account, amount, label, tax_id=None):
|
||||
"""Create a write-off move + reconcile the bank line against it.
|
||||
|
||||
Returns: same shape as ``reconcile_one``.
|
||||
"""
|
||||
write_off_vals = {
|
||||
'account_id': account.id if hasattr(account, 'id') else account,
|
||||
'amount': amount,
|
||||
'tax_id': (tax_id.id if (tax_id and hasattr(tax_id, 'id'))
|
||||
else tax_id),
|
||||
'label': label,
|
||||
}
|
||||
return self.reconcile_one(
|
||||
statement_line, against_lines=None, write_off_vals=write_off_vals)
|
||||
|
||||
@api.model
|
||||
def unreconcile(self, partial_reconciles):
|
||||
"""Reverse a reconciliation. Handles full vs. partial chains.
|
||||
|
||||
Because ``reconcile_one`` rewrites the bank move's suspense line into
|
||||
one or more counterpart lines, simply deleting the
|
||||
``account.partial.reconcile`` rows is not enough — the bank move
|
||||
would still look reconciled (no suspense line, no residual). We
|
||||
delegate to V19's standard ``account.bank.statement.line.
|
||||
action_undo_reconciliation`` for any affected bank line, which
|
||||
clears the partials AND restores the original suspense state.
|
||||
|
||||
Returns: ``{'unreconciled_line_ids': [...]}``
|
||||
"""
|
||||
partial_reconciles = partial_reconciles.exists()
|
||||
if not partial_reconciles:
|
||||
return {'unreconciled_line_ids': []}
|
||||
all_lines = (
|
||||
partial_reconciles.mapped('debit_move_id')
|
||||
| partial_reconciles.mapped('credit_move_id')
|
||||
)
|
||||
line_ids = all_lines.ids
|
||||
# Find any bank statement lines whose move owns one of these journal
|
||||
# items; route them through the standard undo flow which both
|
||||
# deletes the partials and restores the suspense line.
|
||||
affected_bank_lines = self.env['account.bank.statement.line'].search([
|
||||
('move_id', 'in', all_lines.mapped('move_id').ids),
|
||||
])
|
||||
if affected_bank_lines:
|
||||
affected_bank_lines.action_undo_reconciliation()
|
||||
# Anything still hanging around (rare — non-bank-line reconciles)
|
||||
# gets a direct unlink as a fallback.
|
||||
remaining = partial_reconciles.exists()
|
||||
if remaining:
|
||||
remaining.unlink()
|
||||
return {'unreconciled_line_ids': line_ids}
|
||||
|
||||
# ============================================================
|
||||
# PRIVATE HELPERS
|
||||
# ============================================================
|
||||
|
||||
def _validate_reconcile(self, statement_line, against_lines):
|
||||
"""Phase 2: structural + safety checks."""
|
||||
if not statement_line.exists():
|
||||
raise ValidationError(_("Statement line does not exist"))
|
||||
company = statement_line.company_id
|
||||
line_date = statement_line.date
|
||||
lock_date = company.fiscalyear_lock_date
|
||||
if lock_date and line_date and line_date <= lock_date:
|
||||
raise ValidationError(_(
|
||||
"Cannot reconcile: line date %(line)s is on or before fiscal "
|
||||
"year lock date %(lock)s",
|
||||
line=line_date,
|
||||
lock=lock_date,
|
||||
))
|
||||
|
||||
def _build_counterpart_vals(self, statement_line, inv_line, *,
|
||||
allocated_balance):
|
||||
"""Build the vals for one counterpart line that mirrors an invoice
|
||||
line on the bank move.
|
||||
|
||||
``allocated_balance`` is the signed company-currency balance to write
|
||||
on the counterpart. It is clamped (by the caller) so that the bank
|
||||
move stays balanced and no invoice gets over-paid. We scale
|
||||
``amount_currency`` proportionally for multi-currency lines.
|
||||
"""
|
||||
inv_residual = inv_line.amount_residual
|
||||
if inv_residual:
|
||||
scale = abs(allocated_balance) / abs(inv_residual)
|
||||
else:
|
||||
scale = 1.0
|
||||
amount_currency = -inv_line.amount_residual_currency * scale
|
||||
return {
|
||||
'name': inv_line.name or statement_line.payment_ref or '',
|
||||
'account_id': inv_line.account_id.id,
|
||||
'partner_id': (inv_line.partner_id.id
|
||||
if inv_line.partner_id else False),
|
||||
'currency_id': inv_line.currency_id.id,
|
||||
'amount_currency': amount_currency,
|
||||
'balance': allocated_balance,
|
||||
}
|
||||
|
||||
def _build_write_off_vals(self, statement_line, write_off_vals, *,
|
||||
balance):
|
||||
"""Build the vals for a write-off counterpart line on the bank move.
|
||||
|
||||
``balance`` is the signed company-currency balance the write-off
|
||||
line must carry to keep the bank move balanced.
|
||||
"""
|
||||
vals = {
|
||||
'name': write_off_vals.get('label') or _('Write-off'),
|
||||
'account_id': write_off_vals['account_id'],
|
||||
'partner_id': (statement_line.partner_id.id
|
||||
if statement_line.partner_id else False),
|
||||
'balance': balance,
|
||||
}
|
||||
if write_off_vals.get('tax_id'):
|
||||
vals['tax_ids'] = [(6, 0, [write_off_vals['tax_id']])]
|
||||
return vals
|
||||
|
||||
def _fetch_candidates(self, statement_line):
|
||||
"""SQL pre-filter: open journal items matching partner + reconcilable
|
||||
account."""
|
||||
domain = [
|
||||
('parent_state', '=', 'posted'),
|
||||
('account_id.reconcile', '=', True),
|
||||
('reconciled', '=', False),
|
||||
('display_type', 'not in', ('line_section', 'line_note')),
|
||||
]
|
||||
if statement_line.partner_id:
|
||||
domain.append(('partner_id', '=', statement_line.partner_id.id))
|
||||
return self.env['account.move.line'].search(domain, limit=200)
|
||||
|
||||
def _records_to_candidates(self, statement_line, records):
|
||||
"""Convert ``account.move.line`` recordset to ``Candidate`` dataclasses."""
|
||||
today = fields.Date.today()
|
||||
result = []
|
||||
for c in records:
|
||||
ref_date = c.date_maturity or c.date or today
|
||||
age_days = (today - ref_date).days
|
||||
result.append(Candidate(
|
||||
id=c.id,
|
||||
amount=abs(c.amount_residual) or abs(c.balance),
|
||||
partner_id=c.partner_id.id if c.partner_id else 0,
|
||||
age_days=age_days,
|
||||
))
|
||||
return result
|
||||
|
||||
def _apply_strategy(self, line, candidate_records, strategy):
|
||||
"""Apply the named strategy. Returns matching ``account.move.line``
|
||||
recordset, or empty recordset if nothing matched."""
|
||||
AML = self.env['account.move.line']
|
||||
if not candidate_records:
|
||||
return AML
|
||||
candidate_dcs = self._records_to_candidates(line, candidate_records)
|
||||
bank_amount = abs(line.amount)
|
||||
if strategy == 'auto':
|
||||
for strat_class in (AmountExactStrategy,
|
||||
MultiInvoiceStrategy,
|
||||
FIFOStrategy):
|
||||
result = strat_class().match(
|
||||
bank_amount=bank_amount, candidates=candidate_dcs)
|
||||
if result.picked_ids:
|
||||
return AML.browse(result.picked_ids)
|
||||
return AML
|
||||
|
||||
def _post_audit(self, statement_line, partial_ids, source):
|
||||
"""Append an audit log to the bank-line move's chatter."""
|
||||
if not statement_line.move_id:
|
||||
return
|
||||
try:
|
||||
statement_line.move_id.message_post(
|
||||
body=_(
|
||||
"Reconciled via %(source)s; %(count)d partial(s) created: "
|
||||
"%(ids)s",
|
||||
source=source,
|
||||
count=len(partial_ids),
|
||||
ids=partial_ids,
|
||||
),
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
_logger.debug(
|
||||
"Audit log skipped for line %s: %s", statement_line.id, e)
|
||||
|
||||
def _record_precedent(self, statement_line, against_lines):
|
||||
"""Append a precedent for future pattern learning. Best-effort."""
|
||||
if not against_lines:
|
||||
return
|
||||
try:
|
||||
self.env['fusion.reconcile.precedent'].sudo().create({
|
||||
'company_id': statement_line.company_id.id,
|
||||
'partner_id': (statement_line.partner_id.id
|
||||
if statement_line.partner_id else False),
|
||||
'amount': abs(statement_line.amount),
|
||||
'currency_id': statement_line.currency_id.id,
|
||||
'date': statement_line.date,
|
||||
'memo_tokens': ','.join(
|
||||
tokenize_memo(statement_line.payment_ref)),
|
||||
'journal_id': statement_line.journal_id.id,
|
||||
'matched_move_line_count': len(against_lines),
|
||||
'matched_account_ids': ','.join(
|
||||
str(i) for i in against_lines.mapped('account_id').ids),
|
||||
'reconciler_user_id': self.env.uid,
|
||||
'reconciled_at': fields.Datetime.now(),
|
||||
'source': 'manual',
|
||||
})
|
||||
except Exception as e: # noqa: BLE001
|
||||
_logger.warning(
|
||||
"Failed to record precedent for line %s: %s",
|
||||
statement_line.id, e)
|
||||
@@ -0,0 +1,55 @@
|
||||
"""Per-partner bank reconciliation pattern aggregate.
|
||||
|
||||
One row per (company_id, partner_id). Continuously summarises HOW this
|
||||
partner gets reconciled. Recomputed nightly via cron from the precedent
|
||||
table. Used as a feature input to confidence_scoring.
|
||||
"""
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class FusionReconcilePattern(models.Model):
|
||||
_name = "fusion.reconcile.pattern"
|
||||
_description = "Per-partner bank reconciliation pattern aggregate"
|
||||
_rec_name = "partner_id"
|
||||
|
||||
company_id = fields.Many2one('res.company', required=True, index=True,
|
||||
default=lambda self: self.env.company)
|
||||
partner_id = fields.Many2one('res.partner', required=True, index=True)
|
||||
|
||||
# Volume + cadence
|
||||
reconcile_count = fields.Integer(default=0,
|
||||
help="Total past reconciles for this partner")
|
||||
typical_amount_range = fields.Char(
|
||||
help="e.g. '$1,200 – $2,400 (median $1,847.50)'")
|
||||
typical_cadence_days = fields.Float(
|
||||
help="Mean inter-reconcile days")
|
||||
typical_day_of_month = fields.Char(
|
||||
help="e.g. '1st, 15th'")
|
||||
|
||||
# Matching strategy used historically
|
||||
pref_strategy = fields.Selection([
|
||||
('exact_amount', 'Exact-amount-first'),
|
||||
('fifo', 'FIFO oldest-due-first'),
|
||||
('multi_invoice', 'Multi-invoice consolidation'),
|
||||
('cherry_pick', 'Cherry-pick specific invoices'),
|
||||
])
|
||||
pref_account_id = fields.Many2one('account.account',
|
||||
help="Most-used target account")
|
||||
|
||||
# Memo signature
|
||||
common_memo_tokens = fields.Char(
|
||||
help="Comma-separated tokens that appear in ≥30% of past reconciles")
|
||||
|
||||
# Tax + write-off habits
|
||||
common_writeoff_account_id = fields.Many2one('account.account')
|
||||
common_writeoff_tax_id = fields.Many2one('account.tax')
|
||||
typical_writeoff_amount = fields.Float(
|
||||
help="e.g. 0.05 for rounding diffs")
|
||||
|
||||
last_refreshed_at = fields.Datetime()
|
||||
|
||||
_uniq_company_partner = models.Constraint(
|
||||
'unique(company_id, partner_id)',
|
||||
'One pattern row per (company, partner) — already exists.',
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
"""Per-historical-decision reconciliation memory.
|
||||
|
||||
One row per past reconciliation. Holds the full feature vector + outcome,
|
||||
used by precedent_lookup for K-nearest-neighbour search when scoring a
|
||||
new bank line.
|
||||
"""
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class FusionReconcilePrecedent(models.Model):
|
||||
_name = "fusion.reconcile.precedent"
|
||||
_description = "Historical bank reconciliation decision (memory)"
|
||||
_order = "reconciled_at desc, id desc"
|
||||
|
||||
company_id = fields.Many2one('res.company', required=True, index=True,
|
||||
default=lambda self: self.env.company)
|
||||
partner_id = fields.Many2one('res.partner', index=True)
|
||||
|
||||
# Bank line features (the "input")
|
||||
amount = fields.Monetary(currency_field='currency_id')
|
||||
currency_id = fields.Many2one('res.currency')
|
||||
date = fields.Date()
|
||||
memo_tokens = fields.Char(
|
||||
help="Comma-separated normalized memo tokens (output of memo_tokenizer)")
|
||||
journal_id = fields.Many2one('account.journal')
|
||||
|
||||
# Outcome (the "decision made")
|
||||
matched_move_line_count = fields.Integer(
|
||||
help="1 = exact, 2-3 = consolidation, etc.")
|
||||
matched_account_ids = fields.Char(
|
||||
help="Comma-separated account.account IDs that were matched against")
|
||||
matched_invoice_ages_days = fields.Char(
|
||||
help="Comma-separated days-old at reconcile time, e.g. '12, 45, 78'")
|
||||
write_off_amount = fields.Float()
|
||||
write_off_account_id = fields.Many2one('account.account')
|
||||
exchange_diff = fields.Boolean()
|
||||
|
||||
# Provenance
|
||||
reconciler_user_id = fields.Many2one('res.users')
|
||||
reconciled_at = fields.Datetime()
|
||||
source = fields.Selection([
|
||||
('historical_bootstrap', 'Imported from history'),
|
||||
('backfill', 'Backfilled from account.partial.reconcile (migration)'),
|
||||
('manual', 'Manual reconcile via fusion'),
|
||||
('ai_accepted', 'AI suggestion accepted'),
|
||||
('auto_rule', 'account.reconcile.model auto-fired'),
|
||||
], required=True)
|
||||
|
||||
# No uniqueness constraint — multiple reconciles can share features
|
||||
137
fusion_accounting_bank_rec/models/fusion_reconcile_suggestion.py
Normal file
137
fusion_accounting_bank_rec/models/fusion_reconcile_suggestion.py
Normal file
@@ -0,0 +1,137 @@
|
||||
"""Persisted AI suggestions for bank line reconciliations.
|
||||
|
||||
One row per (statement_line, candidate_match). The OWL widget reads these
|
||||
to render confidence badges; users accept/reject which feeds back into
|
||||
the pattern learning system.
|
||||
|
||||
The AI never writes account.partial.reconcile directly — it writes
|
||||
suggestions here, and the user (or batch-accept action) approves them
|
||||
through the engine's accept_suggestion() method.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionReconcileSuggestion(models.Model):
|
||||
_name = "fusion.reconcile.suggestion"
|
||||
_description = "AI-generated bank reconciliation suggestion"
|
||||
_order = "statement_line_id, confidence desc"
|
||||
|
||||
company_id = fields.Many2one('res.company', required=True, index=True,
|
||||
default=lambda self: self.env.company)
|
||||
statement_line_id = fields.Many2one('account.bank.statement.line',
|
||||
required=True, index=True, ondelete='cascade')
|
||||
|
||||
# The proposal
|
||||
proposed_move_line_ids = fields.Many2many('account.move.line',
|
||||
string="Proposed matches")
|
||||
proposed_write_off_amount = fields.Monetary(currency_field='currency_id')
|
||||
proposed_write_off_account_id = fields.Many2one('account.account')
|
||||
currency_id = fields.Many2one('res.currency',
|
||||
related='statement_line_id.currency_id',
|
||||
store=True)
|
||||
|
||||
# Scoring
|
||||
confidence = fields.Float(required=True)
|
||||
confidence_band = fields.Selection([
|
||||
('high', 'High (>=95%)'),
|
||||
('medium', 'Medium (70-94%)'),
|
||||
('low', 'Low (50-69%)'),
|
||||
('none', 'No confidence (<50%)'),
|
||||
], compute='_compute_band', store=True)
|
||||
rank = fields.Integer(help="1 = top suggestion, 2-N = alternatives")
|
||||
reasoning = fields.Text(help="Human-readable explanation")
|
||||
|
||||
# Feature breakdown (for transparency + future learning)
|
||||
score_amount_match = fields.Float()
|
||||
score_partner_pattern = fields.Float()
|
||||
score_precedent_similarity = fields.Float()
|
||||
score_ai_rerank = fields.Float()
|
||||
|
||||
# Provenance
|
||||
generated_at = fields.Datetime(default=fields.Datetime.now)
|
||||
generated_by = fields.Selection([
|
||||
('cron_batch', 'Batch cron'),
|
||||
('on_demand', 'User refreshed alternatives'),
|
||||
('on_open', 'Widget opened (lazy)'),
|
||||
])
|
||||
provider_used = fields.Char(
|
||||
help="e.g. 'claude_sonnet_4_5', 'lmstudio_qwen_7b', 'statistical_only'")
|
||||
tokens_used = fields.Integer(help="if AI re-rank invoked")
|
||||
generation_ms = fields.Integer(help="latency for monitoring")
|
||||
|
||||
# Lifecycle
|
||||
state = fields.Selection([
|
||||
('pending', 'Pending review'),
|
||||
('accepted', 'Accepted'),
|
||||
('rejected', 'Rejected'),
|
||||
('superseded', 'Superseded by newer suggestion'),
|
||||
('stale', 'Stale (line changed since)'),
|
||||
], default='pending', required=True, index=True)
|
||||
accepted_at = fields.Datetime()
|
||||
accepted_by = fields.Many2one('res.users')
|
||||
rejected_at = fields.Datetime()
|
||||
rejected_reason = fields.Selection([
|
||||
('wrong_invoice', 'Wrong invoice'),
|
||||
('wrong_partner', 'Wrong partner'),
|
||||
('wrong_amount', 'Amount off'),
|
||||
('not_a_match', 'No good match exists'),
|
||||
('other', 'Other'),
|
||||
])
|
||||
|
||||
_confidence_in_range = models.Constraint(
|
||||
'CHECK (confidence >= 0.0 AND confidence <= 1.0)',
|
||||
'Confidence must be between 0.0 and 1.0',
|
||||
)
|
||||
|
||||
@api.depends('confidence')
|
||||
def _compute_band(self):
|
||||
for sug in self:
|
||||
c = sug.confidence
|
||||
if c >= 0.95:
|
||||
sug.confidence_band = 'high'
|
||||
elif c >= 0.70:
|
||||
sug.confidence_band = 'medium'
|
||||
elif c >= 0.50:
|
||||
sug.confidence_band = 'low'
|
||||
else:
|
||||
sug.confidence_band = 'none'
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# CRUD overrides — trigger MV refresh so the OWL widget sees fresh
|
||||
# confidence bands / top suggestion ids without waiting for cron.
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
records = super().create(vals_list)
|
||||
self._trigger_mv_refresh()
|
||||
return records
|
||||
|
||||
def write(self, vals):
|
||||
res = super().write(vals)
|
||||
# Only refresh on changes that affect the MV's projected columns.
|
||||
if 'state' in vals or 'confidence' in vals or 'rank' in vals:
|
||||
self._trigger_mv_refresh()
|
||||
return res
|
||||
|
||||
def _trigger_mv_refresh(self):
|
||||
"""Best-effort MV refresh; never poison the originating transaction.
|
||||
|
||||
Uses concurrently=False because Postgres forbids
|
||||
REFRESH MATERIALIZED VIEW CONCURRENTLY inside a transaction block,
|
||||
and Odoo's per-request cursor is always in a transaction. The cron
|
||||
job (Task 25) opens a dedicated autocommit cursor for CONCURRENTLY
|
||||
refreshes when the MV grows large enough that a brief blocking
|
||||
refresh becomes objectionable.
|
||||
"""
|
||||
try:
|
||||
self.env['fusion.unreconciled.bank.line.mv']._refresh(
|
||||
concurrently=False)
|
||||
except Exception as e: # noqa: BLE001
|
||||
_logger.warning(
|
||||
"MV refresh after suggestion write failed: %s", e)
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Materialized view exposing pre-aggregated unreconciled-bank-line data.
|
||||
|
||||
The MV is created in the model's init() (called by Odoo on install/upgrade).
|
||||
Refresh strategy:
|
||||
- Cron (every 5 min) — see fusion_accounting_bank_rec/data/cron.xml (Task 25)
|
||||
- Triggered refresh after suggestion writes (handled in fusion_reconcile_suggestion.py)
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class FusionUnreconciledBankLineMV(models.Model):
|
||||
_name = "fusion.unreconciled.bank.line.mv"
|
||||
_description = "Materialized view of unreconciled bank lines for OWL widget"
|
||||
_auto = False # we manage the table ourselves
|
||||
_table = "fusion_unreconciled_bank_line_mv"
|
||||
_order = "date desc, id desc"
|
||||
|
||||
# Fields mirror the columns in the SQL view; required so Odoo can read them.
|
||||
company_id = fields.Many2one('res.company', readonly=True)
|
||||
journal_id = fields.Many2one('account.journal', readonly=True)
|
||||
date = fields.Date(readonly=True)
|
||||
amount = fields.Float(readonly=True)
|
||||
payment_ref = fields.Char(readonly=True)
|
||||
currency_id = fields.Many2one('res.currency', readonly=True)
|
||||
partner_id = fields.Many2one('res.partner', readonly=True)
|
||||
create_date = fields.Datetime(readonly=True)
|
||||
top_suggestion_id = fields.Many2one('fusion.reconcile.suggestion', readonly=True)
|
||||
top_confidence = fields.Float(readonly=True)
|
||||
confidence_band = fields.Selection([
|
||||
('high', 'High'),
|
||||
('medium', 'Medium'),
|
||||
('low', 'Low'),
|
||||
('none', 'None'),
|
||||
], readonly=True)
|
||||
attachment_count = fields.Integer(readonly=True)
|
||||
partner_reconcile_count = fields.Integer(readonly=True)
|
||||
|
||||
def init(self):
|
||||
"""Create the MV if missing.
|
||||
|
||||
Reads create_mv_unreconciled_bank_line.sql and executes it. Idempotent
|
||||
because the SQL uses CREATE MATERIALIZED VIEW IF NOT EXISTS."""
|
||||
sql_path = os.path.join(
|
||||
os.path.dirname(__file__), '..', 'data', 'sql',
|
||||
'create_mv_unreconciled_bank_line.sql')
|
||||
with open(sql_path, 'r') as f:
|
||||
sql = f.read()
|
||||
self.env.cr.execute(sql)
|
||||
_logger.info(
|
||||
"fusion_unreconciled_bank_line_mv: created/verified MV + indexes")
|
||||
|
||||
@api.model
|
||||
def _refresh(self, *, concurrently=True):
|
||||
"""Refresh the MV.
|
||||
|
||||
If ``concurrently=True`` (default), uses
|
||||
REFRESH MATERIALIZED VIEW CONCURRENTLY (requires the unique index).
|
||||
Falls back to a blocking refresh on the first refresh after creation
|
||||
(when CONCURRENTLY is not yet allowed because the MV has never been
|
||||
populated).
|
||||
|
||||
Flushes the ORM cache first so the materialization sees the latest
|
||||
committed-to-DB values for fields like ``is_reconciled`` (computed,
|
||||
stored — sometimes still buffered in the cache mid-request)."""
|
||||
self.env.flush_all()
|
||||
keyword = "CONCURRENTLY" if concurrently else ""
|
||||
try:
|
||||
self.env.cr.execute(
|
||||
f"REFRESH MATERIALIZED VIEW {keyword} fusion_unreconciled_bank_line_mv"
|
||||
)
|
||||
_logger.debug(
|
||||
"fusion_unreconciled_bank_line_mv refreshed (%s)",
|
||||
'concurrent' if concurrently else 'blocking')
|
||||
except Exception as e: # noqa: BLE001
|
||||
# CONCURRENTLY fails on first refresh after creation if the MV is
|
||||
# empty / has never been populated; fall back to non-concurrent.
|
||||
if concurrently:
|
||||
_logger.warning(
|
||||
"Concurrent MV refresh failed (%s); falling back to "
|
||||
"blocking refresh", e)
|
||||
self.env.cr.execute(
|
||||
"REFRESH MATERIALIZED VIEW fusion_unreconciled_bank_line_mv"
|
||||
)
|
||||
else:
|
||||
raise
|
||||
1
fusion_accounting_bank_rec/reports/__init__.py
Normal file
1
fusion_accounting_bank_rec/reports/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
from . import migration_audit_report
|
||||
51
fusion_accounting_bank_rec/reports/migration_audit_report.py
Normal file
51
fusion_accounting_bank_rec/reports/migration_audit_report.py
Normal file
@@ -0,0 +1,51 @@
|
||||
"""QWeb PDF report: summary of bank-rec migration outcomes.
|
||||
|
||||
Triggered from the migration wizard's "Print" menu after the wizard
|
||||
completes. For each company on the system, reports:
|
||||
- Backfilled precedents (source='backfill')
|
||||
- Fusion reconcile patterns
|
||||
- Bank statement lines still unreconciled
|
||||
|
||||
Lets the operator confirm Phase 1 migration successfully bootstrapped
|
||||
the AI's reconcile memory from past Enterprise reconciles.
|
||||
"""
|
||||
|
||||
from odoo import api, models
|
||||
|
||||
|
||||
class FusionMigrationAuditReport(models.AbstractModel):
|
||||
_name = "report.fusion_accounting_bank_rec.migration_audit_template"
|
||||
_description = "Bank-Rec Migration Audit Report"
|
||||
|
||||
@api.model
|
||||
def _get_report_values(self, docids, data=None):
|
||||
Wizard = self.env['fusion.migration.wizard']
|
||||
wizards = Wizard.browse(docids) if docids else Wizard
|
||||
|
||||
Precedent = self.env['fusion.reconcile.precedent']
|
||||
Pattern = self.env['fusion.reconcile.pattern']
|
||||
Line = self.env['account.bank.statement.line']
|
||||
|
||||
company_stats = []
|
||||
for company in self.env['res.company'].search([]):
|
||||
company_stats.append({
|
||||
'company': company,
|
||||
'precedents_count': Precedent.search_count([
|
||||
('company_id', '=', company.id),
|
||||
('source', '=', 'backfill'),
|
||||
]),
|
||||
'patterns_count': Pattern.search_count([
|
||||
('company_id', '=', company.id),
|
||||
]),
|
||||
'unreconciled_count': Line.search_count([
|
||||
('company_id', '=', company.id),
|
||||
('is_reconciled', '=', False),
|
||||
]),
|
||||
})
|
||||
|
||||
return {
|
||||
'doc_ids': docids,
|
||||
'doc_model': 'fusion.migration.wizard',
|
||||
'docs': wizards,
|
||||
'company_stats': company_stats,
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="action_report_migration_audit" model="ir.actions.report">
|
||||
<field name="name">Bank-Rec Migration Audit</field>
|
||||
<field name="model">fusion.migration.wizard</field>
|
||||
<field name="report_type">qweb-pdf</field>
|
||||
<field name="report_name">fusion_accounting_bank_rec.migration_audit_template</field>
|
||||
<field name="report_file">fusion_accounting_bank_rec.migration_audit_template</field>
|
||||
<field name="binding_model_id" ref="fusion_accounting_migration.model_fusion_migration_wizard"/>
|
||||
<field name="binding_type">report</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<template id="migration_audit_template">
|
||||
<t t-call="web.html_container">
|
||||
<t t-call="web.external_layout">
|
||||
<div class="page">
|
||||
<h2>Bank-Rec Migration Audit</h2>
|
||||
<p>
|
||||
Generated
|
||||
<span t-esc="context_timestamp(datetime.datetime.now()).strftime('%Y-%m-%d %H:%M')"/>
|
||||
</p>
|
||||
|
||||
<h3>Per-Company Summary</h3>
|
||||
<table class="table table-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Company</th>
|
||||
<th class="text-end">Backfilled Precedents</th>
|
||||
<th class="text-end">Patterns</th>
|
||||
<th class="text-end">Still Unreconciled</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr t-foreach="company_stats" t-as="cs">
|
||||
<td><span t-esc="cs['company'].name"/></td>
|
||||
<td class="text-end"><span t-esc="cs['precedents_count']"/></td>
|
||||
<td class="text-end"><span t-esc="cs['patterns_count']"/></td>
|
||||
<td class="text-end"><span t-esc="cs['unreconciled_count']"/></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
<p class="text-muted">
|
||||
This report verifies that Phase 1 migration successfully
|
||||
bootstrapped the AI's reconcile memory from past Enterprise
|
||||
reconciles.
|
||||
</p>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
</template>
|
||||
</odoo>
|
||||
12
fusion_accounting_bank_rec/security/ir.model.access.csv
Normal file
12
fusion_accounting_bank_rec/security/ir.model.access.csv
Normal file
@@ -0,0 +1,12 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_fusion_reconcile_pattern_user,pattern user,model_fusion_reconcile_pattern,fusion_accounting_core.group_fusion_accounting_user,1,0,0,0
|
||||
access_fusion_reconcile_pattern_admin,pattern admin,model_fusion_reconcile_pattern,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1
|
||||
access_fusion_reconcile_precedent_user,precedent user,model_fusion_reconcile_precedent,fusion_accounting_core.group_fusion_accounting_user,1,0,0,0
|
||||
access_fusion_reconcile_precedent_admin,precedent admin,model_fusion_reconcile_precedent,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1
|
||||
access_fusion_reconcile_suggestion_user,suggestion user,model_fusion_reconcile_suggestion,fusion_accounting_core.group_fusion_accounting_user,1,0,0,0
|
||||
access_fusion_reconcile_suggestion_admin,suggestion admin,model_fusion_reconcile_suggestion,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1
|
||||
access_fusion_bank_rec_widget_user,bank rec widget user,model_fusion_bank_rec_widget,fusion_accounting_core.group_fusion_accounting_user,1,1,1,1
|
||||
access_fusion_unreconciled_bank_line_mv_user,unreconciled bank line mv user,model_fusion_unreconciled_bank_line_mv,fusion_accounting_core.group_fusion_accounting_user,1,0,0,0
|
||||
access_fusion_unreconciled_bank_line_mv_admin,unreconciled bank line mv admin,model_fusion_unreconciled_bank_line_mv,fusion_accounting_core.group_fusion_accounting_admin,1,0,0,0
|
||||
access_fusion_auto_reconcile_wizard_user,fusion.auto.reconcile.wizard.user,model_fusion_auto_reconcile_wizard,base.group_user,1,1,1,0
|
||||
access_fusion_bulk_reconcile_wizard_user,fusion.bulk.reconcile.wizard.user,model_fusion_bulk_reconcile_wizard,base.group_user,1,1,1,0
|
||||
|
7
fusion_accounting_bank_rec/services/__init__.py
Normal file
7
fusion_accounting_bank_rec/services/__init__.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from . import memo_tokenizer
|
||||
from . import exchange_diff
|
||||
from . import matching_strategies
|
||||
from . import precedent_lookup
|
||||
from . import pattern_extractor
|
||||
from . import confidence_scoring
|
||||
from . import precedent_backfill
|
||||
178
fusion_accounting_bank_rec/services/confidence_scoring.py
Normal file
178
fusion_accounting_bank_rec/services/confidence_scoring.py
Normal file
@@ -0,0 +1,178 @@
|
||||
"""4-pass confidence scoring pipeline.
|
||||
|
||||
Pass 1: SQL filter — partner match + reconcilable account (done by caller — engine._fetch_candidates)
|
||||
Pass 2: Statistical scoring — amount delta + pattern match + precedent similarity
|
||||
Pass 3: AI re-rank (if provider configured) — feed top 5 to LLM, parse JSON ranking
|
||||
Pass 4: Persist as fusion.reconcile.suggestion rows (done by caller — engine.suggest_matches)
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .matching_strategies import Candidate
|
||||
from .precedent_lookup import find_nearest_precedents
|
||||
from .memo_tokenizer import tokenize_memo
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ScoredCandidate:
|
||||
candidate_id: int
|
||||
confidence: float
|
||||
reasoning: str
|
||||
score_amount_match: float
|
||||
score_partner_pattern: float
|
||||
score_precedent_similarity: float
|
||||
score_ai_rerank: float = 0.0
|
||||
|
||||
|
||||
def score_candidates(env, *, statement_line, candidates, k=5, use_ai=True):
|
||||
"""Score and rank candidate matches for a statement line.
|
||||
|
||||
Args:
|
||||
env: Odoo env
|
||||
statement_line: account.bank.statement.line recordset (singleton)
|
||||
candidates: list of Candidate dataclasses (from matching_strategies)
|
||||
k: max number of scored candidates to return
|
||||
use_ai: if True AND a provider is configured, invoke AI re-rank
|
||||
|
||||
Returns:
|
||||
list of ScoredCandidate sorted by confidence desc, max length k.
|
||||
"""
|
||||
if not candidates or not statement_line:
|
||||
return []
|
||||
|
||||
partner_id = statement_line.partner_id.id if statement_line.partner_id else None
|
||||
bank_amount = abs(statement_line.amount)
|
||||
memo_tokens = tokenize_memo(statement_line.payment_ref)
|
||||
|
||||
pattern = None
|
||||
if partner_id:
|
||||
pattern = env['fusion.reconcile.pattern'].sudo().search(
|
||||
[('partner_id', '=', partner_id)], limit=1)
|
||||
if not pattern:
|
||||
pattern = None
|
||||
|
||||
precedents = []
|
||||
if partner_id:
|
||||
precedents = find_nearest_precedents(
|
||||
env, partner_id=partner_id, amount=bank_amount, k=5, memo_tokens=memo_tokens)
|
||||
|
||||
scored = []
|
||||
for cand in candidates:
|
||||
amount_score = 1.0 - min(abs(cand.amount - bank_amount) / max(bank_amount, 1), 1.0)
|
||||
pattern_score = _pattern_score(cand, pattern, bank_amount)
|
||||
precedent_score = _precedent_score(cand, precedents)
|
||||
confidence = (amount_score * 0.5) + (pattern_score * 0.25) + (precedent_score * 0.25)
|
||||
|
||||
reasoning = _build_reasoning(amount_score, pattern_score, precedent_score, pattern)
|
||||
scored.append(ScoredCandidate(
|
||||
candidate_id=cand.id,
|
||||
confidence=round(confidence, 3),
|
||||
reasoning=reasoning,
|
||||
score_amount_match=round(amount_score, 3),
|
||||
score_partner_pattern=round(pattern_score, 3),
|
||||
score_precedent_similarity=round(precedent_score, 3),
|
||||
))
|
||||
|
||||
scored.sort(key=lambda s: -s.confidence)
|
||||
top_k = scored[:k]
|
||||
|
||||
if use_ai:
|
||||
provider = _get_provider(env, 'bank_rec_suggest')
|
||||
if provider is not None:
|
||||
try:
|
||||
top_k = _ai_rerank(env, provider, statement_line, top_k, pattern, precedents)
|
||||
except Exception as e:
|
||||
_logger.warning("AI re-rank failed, using statistical scoring: %s", e)
|
||||
|
||||
return top_k
|
||||
|
||||
|
||||
def _pattern_score(cand, pattern, bank_amount) -> float:
|
||||
"""How well does this candidate fit the partner's typical pattern?"""
|
||||
if not pattern:
|
||||
return 0.5
|
||||
score = 0.5
|
||||
if pattern.pref_strategy == 'exact_amount' and abs(cand.amount - bank_amount) < 0.005:
|
||||
score = 1.0
|
||||
return score
|
||||
|
||||
|
||||
def _precedent_score(cand, precedents) -> float:
|
||||
"""How similar is this candidate to past precedents?"""
|
||||
if not precedents:
|
||||
return 0.5
|
||||
best = max((p.similarity_score for p in precedents), default=0.5)
|
||||
return best
|
||||
|
||||
|
||||
def _build_reasoning(amount_score, pattern_score, precedent_score, pattern) -> str:
|
||||
parts = []
|
||||
if amount_score >= 0.99:
|
||||
parts.append("Exact amount match")
|
||||
elif amount_score >= 0.95:
|
||||
parts.append("Amount close")
|
||||
if pattern and pattern.reconcile_count > 5:
|
||||
parts.append(f"Matches partner's {pattern.reconcile_count}-reconcile pattern")
|
||||
if precedent_score >= 0.8:
|
||||
parts.append("Strong precedent match")
|
||||
return " · ".join(parts) if parts else "Weak signal"
|
||||
|
||||
|
||||
def _get_provider(env, feature_name):
|
||||
"""Look up provider name from per-feature config; instantiate adapter.
|
||||
|
||||
Returns None if no provider configured (statistical-only mode)."""
|
||||
param = env['ir.config_parameter'].sudo()
|
||||
provider_name = param.get_param(f'fusion_accounting.provider.{feature_name}')
|
||||
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:
|
||||
_logger.warning("fusion_accounting_ai adapters not importable")
|
||||
return None
|
||||
if provider_name.startswith('openai'):
|
||||
return OpenAIAdapter(env)
|
||||
elif provider_name.startswith('claude'):
|
||||
return ClaudeAdapter(env)
|
||||
return None
|
||||
|
||||
|
||||
def _ai_rerank(env, provider, statement_line, scored, pattern, precedents):
|
||||
"""Send top-K candidates + features to LLM for re-rank. Parse JSON response.
|
||||
|
||||
On any failure (network, JSON parse, missing key), return scored unchanged."""
|
||||
try:
|
||||
from odoo.addons.fusion_accounting_ai.services.prompts.bank_rec_prompt import build_prompt
|
||||
except ImportError:
|
||||
_logger.debug("bank_rec_prompt not yet available; skipping AI re-rank")
|
||||
return scored
|
||||
|
||||
system, user = build_prompt(statement_line, scored, pattern, precedents)
|
||||
response = provider.complete(
|
||||
system=system,
|
||||
messages=[{'role': 'user', 'content': user}],
|
||||
max_tokens=800,
|
||||
temperature=0.0,
|
||||
)
|
||||
|
||||
try:
|
||||
parsed = json.loads(response['content'])
|
||||
except (json.JSONDecodeError, KeyError, TypeError):
|
||||
return scored
|
||||
|
||||
ai_order = {item['candidate_id']: item for item in parsed.get('ranked', [])}
|
||||
for s in scored:
|
||||
if s.candidate_id in ai_order:
|
||||
s.score_ai_rerank = ai_order[s.candidate_id].get('confidence', s.confidence)
|
||||
s.reasoning = ai_order[s.candidate_id].get('reason', s.reasoning)
|
||||
s.confidence = round((s.confidence * 0.4) + (s.score_ai_rerank * 0.6), 3)
|
||||
scored.sort(key=lambda x: -x.confidence)
|
||||
return scored
|
||||
46
fusion_accounting_bank_rec/services/exchange_diff.py
Normal file
46
fusion_accounting_bank_rec/services/exchange_diff.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Exchange-difference calculation helper.
|
||||
|
||||
Pure-Python FX gain/loss computation. The engine uses this for rapid
|
||||
pre-checks; Odoo's account.move._create_exchange_difference_move() is
|
||||
invoked separately for the actual GL posting.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class ExchangeDiffResult:
|
||||
needs_diff_move: bool
|
||||
diff_amount: float # in company currency; positive = gain, negative = loss
|
||||
line_company_amount: float
|
||||
against_company_amount: float
|
||||
|
||||
|
||||
def compute_exchange_diff(*, line_amount, line_currency_code, against_amount,
|
||||
against_currency_code, line_rate, against_rate) -> ExchangeDiffResult:
|
||||
"""Compute whether an exchange-diff move is needed and its magnitude.
|
||||
|
||||
Args:
|
||||
line_amount: Bank line amount in its currency
|
||||
line_currency_code: e.g. 'USD'
|
||||
against_amount: Matched journal item amount in its currency
|
||||
against_currency_code: e.g. 'USD' (or different)
|
||||
line_rate: FX rate (foreign per company currency) at line date
|
||||
against_rate: FX rate at journal item posting date
|
||||
|
||||
Returns:
|
||||
ExchangeDiffResult with needs_diff_move flag and computed diff
|
||||
in company currency (positive = gain, negative = loss).
|
||||
"""
|
||||
line_company = line_amount * line_rate
|
||||
against_company = against_amount * against_rate
|
||||
|
||||
diff = line_company - against_company
|
||||
needs_diff = abs(diff) > 0.005 # rounding tolerance
|
||||
|
||||
return ExchangeDiffResult(
|
||||
needs_diff_move=needs_diff,
|
||||
diff_amount=round(diff, 2),
|
||||
line_company_amount=round(line_company, 2),
|
||||
against_company_amount=round(against_company, 2),
|
||||
)
|
||||
91
fusion_accounting_bank_rec/services/matching_strategies.py
Normal file
91
fusion_accounting_bank_rec/services/matching_strategies.py
Normal file
@@ -0,0 +1,91 @@
|
||||
"""Matching strategy classes for the reconcile engine.
|
||||
|
||||
Each strategy takes a bank amount + list of candidate journal items
|
||||
and returns a MatchResult with the picked ids + confidence + residual.
|
||||
Strategies are pure Python; no ORM dependency.
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from itertools import combinations
|
||||
|
||||
|
||||
@dataclass
|
||||
class Candidate:
|
||||
id: int
|
||||
amount: float
|
||||
partner_id: int
|
||||
age_days: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class MatchResult:
|
||||
picked_ids: list[int] = field(default_factory=list)
|
||||
confidence: float = 0.0
|
||||
residual: float = 0.0 # bank_amount - sum(picked); positive = under-allocated
|
||||
strategy_name: str = ""
|
||||
|
||||
|
||||
AMOUNT_TOLERANCE = 0.005 # currency rounding tolerance
|
||||
|
||||
|
||||
class AmountExactStrategy:
|
||||
"""Pick a single candidate whose amount equals the bank amount exactly.
|
||||
If multiple candidates match exactly, pick the oldest (FIFO tiebreaker)."""
|
||||
|
||||
def match(self, *, bank_amount: float, candidates: list[Candidate]) -> MatchResult:
|
||||
exact = [c for c in candidates if abs(c.amount - bank_amount) < AMOUNT_TOLERANCE]
|
||||
if not exact:
|
||||
return MatchResult(strategy_name='amount_exact')
|
||||
oldest = max(exact, key=lambda c: c.age_days)
|
||||
return MatchResult(
|
||||
picked_ids=[oldest.id],
|
||||
confidence=1.0,
|
||||
residual=0.0,
|
||||
strategy_name='amount_exact',
|
||||
)
|
||||
|
||||
|
||||
class FIFOStrategy:
|
||||
"""Pick oldest candidates first until the bank amount is exhausted.
|
||||
May produce partial reconcile residual if last candidate doesn't fit exactly."""
|
||||
|
||||
def match(self, *, bank_amount: float, candidates: list[Candidate]) -> MatchResult:
|
||||
if not candidates:
|
||||
return MatchResult(strategy_name='fifo')
|
||||
oldest_first = sorted(candidates, key=lambda c: -c.age_days)
|
||||
picked = []
|
||||
remaining = bank_amount
|
||||
for c in oldest_first:
|
||||
if remaining <= AMOUNT_TOLERANCE:
|
||||
break
|
||||
picked.append(c.id)
|
||||
remaining -= c.amount
|
||||
|
||||
confidence = 0.7 if remaining < AMOUNT_TOLERANCE else 0.5
|
||||
return MatchResult(
|
||||
picked_ids=picked,
|
||||
confidence=confidence,
|
||||
residual=remaining,
|
||||
strategy_name='fifo',
|
||||
)
|
||||
|
||||
|
||||
class MultiInvoiceStrategy:
|
||||
"""Find the smallest combination of candidates summing to the bank amount.
|
||||
Bounded by max_combinations to keep complexity manageable."""
|
||||
|
||||
def __init__(self, max_combinations=3):
|
||||
self.max_combinations = max_combinations
|
||||
|
||||
def match(self, *, bank_amount: float, candidates: list[Candidate]) -> MatchResult:
|
||||
for k in range(2, self.max_combinations + 1):
|
||||
for combo in combinations(candidates, k):
|
||||
total = sum(c.amount for c in combo)
|
||||
if abs(total - bank_amount) < AMOUNT_TOLERANCE:
|
||||
return MatchResult(
|
||||
picked_ids=[c.id for c in combo],
|
||||
confidence=0.85,
|
||||
residual=0.0,
|
||||
strategy_name=f'multi_invoice_{k}',
|
||||
)
|
||||
return MatchResult(strategy_name='multi_invoice')
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user