63 Commits

Author SHA1 Message Date
gsinghpal
3491069f48 docs(fusion_accounting_followup): CLAUDE.md, UPGRADE_NOTES.md, README.md
Some checks failed
fusion_accounting CI / test (fusion_accounting_ai) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_core) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_migration) (push) Has been cancelled
Made-with: Cursor
2026-04-19 21:41:41 -04:00
gsinghpal
fbc1ac38f8 feat(fusion_accounting): meta-module now installs followup sub-module
Made-with: Cursor
2026-04-19 21:40:10 -04:00
gsinghpal
aeb5461ad0 test(fusion_accounting_followup): local LLM follow-up text smoke (skips without LLM)
Made-with: Cursor
2026-04-19 21:39:50 -04:00
gsinghpal
e1f94d5202 test(fusion_accounting_followup): 5 OWL tour tests
Made-with: Cursor
2026-04-19 21:39:08 -04:00
gsinghpal
8eb4b8dc6c fix(fusion_accounting_followup): seeded levels + migration idempotency
- test_create_minimal/negative_delay used sequence=1, which now collides
  with the seeded Friendly Reminder level. Use sequences 901/902.
- migration backfill: search by name (not raw seq) for idempotency,
  allocate sequence as max(existing)+1 to avoid both seed clashes and
  within-batch collisions when Enterprise has duplicate sequence values.

Made-with: Cursor
2026-04-19 21:33:26 -04:00
gsinghpal
d0a912b1da test(fusion_accounting_followup): coexistence behavior
Made-with: Cursor
2026-04-19 21:30:26 -04:00
gsinghpal
8ef88da94a feat(fusion_accounting_followup): menu + window actions with coexistence group filter
Made-with: Cursor
2026-04-19 21:30:06 -04:00
gsinghpal
38a2684782 feat(fusion_accounting_followup): migration wizard backfill from account_followup
Made-with: Cursor
2026-04-19 21:29:38 -04:00
gsinghpal
2ec90a50b0 feat(fusion_accounting_followup): batch send follow-ups wizard
Made-with: Cursor
2026-04-19 21:28:58 -04:00
gsinghpal
4ee261e189 feat(fusion_accounting_followup): default mail templates for 3 escalation levels
Made-with: Cursor
2026-04-19 21:27:59 -04:00
gsinghpal
ab3fcc56db feat(fusion_accounting_followup): seed 3 default follow-up levels
Made-with: Cursor
2026-04-19 21:27:33 -04:00
gsinghpal
474485f963 feat(fusion_accounting_followup): ai_text_panel + followup_history_table components
Made-with: Cursor
2026-04-19 21:20:51 -04:00
gsinghpal
da746698c5 feat(fusion_accounting_followup): partner_card + aging_bucket_strip + risk_badge components
Made-with: Cursor
2026-04-19 21:19:52 -04:00
gsinghpal
21f6171162 feat(fusion_accounting_followup): top-level followup_dashboard component
Made-with: Cursor
2026-04-19 21:18:59 -04:00
gsinghpal
86bead48e1 feat(fusion_accounting_followup): followup_service.js reactive frontend service
Made-with: Cursor
2026-04-19 21:17:57 -04:00
gsinghpal
99e4f8e17f feat(fusion_accounting_followup): SCSS foundation for OWL widget
Made-with: Cursor
2026-04-19 21:17:18 -04:00
gsinghpal
f45d66c465 test(fusion_accounting_followup): performance benchmarks with P95 targets
Made-with: Cursor
2026-04-19 21:10:02 -04:00
gsinghpal
f64b8f373c test(fusion_accounting_followup): full follow-up flow integration test
Made-with: Cursor
2026-04-19 21:09:17 -04:00
gsinghpal
d51a2b104e test(fusion_accounting_followup): Hypothesis property-based invariants
Made-with: Cursor
2026-04-19 21:08:35 -04:00
gsinghpal
042dcf8067 feat(fusion_accounting_followup): 2 cron jobs (daily scan + weekly risk refresh)
Some checks failed
fusion_accounting CI / test (fusion_accounting_ai) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_core) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_migration) (push) Has been cancelled
- fusion.followup.cron AbstractModel with two handlers
- cron_fusion_followup_daily_scan: walks every overdue partner and
  delegates to engine.send_followup_email
- cron_fusion_followup_risk_refresh: weekly refresh of
  fusion_followup_risk_score / risk_band on res.partner
- V19 ir.cron records (no numbercall field)
- 2 smoke tests added (80 total)

Made-with: Cursor
2026-04-19 21:04:37 -04:00
gsinghpal
52becd176a feat(fusion_accounting_ai): 5 new customer follow-up AI tools
Adds Task 17 tool layer:
- fusion_list_overdue
- fusion_get_partner_followup_detail
- fusion_generate_followup_text
- fusion_send_followup
- fusion_get_partner_risk_score

Tools register through TOOL_DISPATCH and degrade with a clear
error message when fusion_accounting_followup is not installed.
5 TransactionCase tests added (78 total).

Made-with: Cursor
2026-04-19 21:03:30 -04:00
gsinghpal
993df3a14a feat(fusion_accounting_ai): wire FollowupAdapter fusion paths to engine
- Switch FUSION_MODEL to fusion.followup.engine so adapter mode
  selection matches the new module
- Add list_overdue() with fusion/enterprise/community variants
- Re-route send_followup_via_fusion to engine.send_followup_email
- 4 new TransactionCase tests (73 total)

Existing aging / overdue_invoices adapter methods continue to fall
back to the community implementation.

Made-with: Cursor
2026-04-19 21:02:17 -04:00
gsinghpal
d455016c27 feat(fusion_accounting_followup): 6 JSON-RPC endpoints for OWL widget
Adds Task 15 controller layer:
- /fusion/followup/list_overdue
- /fusion/followup/get_partner_detail
- /fusion/followup/generate_text
- /fusion/followup/send
- /fusion/followup/pause
- /fusion/followup/reset

All endpoints use V19 type='jsonrpc' and route through
fusion.followup.engine. 6 HttpCase tests added (69 total).

Made-with: Cursor
2026-04-19 21:00:07 -04:00
gsinghpal
9b6d6b3895 test(fusion_accounting_followup): engine integration tests for full lifecycle
End-to-end flows over a real posted receivable line: aging discovery,
level resolution, send-with-cache reuse, pause+force override, and
audit history growth. Adds ignore_pause kwarg to compute_followup_level
so force=True in send_followup_email reaches level resolution.

Made-with: Cursor
2026-04-19 20:54:13 -04:00
gsinghpal
6802d60e44 feat(fusion_accounting_followup): fusion.followup.engine 7-method API
The orchestrator AbstractModel for follow-up lifecycle.
get_overdue_for_partner, compute_followup_level, send_followup_email,
escalate_to_next_level, pause_followup, reset_followup, snapshot_followup_history.

All controllers, AI tools, wizards, cron must route through these
methods; no direct ORM writes to fusion.followup.run from anywhere else.

Made-with: Cursor
2026-04-19 20:52:27 -04:00
gsinghpal
06dafc31c1 feat(fusion_accounting_followup): inherit account.move.line for level tracking
Made-with: Cursor
2026-04-19 20:47:37 -04:00
gsinghpal
2ddc600d65 feat(fusion_accounting_followup): inherit res.partner with follow-up state
Made-with: Cursor
2026-04-19 20:46:08 -04:00
gsinghpal
207c857e6b feat(fusion_accounting_followup): LLM text cache model
Made-with: Cursor
2026-04-19 20:45:27 -04:00
gsinghpal
05de855cea feat(fusion_accounting_followup): fusion.followup.run audit model
Made-with: Cursor
2026-04-19 20:44:39 -04:00
gsinghpal
9ae9161892 feat(fusion_accounting_followup): fusion.followup.level definition model
Made-with: Cursor
2026-04-19 20:43:51 -04:00
gsinghpal
1829f0584f feat(fusion_accounting_followup): AI follow-up text generator + prompt
Made-with: Cursor
2026-04-19 20:40:26 -04:00
gsinghpal
63f3e0ec14 feat(fusion_accounting_followup): tone_selector service
Made-with: Cursor
2026-04-19 20:39:17 -04:00
gsinghpal
397fb238c5 feat(fusion_accounting_followup): risk_scorer service
Made-with: Cursor
2026-04-19 20:38:44 -04:00
gsinghpal
d4ef19858d feat(fusion_accounting_followup): level_resolver service
Made-with: Cursor
2026-04-19 20:38:02 -04:00
gsinghpal
4ce0edc698 feat(fusion_accounting_followup): overdue_aging service with 6 buckets
Made-with: Cursor
2026-04-19 20:35:39 -04:00
gsinghpal
ea2f44287f feat(fusion_accounting_followup): Phase 4 skeleton + plan
35-task plan to replace Enterprise account_followup module:
- Multi-level dunning (gentle reminder -> firm warning -> legal)
- AI augmentation: contextual follow-up text generation + payment risk scoring + tone selection
- HYBRID engine: shared primitives + persisted level/run/cache models
- Per-partner state: current level, paused-until, history
- Coexists with Enterprise (group_fusion_show_when_enterprise_absent)
- Same V19 conventions + test pyramid + perf-budget discipline as Phases 1-3

Made-with: Cursor
2026-04-19 20:31:07 -04:00
gsinghpal
b4558a223c feat(configurator): stub fp.direct.order.line model for multi-line direct order wizard
Task A1 of the direct-order-wizard rewrite. Adds the transient line
model that will hold per-part detail (part, coating, qty, price) when
the wizard moves from single-line to header+lines architecture.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 20:29:52 -04:00
gsinghpal
7a53012f09 Merge Phase 3: AI-augmented asset management
Some checks failed
fusion_accounting CI / test (fusion_accounting_ai) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_core) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_migration) (push) Has been cancelled
50 tasks shipped on fusion_accounting/phase-3-assets:
- fusion.asset.engine (7-method API: compute_schedule, post, dispose, partial_sale, pause, resume, reverse)
- 3 depreciation methods (straight-line, declining-balance, units-of-production)
- 6 persisted models + materialized view for portfolio queries
- AI: anomaly detection + LLM-suggested useful life with templated fallback
- 8 JSON-RPC controller endpoints + reactive frontend service
- 6 OWL components + SCSS tokens + dark mode
- 4 wizards (creation w/ AI suggest, disposal, partial sale, depreciation run)
- Migration wizard backfill from account.asset (verified live: 2 records, idempotent)
- Audit PDF report
- 2 cron jobs (daily depreciation post + monthly anomaly scan)
- 5 AI chat tools
- Coexists with Enterprise (group_fusion_show_when_enterprise_absent)
- 141 tests passing (unit, integration, property-based, controller, MV, wizards, coexistence, perf, LLM compat, OWL tours)
- All 5 P95 perf metrics within 1x of budget (8x-500x headroom)
2026-04-19 20:29:40 -04:00
gsinghpal
43e1f3d6f5 docs(fusion_accounting_assets): CLAUDE.md, UPGRADE_NOTES.md, README.md
Some checks failed
fusion_accounting CI / test (fusion_accounting_ai) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_core) (push) Has been cancelled
fusion_accounting CI / test (fusion_accounting_migration) (push) Has been cancelled
Mirrors Phase 1 + 2 doc layout. CLAUDE.md captures architecture, the
7-method engine API, persisted models, controllers, OWL frontend,
performance baselines (Tasks 23 + 41 numbers), test counts (140), and
Phase 3.5 backlog. UPGRADE_NOTES.md anchors the Odoo 19 reference and
records V19 deprecations applied. README.md is the user-facing intro.

Made-with: Cursor
2026-04-19 20:25:16 -04:00
gsinghpal
69453bd8ae feat(fusion_accounting): meta-module now installs assets sub-module
Adds fusion_accounting_assets to the meta-module 'depends' so a single
install of fusion_accounting brings up the full Phase 1 + 2 + 3 stack.
Bumps version 19.0.1.0.2 -> 19.0.1.0.3.

Made-with: Cursor
2026-04-19 20:23:47 -04:00
gsinghpal
7e2c31e371 test(fusion_accounting_assets): local LLM useful-life smoke (skips without LLM)
Auto-detects LM Studio (:1234) or Ollama (:11434) on
host.docker.internal / localhost; skips silently when no server is
reachable so CI stays green. When a server is present it exercises the
full predict_useful_life path through the OpenAI-compatible adapter,
catching prompt / JSON-parsing regressions that mocked LLMs hide.

Tagged 'local_llm' so it can be selected explicitly when an LLM is
known-available.

Made-with: Cursor
2026-04-19 20:23:30 -04:00
gsinghpal
6344a75150 test(fusion_accounting_assets): controller perf benchmark
Adds JSON-RPC controller benchmark to complement Task 23's engine-level
benchmarks: end-to-end /fusion/assets/get_detail timing through the HTTP
dispatch layer.

Captured locally on westin-v19:
  controller.get_detail: median=2ms p95=40ms (target <500ms, 12x headroom)

Tagged 'benchmark' so it stays out of fast unit runs.

Made-with: Cursor
2026-04-19 20:22:50 -04:00
gsinghpal
59ecc9fc5b test(fusion_accounting_assets): 5 OWL tour tests
Mirrors Phase 1 + 2 tour pattern: HttpCase.start_tour wrappers tagged
'tour' so they skip cleanly when websocket-client is absent. Tours cover
smoke (/odoo loads), the asset list / category list / anomaly list views,
and the depreciation-run wizard form. Bundle is wired via
web.assets_tests.

Verified locally: 5 tests registered, all skip with
"websocket-client module is not installed" (expected — no chromium in
the dev container).

Made-with: Cursor
2026-04-19 20:22:13 -04:00
gsinghpal
2ee341316c test(fusion_accounting_assets): coexistence behavior
Made-with: Cursor
2026-04-19 20:16:30 -04:00
gsinghpal
02885108f2 feat(fusion_accounting_assets): menu + window actions with coexistence group filter
Made-with: Cursor
2026-04-19 20:15:38 -04:00
gsinghpal
af8c72a3b1 feat(fusion_accounting_assets): migration audit PDF report
Made-with: Cursor
2026-04-19 20:14:50 -04:00
gsinghpal
1491f455fe feat(fusion_accounting_assets): migration wizard backfill from account.asset
Made-with: Cursor
2026-04-19 20:13:30 -04:00
gsinghpal
3efef7efc7 feat(fusion_accounting_assets): depreciation run wizard
Made-with: Cursor
2026-04-19 20:06:25 -04:00
gsinghpal
92f445eb8f feat(fusion_accounting_assets): partial sale wizard
Made-with: Cursor
2026-04-19 20:05:17 -04:00
gsinghpal
892c37e2b0 feat(fusion_accounting_assets): disposal wizard
Made-with: Cursor
2026-04-19 20:04:03 -04:00
gsinghpal
a6ef7e0c2a feat(fusion_accounting_assets): asset creation wizard with AI useful-life suggest
Made-with: Cursor
2026-04-19 20:02:46 -04:00
gsinghpal
9794970429 feat(fusion_accounting_assets): ai_useful_life_panel + anomaly_strip components
Made-with: Cursor
2026-04-19 17:39:56 -04:00
gsinghpal
c0b8cc4159 feat(fusion_accounting_assets): disposal_dialog component
Made-with: Cursor
2026-04-19 17:39:17 -04:00
gsinghpal
51bff01f13 feat(fusion_accounting_assets): depreciation_board component
Made-with: Cursor
2026-04-19 17:38:50 -04:00
gsinghpal
7ba15c65aa feat(fusion_accounting_assets): asset_detail_panel component
Made-with: Cursor
2026-04-19 17:38:28 -04:00
gsinghpal
bf8689716c feat(fusion_accounting_assets): asset_card component
Made-with: Cursor
2026-04-19 17:37:57 -04:00
gsinghpal
bddd22cabd feat(fusion_accounting_assets): top-level asset_dashboard component
Made-with: Cursor
2026-04-19 17:37:34 -04:00
gsinghpal
6051ef22a0 feat(fusion_accounting_assets): assets_service.js reactive frontend service
Made-with: Cursor
2026-04-19 17:36:52 -04:00
gsinghpal
24f8a5857e feat(fusion_accounting_assets): SCSS foundation for OWL widget
Made-with: Cursor
2026-04-19 17:36:11 -04:00
gsinghpal
475d17c1aa test(fusion_accounting_assets): performance benchmarks with P95 targets
Made-with: Cursor
2026-04-19 17:26:01 -04:00
gsinghpal
fec1c12246 feat(fusion_accounting_assets): MV for per-asset book value snapshot
Made-with: Cursor
2026-04-19 17:25:14 -04:00
gsinghpal
c939b83812 test(fusion_accounting_assets): integration tests for all 3 depreciation methods
Made-with: Cursor
2026-04-19 17:23:41 -04:00
gsinghpal
1e70b8d5c0 test(fusion_accounting_assets): Hypothesis property-based depreciation invariants
Made-with: Cursor
2026-04-19 17:22:55 -04:00
145 changed files with 7502 additions and 14 deletions

View File

@@ -0,0 +1,140 @@
# Phase 4 — Fusion Accounting Follow-up Implementation Plan
**Module:** `fusion_accounting_followup`
**Branch:** `fusion_accounting/phase-4-followup`
**Pre-phase tag:** `fusion_accounting/pre-phase-4`
**Estimated tasks:** ~35
**Reference:** `/Users/gurpreet/Github/RePackaged-Odoo/accounting/account_followup/` (~1318 LOC Python)
## Goal
Replace Enterprise's `account_followup` module — multi-level dunning sequences for unpaid invoices, with AI augmentation: contextually-appropriate follow-up text generation + payment-risk scoring + tone adjustment based on customer history. Coexists with Enterprise.
## Architecture (HYBRID engine, Phases 1-3 pattern)
```
fusion.followup.engine (AbstractModel) ← shared primitives
├── compute_followup_level(partner)
├── get_overdue_for_partner(partner)
├── send_followup_email(partner, level=None)
├── escalate_to_next_level(partner)
├── pause_followup(partner, until_date)
├── reset_followup(partner)
└── snapshot_followup_history(partner) ← audit/history
services/ ← pure-Python
├── overdue_aging.py → bucket overdue lines (current/30/60/90/120+)
├── level_resolver.py → match aging buckets to follow-up levels
├── risk_scorer.py → payment-history risk score (0-100)
├── tone_selector.py → gentle/firm/legal based on level + risk
├── followup_text_generator.py → LLM-generated follow-up text
└── followup_text_prompt.py → provider-agnostic LLM prompt
models/
├── fusion_followup_level.py → level definition (delay days, template, action)
├── fusion_followup_run.py → execution record (per-partner per-level)
├── fusion_followup_text_cache.py → LLM-generated text cache (cost-saving)
├── fusion_followup_engine.py → AbstractModel orchestrator
├── res_partner.py (inherit) → fusion_followup_status, fusion_followup_paused_until
└── account_move_line.py (inherit) → followup_level_id (which level last contacted at)
controllers/followup_controller.py ← 6 JSON-RPC endpoints
├── /fusion/followup/list_overdue → list partners with overdue
├── /fusion/followup/get_partner_detail → single partner with aging + history
├── /fusion/followup/generate_text → AI-generate follow-up text
├── /fusion/followup/send → send a follow-up email
├── /fusion/followup/pause → pause follow-ups for a partner
└── /fusion/followup/reset → reset follow-up state
static/src/
├── scss/ ← follow-up design tokens
├── services/followup_service.js ← reactive state + RPC wrappers
├── views/followup_dashboard/ ← top-level OWL controller
└── components/ ← partner_card, aging_bucket_strip, ai_text_panel,
followup_history_table, risk_badge
```
## Coexistence
`group_fusion_show_when_enterprise_absent`. Follow-up menu visible only when `account_followup` NOT installed.
## Tasks (~35 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/overdue_aging.py` (TDD: bucket lines into 0/30/60/90/120+)
4. `services/level_resolver.py` (TDD: match aging to level)
5. `services/risk_scorer.py` (TDD: payment-history risk 0-100)
6. `services/tone_selector.py` (TDD: gentle/firm/legal)
7. `services/followup_text_generator.py` + `followup_text_prompt.py` (LLM)
### Group 3: Persisted models (8-12)
8. `models/fusion_followup_level.py` (level definition)
9. `models/fusion_followup_run.py` (execution record)
10. `models/fusion_followup_text_cache.py` (LLM cache)
11. `models/res_partner.py` (inherit: fusion_followup_status, paused_until)
12. `models/account_move_line.py` (inherit: followup_level_id)
### Group 4: Engine + integration tests (13-14)
13. `models/fusion_followup_engine.py` (7-method API)
14. Engine integration tests
### Group 5: Backend wiring (15-18)
15. JSON-RPC controller (6 endpoints)
16. FollowupAdapter wiring `_via_fusion` paths
17. 4 new AI tools (list_overdue, generate_text, send_followup, get_risk_score)
18. Cron — daily scan + escalate
### Group 6: Tests + perf (19-21)
19. Property-based tests (Hypothesis: aging buckets sum to total)
20. Integration tests (full follow-up flow: scan → escalate → send → reset)
21. Performance benchmarks (P95: scan < 500ms, generate_text < 5s incl. LLM)
### Group 7: Frontend (22-26)
22. SCSS tokens + main stylesheet
23. `followup_service.js`
24. `followup_dashboard` (top-level)
25. `partner_card` + `aging_bucket_strip` + `risk_badge`
26. `ai_text_panel` (Fusion-only) + `followup_history_table`
### Group 8: Wizards + data (27-29)
27. Default follow-up levels XML data (7-day reminder, 30-day, 60-day, legal)
28. Default mail templates XML data (3 escalation levels)
29. "Send batch follow-ups" wizard
### Group 9: Migration + coexistence (30-32)
30. Migration wizard inheritance — backfill from account_followup tables
31. Menu + window action with coexistence group filter
32. Coexistence test
### Group 10: Final tests + polish (33-37)
33. 5 OWL tour tests
34. Local LLM compat test for text_generator
35. Update meta-module manifest
36. CLAUDE.md, UPGRADE_NOTES.md, README.md
37. End-to-end smoke + tag phase-4-complete + push
## Performance Targets (P95)
- `compute_followup_level`: <50ms
- `get_overdue_for_partner`: <100ms
- `send_followup_email` (no LLM): <200ms
- `generate_text` (with LLM): <5s
- Controller `list_overdue` (50 partners): <500ms
## V19 Conventions (Phases 1-3 lessons)
- `models.Constraint` not `_sql_constraints`
- No `@api.depends('id')` on stored compute fields
- `@route(type='jsonrpc')` not `type='json'`
- `ir.cron` no `numbercall` field
- `res.groups.user_ids` not `users`
- `ir.ui.menu.group_ids` not `groups_id`
- `from odoo.exceptions import UserError, ValidationError` (NOT `self.env['ir.exceptions'].UserError`)
## Test Targets
Match Phases 1-3 test pyramid. Phase 4 target: ~80-100 additional tests → ~510-530 total project tests.

View File

@@ -1,6 +1,6 @@
{
'name': 'Fusion Accounting',
'version': '19.0.1.0.2',
'version': '19.0.1.0.4',
'category': 'Accounting/Accounting',
'sequence': 25,
'summary': 'Meta-module that installs the full Fusion Accounting suite (core, AI, migration; bank rec, reports, etc. as later sub-modules ship).',
@@ -15,11 +15,11 @@ Currently installs:
- fusion_accounting_migration Transitional Enterprise->Fusion data migration
- fusion_accounting_bank_rec AI-assisted bank reconciliation (Phase 1)
- fusion_accounting_reports AI-augmented financial reports (Phase 2)
- fusion_accounting_assets AI-augmented asset management (Phase 3)
- fusion_accounting_followup AI-augmented customer follow-ups (Phase 4)
Future sub-modules (added per the roadmap as each Phase ships):
- fusion_accounting_dashboard (Phase 3)
- fusion_accounting_followup (Phase 5)
- fusion_accounting_assets (Phase 6)
- fusion_accounting_dashboard (Phase 5)
- fusion_accounting_budget (Phase 6)
Built by Nexa Systems Inc.
@@ -35,6 +35,8 @@ Built by Nexa Systems Inc.
'fusion_accounting_migration',
'fusion_accounting_bank_rec',
'fusion_accounting_reports',
'fusion_accounting_assets',
'fusion_accounting_followup',
],
'data': [],
'installable': True,

View File

@@ -28,7 +28,7 @@ def _bucket_for_days(days):
class FollowupAdapter(DataAdapter):
FUSION_MODEL = 'fusion.followup.line'
FUSION_MODEL = 'fusion.followup.engine'
ENTERPRISE_MODULE = 'account_followup'
# ------------------------------------------------------------------
@@ -179,15 +179,29 @@ class FollowupAdapter(DataAdapter):
}
# ------------------------------------------------------------------
# send_followup — Enterprise-only action
# send_followup — routes to fusion engine when available
# ------------------------------------------------------------------
def send_followup(self, partner_id, options=None):
return self._dispatch('send_followup', partner_id=partner_id, options=options)
def send_followup(self, partner_id, level_id=None, force=False, options=None):
return self._dispatch(
'send_followup',
partner_id=partner_id, level_id=level_id,
force=force, options=options,
)
def send_followup_via_fusion(self, partner_id, options=None):
return self.send_followup_via_community(partner_id=partner_id, options=options)
def send_followup_via_fusion(self, partner_id, level_id=None,
force=False, options=None):
if 'fusion.followup.engine' not in self.env.registry:
return {'error': 'fusion_accounting_followup not installed'}
partner = self.env['res.partner'].browse(int(partner_id))
level = None
if level_id:
level = self.env['fusion.followup.level'].browse(int(level_id))
return self.env['fusion.followup.engine'].send_followup_email(
partner, level=level, force=bool(force),
)
def send_followup_via_enterprise(self, partner_id, options=None):
def send_followup_via_enterprise(self, partner_id, level_id=None,
force=False, options=None):
partner = self.env['res.partner'].browse(partner_id)
if not partner.exists():
return {'error': 'Partner not found'}
@@ -198,7 +212,8 @@ class FollowupAdapter(DataAdapter):
'result': str(result) if result else 'done',
}
def send_followup_via_community(self, partner_id, options=None):
def send_followup_via_community(self, partner_id, level_id=None,
force=False, options=None):
return {
'error': (
'Sending follow-ups is only available when account_followup '
@@ -206,5 +221,61 @@ class FollowupAdapter(DataAdapter):
),
}
# ------------------------------------------------------------------
# list_overdue — partner-centric overdue rollup (fusion engine)
# ------------------------------------------------------------------
def list_overdue(self, status=None, limit=50, company_id=None):
return self._dispatch(
'list_overdue',
status=status, limit=limit, company_id=company_id,
)
def list_overdue_via_fusion(self, status=None, limit=50, company_id=None):
if 'fusion.followup.engine' not in self.env.registry:
return {'partners': [], 'count': 0, 'total': 0}
company_id = company_id or self.env.company.id
Line = self.env['account.move.line'].sudo()
partner_ids = Line.search([
('parent_state', '=', 'posted'),
('account_id.account_type', '=', 'asset_receivable'),
('reconciled', '=', False),
('amount_residual', '>', 0),
('date_maturity', '<', date.today()),
('company_id', '=', company_id),
]).mapped('partner_id').ids
Partner = self.env['res.partner'].sudo()
domain = [('id', 'in', partner_ids)]
if status:
domain.append(('fusion_followup_status', '=', status))
partners = Partner.search(domain, limit=int(limit))
engine = self.env['fusion.followup.engine']
rows = []
for p in partners:
try:
overdue = engine.get_overdue_for_partner(p)
rows.append({
'partner_id': p.id,
'partner_name': p.name,
'overdue_amount': overdue['aging']['total_overdue_amount'],
'risk_score': overdue['risk']['score'],
'risk_band': overdue['risk']['band'],
'status': p.fusion_followup_status,
})
except Exception:
pass
return {'count': len(rows), 'total': len(partner_ids), 'partners': rows}
def list_overdue_via_enterprise(self, status=None, limit=50, company_id=None):
return {
'partners': [], 'count': 0, 'total': 0,
'error': 'Enterprise account_followup must be used from its UI',
}
def list_overdue_via_community(self, status=None, limit=50, company_id=None):
return {
'partners': [], 'count': 0, 'total': 0,
'error': 'No follow-up engine in pure Community',
}
register_adapter('followup', FollowupAdapter)

View File

@@ -11,12 +11,13 @@ 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
from .customer_followup import TOOLS as CUSTOMER_FOLLOWUP_TOOLS
TOOL_DISPATCH = {}
for tools_dict in [
BANK_RECON_TOOLS, HST_TOOLS, AR_TOOLS, AP_TOOLS, JOURNAL_TOOLS,
MONTH_END_TOOLS, PAYROLL_TOOLS, INVENTORY_TOOLS, ADP_TOOLS,
REPORTING_TOOLS, AUDIT_TOOLS, FINANCIAL_REPORTS_TOOLS,
ASSET_MANAGEMENT_TOOLS,
ASSET_MANAGEMENT_TOOLS, CUSTOMER_FOLLOWUP_TOOLS,
]:
TOOL_DISPATCH.update(tools_dict)

View File

@@ -0,0 +1,98 @@
"""Fusion-engine-routed AI tools for customer follow-ups.
These tools are exposed through TOOL_DISPATCH and let the assistant query
the customer follow-up engine via natural language. All tools degrade
gracefully when fusion_accounting_followup is not installed.
"""
import logging
_logger = logging.getLogger(__name__)
def fusion_list_overdue(env, params):
"""List partners with overdue invoices, sorted by risk."""
if 'fusion.followup.engine' not in env.registry:
return {'error': 'fusion_accounting_followup not installed'}
from ..data_adapters import get_adapter
adapter = get_adapter(env, 'followup')
return adapter.list_overdue(
status=params.get('status'),
limit=int(params.get('limit', 50)),
company_id=int(params['company_id'])
if params.get('company_id') else env.company.id,
)
def fusion_get_partner_followup_detail(env, params):
"""Detailed follow-up state for a single partner: aging, risk, history."""
if 'fusion.followup.engine' not in env.registry:
return {'error': 'fusion_accounting_followup not installed'}
Partner = env['res.partner']
partner = Partner.browse(int(params['partner_id']))
if not partner.exists():
return {'error': 'Partner not found'}
engine = env['fusion.followup.engine']
overdue = engine.get_overdue_for_partner(partner)
history = engine.snapshot_followup_history(partner, limit=10)
return {
'partner_id': partner.id,
'partner_name': partner.name,
'overdue': overdue,
'history': history,
}
def fusion_generate_followup_text(env, params):
"""Generate (or fall back to template) follow-up subject + body."""
if 'fusion.followup.engine' not in env.registry:
return {'error': 'fusion_accounting_followup not installed'}
from odoo.addons.fusion_accounting_followup.services.followup_text_generator import (
generate_followup_text,
)
return generate_followup_text(
env,
partner_name=params.get('partner_name', ''),
total_overdue=float(params.get('total_overdue', 0)),
currency_code=params.get('currency_code', 'USD'),
longest_overdue_days=int(params.get('longest_overdue_days', 0)),
tone=params.get('tone', 'gentle'),
invoice_count=int(params.get('invoice_count', 0)),
)
def fusion_send_followup(env, params):
"""Send a follow-up email via the engine (creates a fusion.followup.run)."""
if 'fusion.followup.engine' not in env.registry:
return {'error': 'fusion_accounting_followup not installed'}
from ..data_adapters import get_adapter
adapter = get_adapter(env, 'followup')
return adapter.send_followup(
partner_id=int(params['partner_id']),
level_id=int(params['level_id']) if params.get('level_id') else None,
force=bool(params.get('force', False)),
)
def fusion_get_partner_risk_score(env, params):
"""Compute and return the payment-risk score + drivers for a partner."""
if 'fusion.followup.engine' not in env.registry:
return {'error': 'fusion_accounting_followup not installed'}
partner = env['res.partner'].browse(int(params['partner_id']))
if not partner.exists():
return {'error': 'Partner not found'}
overdue = env['fusion.followup.engine'].get_overdue_for_partner(partner)
return {
'partner_id': partner.id,
'partner_name': partner.name,
'risk': overdue['risk'],
}
TOOLS = {
'fusion_list_overdue': fusion_list_overdue,
'fusion_get_partner_followup_detail': fusion_get_partner_followup_detail,
'fusion_generate_followup_text': fusion_generate_followup_text,
'fusion_send_followup': fusion_send_followup,
'fusion_get_partner_risk_score': fusion_get_partner_risk_score,
}

View File

@@ -0,0 +1,130 @@
# fusion_accounting_assets — Cursor / Claude Context
## Purpose
AI-augmented fixed asset management with depreciation schedules — a
Fusion-native replacement for (and coexisting with) Odoo Enterprise's
`account_asset` module. Ships in Phase 3 of the fusion_accounting roadmap.
## Architecture
Hybrid: the engine (`fusion.asset.engine`, AbstractModel) is the SINGLE
write surface for the asset lifecycle. Everything else (controllers, OWL
widget, AI tools, wizards, cron) routes through the engine's 7-method
public API:
- `compute_depreciation_schedule(asset, recompute=False)`
- `post_depreciation_entry(asset, period_date=None)`
- `dispose_asset(asset, sale_amount=0, sale_date=None, sale_partner=None, disposal_type='sale')`
- `partial_sale(asset, sold_amount, sold_qty=None, sale_date=None, sale_partner=None)`
- `pause_asset(asset, pause_date=None)`
- `resume_asset(asset, resume_date=None)`
- `reverse_disposal(asset)`
Pure-Python services live in `services/`:
- `depreciation_methods` — straight_line, declining_balance, units_of_production
- `prorate` — first/last-period prorating: full_month, days_365, days_period
- `salvage_value` — % of cost, fixed amount, zero
- `anomaly_detection` — variance vs expected schedule, low utilization
- `useful_life_predictor` + `useful_life_prompt` — LLM-suggested useful life with templated fallback
Persisted models in `models/`:
- `fusion.asset` — main model, state machine: draft → running → paused → disposed
- `fusion.asset.depreciation.line` — board lines
- `fusion.asset.category` — templates
- `fusion.asset.disposal` — disposal records
- `fusion.asset.anomaly` — flagged variances
- `fusion.asset.book.values.mv` — pre-aggregated materialized view
- `fusion.asset.engine` — AbstractModel (the API)
- `fusion.assets.cron` — cron handlers (post depreciations, MV refresh, anomaly scan)
- `account.move.line` (inherits) — adds `fusion_asset_id` linkage
- `fusion.migration.wizard` (inherits in `models/`) — adds asset backfill step
Wizards (TransientModel) in `wizards/`:
- `fusion.create.asset.wizard` — assisted creation with AI useful-life suggestion
- `fusion.disposal.wizard` — full disposal flow
- `fusion.partial.sale.wizard` — partial-quantity disposal
- `fusion.depreciation.run.wizard` — period close runner
Controller: `controllers/assets_controller.py` exposes 8 JSON-RPC
endpoints under `/fusion/assets/*` (list, get_detail, compute_schedule,
post_depreciation, dispose, get_anomalies, suggest_useful_life,
get_partner_history). All calls route through the engine.
OWL frontend: `static/src/`
- `services/assets_service.js` — central reactive state + RPC wrappers
- `views/asset_dashboard/*` — top-level dashboard view
- `components/asset_card`, `asset_detail_panel`, `depreciation_board`,
`disposal_dialog`, `ai_useful_life_panel`, `anomaly_strip` — 6 components
- `scss/_variables.scss` + `assets.scss` + `dark_mode.scss`
- `tours/assets_tours.js` — 5 OWL tour smoke tests
## Coexistence
When `account_asset` is installed the Asset Management menu hides via
`fusion_accounting_core.group_fusion_show_when_enterprise_absent` (a
computed group). The engine + AI tools remain available for the chat.
The migration wizard backfills `fusion.asset` from existing
`account.asset` records (verified live: 2 records, Task 35).
## Conventions
- **V19 deprecations to avoid:** `_sql_constraints` (use
`models.Constraint`), `@api.depends('id')` (raises
`NotImplementedError`), `@route(type='json')` (use `type='jsonrpc'`),
`numbercall` field on `ir.cron` (removed), `groups_id` on `res.users`
(use `all_group_ids` for searching), `users` field on `res.groups`
(use `user_ids`), `groups_id` on `ir.ui.menu` (use `group_ids`).
- **Materialized view refresh:** `fusion.asset.book.values.mv` is
refreshed by cron (REFRESH CONCURRENTLY in an autocommit cursor since
it can't run inside a regular Odoo transaction).
- **Provider routing:** AI features look up
`fusion_accounting.provider.asset_useful_life`, falling back to
`fusion_accounting.provider.default`. When neither is set the
templated keyword fallback in `useful_life_predictor` keeps the
feature usable offline.
## Performance baseline (Tasks 23 + 41)
| Operation | P95 | Budget | Headroom |
|------------------------------------|-------|----------|----------|
| `engine.compute_schedule` (10yr SL)| 1ms | 500ms | 500x |
| `engine.post_depreciation_entry` | <1ms | 300ms | huge |
| `engine.dispose_asset` | 5ms | 300ms | 60x |
| `controller.list` (35 assets) | 42ms | 300ms | 7x |
| `controller.get_detail` | 40ms | 500ms | 12x |
All Phase 3 perf metrics are within 1x of budget; no optimization was
needed at ship (Task 42 skipped per the conditional rule).
## Test counts (Phase 3 ship)
- 140 logical tests total in fusion_accounting_assets
- 0 failures, 0 errors
- Coverage includes: 4 engine benchmarks + 1 controller benchmark
(tagged `benchmark`), 1 local LLM smoke (tagged `local_llm`, skips
when no LLM), 5 OWL tour tests (tagged `tour`, skip without
websocket-client), Hypothesis property tests on the engine,
integration tests on the public API, controller round-trip tests, MV
shape tests.
## Known concerns / Phase 3.5 backlog
- Sub-annual depreciation frequency (currently annual only)
- Units-of-production assumes even per-period units
- Disposal journal entry not yet created — `dispose_asset` writes the
`fusion.asset.disposal` record but not the cash / gain-loss move
- Multi-currency, allocation rules, and analytic tags for depreciation
moves are out of scope for Phase 3
- Partial-sale child asset is created with no own depreciation schedule
pre-disposal
- Migration wizard inheritance lives in `models/` rather than
`wizards/` (small inconsistency with the rest of the wizard layout —
intentional to keep ORM ordering simple)
- `useful_life_predictor` always returns a usable dict (templated
fallback when LLM absent), so callers can't distinguish "AI said so"
from "fallback fired"; the `confidence` key is the only signal

View File

@@ -0,0 +1,53 @@
# fusion_accounting_assets
AI-augmented fixed asset management for Odoo 19 Community — a
Fusion-native replacement for Enterprise's `account_asset` module.
## What it does
- Three depreciation methods: straight-line, declining balance, and
units-of-production
- Asset lifecycle state machine: draft → running → paused → disposed
- Editable depreciation board with full schedule recompute
- Disposal flow (sale, scrap, donation) plus partial-sale wizard
- Daily cron for posting periodic depreciation
- AI augmentation:
- **Anomaly detection** — variance vs expected schedule, low utilization
- **Useful-life suggestion** — LLM-driven from invoice context, with a
keyword-based templated fallback so the feature still works offline
- Coexists with Enterprise `account_asset` (Enterprise wins by default;
the Fusion menu only appears when Enterprise is uninstalled)
- Migration-aware: bootstrap step backfills `fusion.asset` from existing
`account.asset` rows so the AI has memory from day 1
## Quick start
```bash
# Install
odoo --addons-path=... -i fusion_accounting_assets
# Open the dashboard (when Enterprise's account_asset is NOT installed)
# Apps -> Asset Management -> Assets
# When Enterprise IS installed: use Enterprise's UI; the engine + AI tools
# are still available via the AI chat.
```
## Configuration
- Local LLM (LM Studio, Ollama):
- `fusion_accounting.openai_base_url` =
`http://host.docker.internal:1234/v1`
- `fusion_accounting.openai_model` = your local model name
- `fusion_accounting.openai_api_key` = `lm-studio` (anything non-empty)
- `fusion_accounting.provider.asset_useful_life` = `openai`
## Public API (engine)
`fusion.asset.engine` is the single write surface. See `CLAUDE.md` for
the full 7-method signature list.
## See also
- `CLAUDE.md` — agent context
- `UPGRADE_NOTES.md` — Odoo version anchoring

View File

@@ -0,0 +1,49 @@
# fusion_accounting_assets — Upgrade Notes
## Odoo Version Anchor
This module targets **Odoo 19.0** (community-base).
Reference snapshot of Enterprise code mirrored from:
- `account_asset` (Odoo 19.0.x)
- Source: `/Users/gurpreet/Github/RePackaged-Odoo/accounting/account_asset/`
## Cross-Version Diff Strategy
When a new Odoo version ships:
1. Run `check_odoo_diff.sh` (in repo root) against the new Enterprise version
2. Note any breaking changes in `account.asset` / `account.move.line` API
3. For mirrored OWL components, diff Enterprise's new versions against ours
and port material changes (signature renames, new behaviour we want to
inherit)
4. Re-run the full test suite + tour tests against the new Odoo version
5. Update this file with the new version anchor + any deviations
## V19 Migration Notes (already applied)
- `_sql_constraints``models.Constraint` (every persisted model)
- `@api.depends('id')` → removed (none introduced)
- `@route(type='json')``type='jsonrpc'` (all 8 endpoints in
`controllers/assets_controller.py`)
- `numbercall` removed from `ir.cron` (data/cron.xml)
- `res.groups.users``user_ids` and `ir.ui.menu.groups_id`
`group_ids` (security + menu_views.xml)
## Phase 3 → Phase 3.5 Migration
If we ship Phase 3.5 (sub-annual depreciation frequency, disposal journal
entries, multi-currency, allocation rules), changes will go in
incremental commits. No DB migration needed (Phase 3 schema is
forward-compatible — new columns will be nullable / default-valued).
## Coexistence with Enterprise `account_asset`
The migration step in `fusion.migration.wizard` backfills `fusion.asset`
records from existing `account.asset` rows. It is idempotent (skips rows
already linked via the `legacy_account_asset_id` column). Verified live
on westin-v19: 2 records migrated cleanly.
When `account_asset` is installed the Asset Management menu hides via
`fusion_accounting_core.group_fusion_show_when_enterprise_absent`. The
engine and AI tools remain available for chat-driven workflows.

View File

@@ -1,3 +1,5 @@
from . import models
from . import services
from . import controllers
from . import wizards
from . import reports

View File

@@ -1,6 +1,6 @@
{
'name': 'Fusion Accounting Assets',
'version': '19.0.1.0.17',
'version': '19.0.1.0.36',
'category': 'Accounting/Accounting',
'summary': 'AI-augmented asset management with depreciation schedules.',
'description': """
@@ -28,15 +28,45 @@ menu hides; the engine + AI tools remain available for the chat.
'depends': [
'fusion_accounting_core',
'fusion_accounting_ai',
'fusion_accounting_migration',
'account',
'mail',
],
'data': [
'security/ir.model.access.csv',
'data/cron.xml',
'wizards/create_asset_wizard_views.xml',
'wizards/disposal_wizard_views.xml',
'wizards/partial_sale_wizard_views.xml',
'wizards/depreciation_run_wizard_views.xml',
'reports/migration_audit_report_views.xml',
'reports/migration_audit_report_action.xml',
'views/menu_views.xml',
],
'assets': {
'web.assets_backend': [
'fusion_accounting_assets/static/src/scss/_variables.scss',
'fusion_accounting_assets/static/src/scss/assets.scss',
'fusion_accounting_assets/static/src/scss/dark_mode.scss',
'fusion_accounting_assets/static/src/services/assets_service.js',
'fusion_accounting_assets/static/src/views/asset_dashboard/asset_dashboard.js',
'fusion_accounting_assets/static/src/views/asset_dashboard/asset_dashboard.xml',
'fusion_accounting_assets/static/src/views/asset_dashboard/asset_dashboard_view.js',
'fusion_accounting_assets/static/src/components/asset_card/asset_card.js',
'fusion_accounting_assets/static/src/components/asset_card/asset_card.xml',
'fusion_accounting_assets/static/src/components/asset_detail_panel/asset_detail_panel.js',
'fusion_accounting_assets/static/src/components/asset_detail_panel/asset_detail_panel.xml',
'fusion_accounting_assets/static/src/components/depreciation_board/depreciation_board.js',
'fusion_accounting_assets/static/src/components/depreciation_board/depreciation_board.xml',
'fusion_accounting_assets/static/src/components/disposal_dialog/disposal_dialog.js',
'fusion_accounting_assets/static/src/components/disposal_dialog/disposal_dialog.xml',
'fusion_accounting_assets/static/src/components/ai_useful_life_panel/ai_useful_life_panel.js',
'fusion_accounting_assets/static/src/components/ai_useful_life_panel/ai_useful_life_panel.xml',
'fusion_accounting_assets/static/src/components/anomaly_strip/anomaly_strip.js',
'fusion_accounting_assets/static/src/components/anomaly_strip/anomaly_strip.xml',
],
'web.assets_tests': [
'fusion_accounting_assets/static/src/tours/assets_tours.js',
],
},
'installable': True,

View File

@@ -11,6 +11,16 @@
<field name="active" eval="True"/>
</record>
<record id="cron_fusion_assets_refresh_book_values_mv" model="ir.cron">
<field name="name">Fusion Assets — Refresh Book Values MV</field>
<field name="model_id" ref="model_fusion_assets_cron"/>
<field name="state">code</field>
<field name="code">model._cron_refresh_book_values_mv()</field>
<field name="interval_number">1</field>
<field name="interval_type">hours</field>
<field name="active" eval="True"/>
</record>
<record id="cron_fusion_assets_anomaly_scan" model="ir.cron">
<field name="name">Fusion Assets — Monthly Anomaly Scan</field>
<field name="model_id" ref="model_fusion_assets_cron"/>

View File

@@ -0,0 +1,29 @@
-- Materialized view: per-asset book value snapshot.
-- Refreshed via cron. Used by the OWL dashboard for portfolio summaries.
CREATE MATERIALIZED VIEW IF NOT EXISTS fusion_asset_book_values_mv AS
SELECT
a.id AS id,
a.id AS asset_id,
a.company_id,
a.category_id,
a.state,
a.cost,
a.salvage_value,
COALESCE(SUM(CASE WHEN l.is_posted THEN l.amount ELSE 0 END), 0) AS total_depreciated,
a.cost - COALESCE(SUM(CASE WHEN l.is_posted THEN l.amount ELSE 0 END), 0) AS book_value,
COUNT(l.id) FILTER (WHERE l.is_posted) AS posted_periods,
COUNT(l.id) FILTER (WHERE NOT l.is_posted) AS pending_periods,
a.acquisition_date,
a.in_service_date
FROM fusion_asset a
LEFT JOIN fusion_asset_depreciation_line l ON l.asset_id = a.id
GROUP BY a.id, a.company_id, a.category_id, a.state, a.cost, a.salvage_value,
a.acquisition_date, a.in_service_date;
CREATE UNIQUE INDEX IF NOT EXISTS fusion_asset_book_values_mv_pkey
ON fusion_asset_book_values_mv (id);
CREATE INDEX IF NOT EXISTS fusion_asset_book_values_mv_company_state
ON fusion_asset_book_values_mv (company_id, state);
CREATE INDEX IF NOT EXISTS fusion_asset_book_values_mv_category
ON fusion_asset_book_values_mv (category_id) WHERE category_id IS NOT NULL;

View File

@@ -6,3 +6,5 @@ from . import fusion_asset_anomaly
from . import account_move
from . import fusion_asset_engine
from . import fusion_assets_cron
from . import fusion_asset_book_values_mv
from . import fusion_migration_wizard

View File

@@ -0,0 +1,59 @@
"""MV of per-asset book value snapshot. Refresh via cron or model._refresh()."""
import logging
import os
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
class FusionAssetBookValuesMV(models.Model):
_name = "fusion.asset.book.values.mv"
_description = "MV of asset book value snapshot"
_auto = False
_table = "fusion_asset_book_values_mv"
_order = "book_value desc"
asset_id = fields.Many2one('fusion.asset', readonly=True)
company_id = fields.Many2one('res.company', readonly=True)
category_id = fields.Many2one('fusion.asset.category', readonly=True)
state = fields.Char(readonly=True)
cost = fields.Float(readonly=True)
salvage_value = fields.Float(readonly=True)
total_depreciated = fields.Float(readonly=True)
book_value = fields.Float(readonly=True)
posted_periods = fields.Integer(readonly=True)
pending_periods = fields.Integer(readonly=True)
acquisition_date = fields.Date(readonly=True)
in_service_date = fields.Date(readonly=True)
def init(self):
sql_path = os.path.join(
os.path.dirname(__file__), '..', 'data', 'sql',
'create_mv_asset_book_values.sql',
)
with open(sql_path, 'r') as f:
self.env.cr.execute(f.read())
_logger.info("fusion_asset_book_values_mv: created/verified MV")
@api.model
def _refresh(self, *, concurrently=True):
# CONCURRENTLY requires a unique index (we have one) and that the MV
# has been populated at least once. Wrap the concurrent attempt in a
# savepoint so a failure (e.g. first-ever refresh before the MV is
# populated) does NOT poison the surrounding transaction; we then
# fall back to a plain REFRESH.
if concurrently:
try:
with self.env.cr.savepoint():
self.env.cr.execute(
"REFRESH MATERIALIZED VIEW CONCURRENTLY "
"fusion_asset_book_values_mv"
)
return
except Exception as e: # noqa: BLE001
_logger.warning("Concurrent MV refresh failed (%s); fallback", e)
self.env.cr.execute(
"REFRESH MATERIALIZED VIEW fusion_asset_book_values_mv"
)

View File

@@ -36,6 +36,17 @@ class FusionAssetsCron(models.AbstractModel):
"Cron: posted depreciation on %d lines across %d running assets",
posted_total, len(running_assets),
)
# Keep the book-value MV in sync after posting so the dashboard
# reflects today's numbers without waiting for the dedicated MV cron.
try:
self.env['fusion.asset.book.values.mv']._refresh()
except Exception as e: # noqa: BLE001
_logger.warning("Post-cron MV refresh failed: %s", e)
@api.model
def _cron_refresh_book_values_mv(self):
"""Refresh the per-asset book value MV (hourly)."""
self.env['fusion.asset.book.values.mv']._refresh()
@api.model
def _cron_anomaly_scan(self):

View File

@@ -0,0 +1,105 @@
"""Assets-specific migration step.
Backfills fusion.asset from existing account.asset rows (Enterprise) so users
get all their existing assets in the Fusion namespace after switchover."""
import logging
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
# Map Enterprise method names to Fusion method names
ENTERPRISE_METHOD_MAP = {
'linear': 'straight_line',
'degressive': 'declining_balance',
'degressive_then_linear': 'declining_balance', # simplified
'manual': 'straight_line',
'unit_of_production': 'units_of_production',
'units_of_production': 'units_of_production',
}
class FusionMigrationWizard(models.TransientModel):
_inherit = "fusion.migration.wizard"
def _assets_bootstrap_step(self):
"""Backfill fusion.asset from account.asset (Enterprise) if it exists."""
result = {
'step': 'assets_bootstrap',
'enterprise_module_present': False,
'created': 0, 'skipped': 0, 'errors': [],
}
# Check if Enterprise account.asset exists
AccountAsset = self.env.get('account.asset')
if AccountAsset is None:
result['enterprise_module_present'] = False
return result
result['enterprise_module_present'] = True
FusionAsset = self.env['fusion.asset'].sudo()
# Iterate Enterprise records
company_id = self.company_id.id if 'company_id' in self._fields and self.company_id else None
domain = []
if company_id:
domain.append(('company_id', '=', company_id))
try:
ea_records = AccountAsset.sudo().search(domain, limit=10000)
except Exception as e:
result['errors'].append(f"Enterprise search failed: {e}")
return result
for ea in ea_records:
try:
# Idempotent: skip if a fusion asset with same source name exists
existing = FusionAsset.search([
('name', '=', ea.name),
('cost', '=', getattr(ea, 'original_value', 0) or 0),
('company_id', '=', ea.company_id.id),
], limit=1)
if existing:
result['skipped'] += 1
continue
# Map state — Enterprise has 'draft', 'open' (running), 'paused', 'close' (disposed)
ea_state = getattr(ea, 'state', 'draft')
state_map = {'draft': 'draft', 'open': 'running',
'paused': 'paused', 'close': 'disposed',
'model': 'draft'}
state = state_map.get(ea_state, 'draft')
method = ENTERPRISE_METHOD_MAP.get(
getattr(ea, 'method', 'linear'), 'straight_line')
FusionAsset.create({
'name': ea.name,
'cost': getattr(ea, 'original_value', 0) or 0,
'salvage_value': getattr(ea, 'salvage_value', 0) or 0,
'acquisition_date': getattr(ea, 'acquisition_date', False) or fields.Date.today(),
'in_service_date': getattr(ea, 'prorata_date', False) or False,
'method': method,
'useful_life_years': getattr(ea, 'method_number', 5) or 5,
'declining_rate_pct': getattr(ea, 'method_progress_factor', 0.2) * 100 if hasattr(ea, 'method_progress_factor') else 20.0,
'company_id': ea.company_id.id,
'state': state,
})
result['created'] += 1
except Exception as e:
result['errors'].append(f"Asset {ea.id}: {e}")
_logger.info(
"fusion_accounting_assets migration: %d created, %d skipped, %d errors",
result['created'], result['skipped'], len(result['errors']))
return result
def action_run_migration(self):
"""Override to add assets-bootstrap step."""
result = super().action_run_migration() if hasattr(super(), 'action_run_migration') else None
try:
self._assets_bootstrap_step()
except Exception as e:
_logger.warning("assets_bootstrap_step failed: %s", e)
return result

View File

@@ -0,0 +1 @@
from . import migration_audit_report

View File

@@ -0,0 +1,36 @@
"""QWeb PDF: migration audit report for fusion_accounting_assets."""
from odoo import api, models
class FusionAssetsMigrationAuditReport(models.AbstractModel):
_name = "report.fusion_accounting_assets.migration_audit_template"
_description = "Fusion Assets Migration Audit"
@api.model
def _get_report_values(self, docids, data=None):
wizards = self.env['fusion.migration.wizard'].browse(docids) if docids else self.env['fusion.migration.wizard']
Asset = self.env['fusion.asset']
company_stats = []
for company in self.env['res.company'].search([]):
assets = Asset.search([('company_id', '=', company.id)])
by_state = {}
for state in ('draft', 'running', 'paused', 'disposed'):
by_state[state] = sum(1 for a in assets if a.state == state)
total_cost = sum(a.cost for a in assets)
total_book = sum(a.book_value for a in assets)
total_dep = sum(a.total_depreciated for a in assets)
company_stats.append({
'company': company,
'count': len(assets),
'by_state': by_state,
'total_cost': total_cost,
'total_book_value': total_book,
'total_depreciated': total_dep,
})
return {
'doc_ids': docids,
'doc_model': 'fusion.migration.wizard',
'docs': wizards,
'company_stats': company_stats,
}

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="action_report_assets_migration_audit" model="ir.actions.report">
<field name="name">Assets Migration Audit</field>
<field name="model">fusion.migration.wizard</field>
<field name="report_type">qweb-pdf</field>
<field name="report_name">fusion_accounting_assets.migration_audit_template</field>
<field name="report_file">fusion_accounting_assets.migration_audit_template</field>
<field name="binding_model_id" ref="fusion_accounting_migration.model_fusion_migration_wizard"/>
</record>
</odoo>

View File

@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="migration_audit_template">
<t t-call="web.html_container">
<t t-call="web.external_layout">
<div class="page">
<h2>Fusion Assets Migration Audit</h2>
<p>
<span t-esc="context_timestamp(datetime.datetime.now()).strftime('%Y-%m-%d %H:%M')"/>
</p>
<h3>Per-Company Summary</h3>
<table class="table table-sm">
<thead>
<tr>
<th>Company</th>
<th class="text-end">Total Assets</th>
<th class="text-end">Draft</th>
<th class="text-end">Running</th>
<th class="text-end">Paused</th>
<th class="text-end">Disposed</th>
<th class="text-end">Total Cost</th>
<th class="text-end">Total NBV</th>
<th class="text-end">Total Depreciated</th>
</tr>
</thead>
<tbody>
<tr t-foreach="company_stats" t-as="cs">
<td><span t-field="cs['company'].name"/></td>
<td class="text-end"><span t-esc="cs['count']"/></td>
<td class="text-end"><span t-esc="cs['by_state']['draft']"/></td>
<td class="text-end"><span t-esc="cs['by_state']['running']"/></td>
<td class="text-end"><span t-esc="cs['by_state']['paused']"/></td>
<td class="text-end"><span t-esc="cs['by_state']['disposed']"/></td>
<td class="text-end"><span t-esc="'{:,.2f}'.format(cs['total_cost'])"/></td>
<td class="text-end"><span t-esc="'{:,.2f}'.format(cs['total_book_value'])"/></td>
<td class="text-end"><span t-esc="'{:,.2f}'.format(cs['total_depreciated'])"/></td>
</tr>
</tbody>
</table>
<p class="text-muted small">
Generated by Fusion Accounting Assets
</p>
</div>
</t>
</t>
</template>
</odoo>

View File

@@ -9,3 +9,7 @@ access_fusion_asset_disposal_user,fusion.asset.disposal.user,model_fusion_asset_
access_fusion_asset_disposal_admin,fusion.asset.disposal.admin,model_fusion_asset_disposal,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1
access_fusion_asset_anomaly_user,fusion.asset.anomaly.user,model_fusion_asset_anomaly,base.group_user,1,0,0,0
access_fusion_asset_anomaly_admin,fusion.asset.anomaly.admin,model_fusion_asset_anomaly,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1
access_fusion_create_asset_wizard_user,fusion.create.asset.wizard.user,model_fusion_create_asset_wizard,base.group_user,1,1,1,0
access_fusion_disposal_wizard_user,fusion.disposal.wizard.user,model_fusion_disposal_wizard,base.group_user,1,1,1,0
access_fusion_partial_sale_wizard_user,fusion.partial.sale.wizard.user,model_fusion_partial_sale_wizard,base.group_user,1,1,1,0
access_fusion_depreciation_run_wizard_user,fusion.depreciation.run.wizard.user,model_fusion_depreciation_run_wizard,base.group_user,1,1,1,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
9 access_fusion_asset_disposal_admin fusion.asset.disposal.admin model_fusion_asset_disposal fusion_accounting_core.group_fusion_accounting_admin 1 1 1 1
10 access_fusion_asset_anomaly_user fusion.asset.anomaly.user model_fusion_asset_anomaly base.group_user 1 0 0 0
11 access_fusion_asset_anomaly_admin fusion.asset.anomaly.admin model_fusion_asset_anomaly fusion_accounting_core.group_fusion_accounting_admin 1 1 1 1
12 access_fusion_create_asset_wizard_user fusion.create.asset.wizard.user model_fusion_create_asset_wizard base.group_user 1 1 1 0
13 access_fusion_disposal_wizard_user fusion.disposal.wizard.user model_fusion_disposal_wizard base.group_user 1 1 1 0
14 access_fusion_partial_sale_wizard_user fusion.partial.sale.wizard.user model_fusion_partial_sale_wizard base.group_user 1 1 1 0
15 access_fusion_depreciation_run_wizard_user fusion.depreciation.run.wizard.user model_fusion_depreciation_run_wizard base.group_user 1 1 1 0

View File

@@ -0,0 +1,41 @@
/** @odoo-module **/
import { Component, useState } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
export class AiUsefulLifePanel extends Component {
static template = "fusion_accounting_assets.AiUsefulLifePanel";
static props = {
description: { type: String, optional: true },
amount: { type: Number, optional: true },
onSelect: { type: Function, optional: true },
};
setup() {
this.assets = useService("fusion_assets");
this.state = useState({
suggestion: null,
isLoading: false,
descInput: this.props.description || '',
amountInput: this.props.amount || '',
});
}
async onSuggest() {
this.state.isLoading = true;
try {
this.state.suggestion = await this.assets.suggestUsefulLife(
this.state.descInput,
parseFloat(this.state.amountInput) || null,
);
} finally {
this.state.isLoading = false;
}
}
onUseSuggestion() {
if (this.state.suggestion && this.props.onSelect) {
this.props.onSelect(this.state.suggestion);
}
}
}

View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting_assets.AiUsefulLifePanel">
<div style="background: white; padding: 1rem; border: 1px solid #e5e7eb; border-radius: 0.5rem;">
<h5>AI Suggest Useful Life</h5>
<div class="mb-2">
<label>Description</label>
<input class="form-control" t-att-value="state.descInput"
t-on-input="(ev) => state.descInput = ev.target.value"/>
</div>
<div class="mb-2">
<label>Amount</label>
<input type="number" class="form-control" t-att-value="state.amountInput"
t-on-input="(ev) => state.amountInput = ev.target.value"/>
</div>
<button class="btn_asset primary" t-on-click="onSuggest"
t-att-disabled="state.isLoading">
<t t-if="state.isLoading">Asking AI...</t>
<t t-else="">Suggest</t>
</button>
<div t-if="state.suggestion" class="mt-3 p-2"
style="background: #eff6ff; border-radius: 0.25rem;">
<div><strong>Suggested life:</strong> <t t-esc="state.suggestion.useful_life_years"/> years</div>
<div><strong>Method:</strong> <t t-esc="state.suggestion.depreciation_method"/></div>
<div class="text-muted small">
<em><t t-esc="state.suggestion.rationale"/></em>
(confidence: <t t-esc="(state.suggestion.confidence * 100).toFixed(0)"/>%)
</div>
<button class="btn_asset mt-2" t-if="props.onSelect" t-on-click="onUseSuggestion">
Use This
</button>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,17 @@
/** @odoo-module **/
import { Component } from "@odoo/owl";
export class AnomalyStrip extends Component {
static template = "fusion_accounting_assets.AnomalyStrip";
static props = {
anomaly: { type: Object },
};
formatNumber(n) {
if (n === null || n === undefined) return "";
return new Intl.NumberFormat(undefined, {
minimumFractionDigits: 0, maximumFractionDigits: 1,
}).format(n);
}
}

View File

@@ -0,0 +1,19 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting_assets.AnomalyStrip">
<div class="o_fusion_anomaly_strip" t-att-data-severity="props.anomaly.severity">
<strong>
<t t-esc="props.anomaly.asset_name || 'Asset'"/>
</strong>
<span class="ms-2">
<t t-esc="props.anomaly.anomaly_type.replace('_', ' ')"/>:
<t t-esc="formatNumber(props.anomaly.variance_pct)"/>%
</span>
<span class="ms-3 text-muted">
<t t-esc="props.anomaly.detail"/>
</span>
</div>
</t>
</templates>

View File

@@ -0,0 +1,13 @@
/** @odoo-module **/
import { Component } from "@odoo/owl";
export class AssetCard extends Component {
static template = "fusion_accounting_assets.AssetCard";
static props = {
asset: { type: Object },
selected: { type: Boolean, optional: true },
onSelect: { type: Function },
formatCurrency: { type: Function },
};
}

View File

@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting_assets.AssetCard">
<div class="o_fusion_assets_card"
t-att-class="props.selected ? 'selected' : ''"
t-on-click="props.onSelect">
<div class="o_fusion_assets_card_header">
<div class="asset-name">
<t t-esc="props.asset.name"/>
<span t-if="props.asset.code" class="text-muted ms-2">
[<t t-esc="props.asset.code"/>]
</span>
</div>
<div class="asset-state-badge" t-att-data-state="props.asset.state">
<t t-esc="props.asset.state"/>
</div>
</div>
<div class="asset-numbers">
<div>
<span class="label">Cost:</span>
<span class="value">$<t t-esc="props.formatCurrency(props.asset.cost)"/></span>
</div>
<div>
<span class="label">Book Value:</span>
<span class="value">$<t t-esc="props.formatCurrency(props.asset.book_value)"/></span>
</div>
<div>
<span class="label">Method:</span>
<span class="value"><t t-esc="props.asset.method"/></span>
</div>
<div t-if="props.asset.category_name">
<span class="label">Category:</span>
<span class="value"><t t-esc="props.asset.category_name"/></span>
</div>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,36 @@
/** @odoo-module **/
import { Component } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
import { DepreciationBoard } from "../depreciation_board/depreciation_board";
export class AssetDetailPanel extends Component {
static template = "fusion_accounting_assets.AssetDetailPanel";
static props = {
detail: { type: Object },
formatCurrency: { type: Function },
};
static components = { DepreciationBoard };
setup() {
this.assets = useService("fusion_assets");
}
async onComputeSchedule() {
await this.assets.computeSchedule(this.props.detail.asset.id, false);
}
async onRecomputeSchedule() {
await this.assets.computeSchedule(this.props.detail.asset.id, true);
}
async onPostDepreciation() {
await this.assets.postDepreciation(this.props.detail.asset.id);
}
async onDispose() {
const saleAmount = parseFloat(prompt("Sale amount (0 for scrap)?", "0"));
if (isNaN(saleAmount)) return;
await this.assets.disposeAsset(this.props.detail.asset.id, { saleAmount });
}
}

View File

@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting_assets.AssetDetailPanel">
<div style="background: white; padding: 1rem; border-radius: 0.5rem; border: 1px solid #e5e7eb;">
<h3><t t-esc="props.detail.asset.name"/></h3>
<div class="text-muted" t-if="props.detail.asset.code">
[<t t-esc="props.detail.asset.code"/>]
</div>
<div class="mt-3">
<div><strong>State:</strong> <t t-esc="props.detail.asset.state"/></div>
<div><strong>Cost:</strong> $<t t-esc="props.formatCurrency(props.detail.asset.cost)"/></div>
<div><strong>Salvage:</strong> $<t t-esc="props.formatCurrency(props.detail.asset.salvage_value)"/></div>
<div><strong>Book Value:</strong> $<t t-esc="props.formatCurrency(props.detail.asset.book_value)"/></div>
<div><strong>Total Depreciated:</strong> $<t t-esc="props.formatCurrency(props.detail.asset.total_depreciated)"/></div>
<div><strong>Method:</strong> <t t-esc="props.detail.asset.method"/></div>
<div><strong>Useful Life:</strong> <t t-esc="props.detail.asset.useful_life_years"/> years</div>
</div>
<div class="d-flex mt-3" style="gap: 0.5rem; flex-wrap: wrap;">
<button class="btn_asset" t-on-click="onComputeSchedule">Compute Schedule</button>
<button class="btn_asset" t-on-click="onRecomputeSchedule">Recompute</button>
<button class="btn_asset primary"
t-if="props.detail.asset.state === 'running'"
t-on-click="onPostDepreciation">Post Next</button>
<button class="btn_asset danger"
t-if="props.detail.asset.state !== 'disposed'"
t-on-click="onDispose">Dispose</button>
</div>
<h4 class="mt-4">Depreciation Schedule</h4>
<DepreciationBoard t-if="props.detail.depreciation_lines"
lines="props.detail.depreciation_lines"
formatCurrency="props.formatCurrency"/>
<div t-if="props.detail.anomalies and props.detail.anomalies.length" class="mt-3">
<h4>Active Anomalies</h4>
<div t-foreach="props.detail.anomalies" t-as="a" t-key="a.id"
class="o_fusion_anomaly_strip" t-att-data-severity="a.severity">
<strong><t t-esc="a.anomaly_type"/></strong>: <t t-esc="a.detail"/>
</div>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,16 @@
/** @odoo-module **/
import { Component } from "@odoo/owl";
export class DepreciationBoard extends Component {
static template = "fusion_accounting_assets.DepreciationBoard";
static props = {
lines: { type: Array },
formatCurrency: { type: Function },
};
rowClass(line) {
if (line.is_posted) return "posted";
return "";
}
}

View File

@@ -0,0 +1,35 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting_assets.DepreciationBoard">
<div class="o_fusion_assets_table">
<table>
<thead>
<tr>
<th>#</th>
<th>Date</th>
<th class="text-end">Amount</th>
<th class="text-end">Accumulated</th>
<th class="text-end">Book Value</th>
<th>Status</th>
</tr>
</thead>
<tbody>
<tr t-foreach="props.lines" t-as="line" t-key="line.id"
t-att-class="rowClass(line)">
<td><t t-esc="line.period_index + 1"/></td>
<td><t t-esc="line.scheduled_date"/></td>
<td class="text-end">$<t t-esc="props.formatCurrency(line.amount)"/></td>
<td class="text-end">$<t t-esc="props.formatCurrency(line.accumulated)"/></td>
<td class="text-end">$<t t-esc="props.formatCurrency(line.book_value_at_end)"/></td>
<td>
<t t-if="line.is_posted">Posted</t>
<t t-else="">Pending</t>
</td>
</tr>
</tbody>
</table>
</div>
</t>
</templates>

View File

@@ -0,0 +1,34 @@
/** @odoo-module **/
import { Component, useState } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
export class DisposalDialog extends Component {
static template = "fusion_accounting_assets.DisposalDialog";
static props = {
assetId: { type: Number },
onClose: { type: Function },
};
setup() {
this.assets = useService("fusion_assets");
this.state = useState({
disposalType: 'sale',
saleAmount: 0,
saleDate: new Date().toISOString().slice(0, 10),
});
}
async onConfirm() {
try {
await this.assets.disposeAsset(this.props.assetId, {
disposalType: this.state.disposalType,
saleAmount: parseFloat(this.state.saleAmount) || 0,
saleDate: this.state.saleDate,
});
this.props.onClose();
} catch (e) {
// Error already shown by service
}
}
}

View File

@@ -0,0 +1,45 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting_assets.DisposalDialog">
<div class="modal" style="display: block; background: rgba(0,0,0,0.5); position: fixed; top:0; left:0; right:0; bottom:0; z-index: 1050;">
<div class="modal-dialog" style="margin: 5vh auto; max-width: 500px;">
<div class="modal-content">
<div class="modal-header">
<h5>Dispose Asset</h5>
<button class="btn-close" t-on-click="props.onClose">&#215;</button>
</div>
<div class="modal-body">
<div class="mb-3">
<label>Disposal Type</label>
<select class="form-select"
t-on-change="(ev) => state.disposalType = ev.target.value">
<option value="sale" selected="state.disposalType === 'sale'">Sale</option>
<option value="scrap" selected="state.disposalType === 'scrap'">Scrap</option>
<option value="donation" selected="state.disposalType === 'donation'">Donation</option>
<option value="lost" selected="state.disposalType === 'lost'">Lost</option>
</select>
</div>
<div class="mb-3" t-if="state.disposalType === 'sale'">
<label>Sale Amount ($)</label>
<input type="number" class="form-control"
t-att-value="state.saleAmount"
t-on-change="(ev) => state.saleAmount = ev.target.value"/>
</div>
<div class="mb-3">
<label>Date</label>
<input type="date" class="form-control"
t-att-value="state.saleDate"
t-on-change="(ev) => state.saleDate = ev.target.value"/>
</div>
</div>
<div class="modal-footer">
<button class="btn_asset" t-on-click="props.onClose">Cancel</button>
<button class="btn_asset primary" t-on-click="onConfirm">Confirm Disposal</button>
</div>
</div>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,40 @@
// Fusion assets design tokens (extends Phase 1+2's tokens for consistency).
$asset-bg-primary: #ffffff;
$asset-bg-secondary: #f9fafb;
$asset-bg-tertiary: #f3f4f6;
$asset-border: #e5e7eb;
$asset-text-primary: #111827;
$asset-text-secondary: #6b7280;
$asset-text-muted: #9ca3af;
$asset-accent: #3b82f6;
$asset-accent-bg: #eff6ff;
// State colors
$asset-state-draft: #6b7280;
$asset-state-running: #10b981;
$asset-state-paused: #f59e0b;
$asset-state-disposed: #ef4444;
// Severity colors (mirrors phase 2)
$asset-severity-high: #ef4444;
$asset-severity-high-bg: #fef2f2;
$asset-severity-medium: #f59e0b;
$asset-severity-medium-bg: #fffbeb;
$asset-severity-low: #10b981;
$asset-severity-low-bg: #ecfdf5;
$asset-space-1: 0.25rem;
$asset-space-2: 0.5rem;
$asset-space-3: 0.75rem;
$asset-space-4: 1rem;
$asset-space-6: 1.5rem;
$asset-font-size-xs: 0.75rem;
$asset-font-size-sm: 0.875rem;
$asset-font-size-base: 1rem;
$asset-font-size-lg: 1.125rem;
$asset-font-size-xl: 1.25rem;
$asset-border-radius: 0.375rem;
$asset-border-radius-md: 0.5rem;

View File

@@ -0,0 +1,157 @@
@import "variables";
.o_fusion_assets {
background: $asset-bg-secondary;
min-height: 100vh;
&_header {
background: $asset-bg-primary;
border-bottom: 1px solid $asset-border;
padding: $asset-space-4 $asset-space-6;
display: flex;
justify-content: space-between;
align-items: center;
h1 { font-size: $asset-font-size-xl; margin: 0; }
.o_fusion_assets_summary {
display: flex;
gap: $asset-space-6;
font-size: $asset-font-size-sm;
color: $asset-text-secondary;
.summary-value {
font-weight: 600;
color: $asset-text-primary;
margin-left: $asset-space-1;
}
}
}
&_card {
background: $asset-bg-primary;
border: 1px solid $asset-border;
border-radius: $asset-border-radius-md;
padding: $asset-space-4;
margin-bottom: $asset-space-3;
cursor: pointer;
transition: all 200ms ease-in-out;
&:hover {
border-color: $asset-accent;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
&.selected {
border-color: $asset-accent;
background: $asset-accent-bg;
}
&_header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: $asset-space-2;
}
.asset-name {
font-weight: 600;
font-size: $asset-font-size-base;
}
.asset-state-badge {
padding: $asset-space-1 $asset-space-2;
border-radius: $asset-border-radius;
font-size: $asset-font-size-xs;
font-weight: 500;
text-transform: uppercase;
&[data-state="draft"] { background: lighten($asset-state-draft, 40%); color: $asset-state-draft; }
&[data-state="running"] { background: lighten($asset-state-running, 45%); color: $asset-state-running; }
&[data-state="paused"] { background: lighten($asset-state-paused, 35%); color: $asset-state-paused; }
&[data-state="disposed"] { background: lighten($asset-state-disposed, 35%); color: $asset-state-disposed; }
}
.asset-numbers {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $asset-space-2;
font-size: $asset-font-size-sm;
color: $asset-text-secondary;
.label {
font-weight: 500;
margin-right: $asset-space-2;
}
.value {
color: $asset-text-primary;
font-weight: 500;
}
}
}
&_table {
background: $asset-bg-primary;
border-radius: $asset-border-radius-md;
overflow: hidden;
font-size: $asset-font-size-sm;
table { width: 100%; border-collapse: collapse; }
th {
background: $asset-bg-tertiary;
padding: $asset-space-3;
text-align: left;
font-weight: 600;
color: $asset-text-secondary;
border-bottom: 1px solid $asset-border;
}
td {
padding: $asset-space-2 $asset-space-3;
border-bottom: 1px solid lighten($asset-border, 5%);
}
tr.posted { background: $asset-bg-secondary; }
tr.due-now { background: $asset-severity-medium-bg; }
.text-end { text-align: right; }
}
.btn_asset {
padding: $asset-space-2 $asset-space-4;
border-radius: $asset-border-radius;
background: $asset-bg-primary;
border: 1px solid $asset-border;
color: $asset-text-primary;
font-size: $asset-font-size-sm;
cursor: pointer;
&:hover { background: $asset-bg-tertiary; }
&.primary {
background: $asset-accent;
border-color: $asset-accent;
color: white;
&:hover { background: darken($asset-accent, 8%); }
}
&.danger {
background: $asset-severity-high;
border-color: $asset-severity-high;
color: white;
}
}
}
.o_fusion_anomaly_strip {
margin: $asset-space-3 0;
padding: $asset-space-3;
border-radius: $asset-border-radius;
border: 1px solid;
font-size: $asset-font-size-sm;
&[data-severity="high"] { background: $asset-severity-high-bg; border-color: $asset-severity-high; }
&[data-severity="medium"] { background: $asset-severity-medium-bg; border-color: $asset-severity-medium; }
&[data-severity="low"] { background: $asset-severity-low-bg; border-color: $asset-severity-low; }
}

View File

@@ -0,0 +1,32 @@
@import "variables";
[data-color-scheme="dark"] .o_fusion_assets {
background: #1f2937; color: #f9fafb;
&_header, &_card, &_table { background: #111827; border-color: #374151; }
&_card {
&:hover { border-color: #60a5fa; }
&.selected { background: #1e3a8a; border-color: #60a5fa; }
.asset-numbers .label { color: #9ca3af; }
.asset-numbers .value { color: #f9fafb; }
}
&_table {
th { background: #1f2937; color: #d1d5db; }
td { border-color: #374151; }
tr.posted { background: #1f2937; }
}
.btn_asset {
background: #374151; border-color: #4b5563; color: #f9fafb;
&:hover { background: #4b5563; }
&.primary { background: #3b82f6; }
}
.o_fusion_anomaly_strip {
&[data-severity="high"] { background: rgba(239, 68, 68, 0.15); }
&[data-severity="medium"] { background: rgba(245, 158, 11, 0.15); }
&[data-severity="low"] { background: rgba(16, 185, 129, 0.15); }
}
}

View File

@@ -0,0 +1,149 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { reactive } from "@odoo/owl";
const ENDPOINT_BASE = "/fusion/assets";
export class AssetsService {
constructor(env, services) {
this.env = env;
this.rpc = services.rpc;
this.notification = services.notification;
this.state = reactive({
assets: [],
count: 0,
total: 0,
stateFilter: null,
categoryFilter: null,
isLoading: false,
isProcessing: false,
selectedAssetId: null,
selectedDetail: null,
companyId: null,
limit: 50,
offset: 0,
anomalies: [],
});
}
async loadAssets(companyId = null) {
this.state.companyId = companyId;
this.state.isLoading = true;
try {
const result = await this.rpc(`${ENDPOINT_BASE}/list`, {
state: this.state.stateFilter,
category_id: this.state.categoryFilter,
limit: this.state.limit,
offset: this.state.offset,
company_id: companyId,
});
this.state.assets = result.assets;
this.state.count = result.count;
this.state.total = result.total;
} finally {
this.state.isLoading = false;
}
}
async selectAsset(assetId) {
this.state.selectedAssetId = assetId;
this.state.selectedDetail = null;
try {
const result = await this.rpc(`${ENDPOINT_BASE}/get_detail`, {
asset_id: assetId,
});
this.state.selectedDetail = result;
} catch (err) {
this.notification.add(`Failed to load asset detail: ${err.message || err}`, { type: "danger" });
}
}
async computeSchedule(assetId, recompute = false) {
this.state.isProcessing = true;
try {
const result = await this.rpc(`${ENDPOINT_BASE}/compute_schedule`, {
asset_id: assetId, recompute: recompute,
});
this.notification.add(`Schedule computed (${result.lines_created} lines)`, { type: "success" });
if (this.state.selectedAssetId === assetId) {
await this.selectAsset(assetId);
}
return result;
} catch (err) {
this.notification.add(`Compute failed: ${err.message || err}`, { type: "danger" });
throw err;
} finally {
this.state.isProcessing = false;
}
}
async postDepreciation(assetId) {
this.state.isProcessing = true;
try {
const result = await this.rpc(`${ENDPOINT_BASE}/post_depreciation`, {
asset_id: assetId,
});
this.notification.add(`Posted ${result.posted_count} period(s)`, { type: "success" });
if (this.state.selectedAssetId === assetId) {
await this.selectAsset(assetId);
}
return result;
} catch (err) {
this.notification.add(`Post failed: ${err.message || err}`, { type: "danger" });
throw err;
} finally {
this.state.isProcessing = false;
}
}
async disposeAsset(assetId, { saleAmount = 0, saleDate = null, salePartnerId = null, disposalType = "sale" } = {}) {
this.state.isProcessing = true;
try {
const result = await this.rpc(`${ENDPOINT_BASE}/dispose`, {
asset_id: assetId, sale_amount: saleAmount,
sale_date: saleDate, sale_partner_id: salePartnerId,
disposal_type: disposalType,
});
this.notification.add(`Asset disposed: gain/loss $${result.gain_loss_amount.toFixed(2)}`, { type: "success" });
await this.loadAssets(this.state.companyId);
return result;
} catch (err) {
this.notification.add(`Dispose failed: ${err.message || err}`, { type: "danger" });
throw err;
} finally {
this.state.isProcessing = false;
}
}
async fetchAnomalies(severity = null) {
try {
const result = await this.rpc(`${ENDPOINT_BASE}/get_anomalies`, {
severity: severity, company_id: this.state.companyId,
});
this.state.anomalies = result.anomalies || [];
} catch (err) {
this.state.anomalies = [];
}
}
async suggestUsefulLife(description, amount = null, partnerName = null) {
return await this.rpc(`${ENDPOINT_BASE}/suggest_useful_life`, {
description: description, amount: amount, partner_name: partnerName,
});
}
setStateFilter(state) {
this.state.stateFilter = state;
this.state.offset = 0;
this.loadAssets(this.state.companyId);
}
}
export const assetsService = {
dependencies: ["rpc", "notification"],
start(env, services) { return new AssetsService(env, services); },
};
registry.category("services").add("fusion_assets", assetsService);

View File

@@ -0,0 +1,80 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
/**
* 5 OWL tours for fusion_accounting_assets smoke testing.
*
* Each tour scripts a user interaction and is invoked from Python via
* HttpCase.start_tour(). Useful for catching UI regressions that asset-bundle
* compilation alone won't catch.
*/
// Tour 1: smoke
registry.category("web_tour.tours").add("fusion_assets_smoke", {
test: true,
url: "/odoo",
steps: () => [
{
content: "Wait for app",
trigger: ".o_navbar",
},
],
});
// Tour 2: open asset list
registry.category("web_tour.tours").add("fusion_assets_list", {
test: true,
url: "/odoo/action-fusion_accounting_assets.action_fusion_asset_list",
steps: () => [
{
content: "List view loads",
trigger: ".o_list_view, .o_view_nocontent",
},
],
});
// Tour 3: open categories
registry.category("web_tour.tours").add("fusion_assets_categories", {
test: true,
url: "/odoo/action-fusion_accounting_assets.action_fusion_asset_category_list",
steps: () => [
{
content: "Categories view loads",
trigger: ".o_list_view, .o_view_nocontent",
},
],
});
// Tour 4: anomalies
registry.category("web_tour.tours").add("fusion_assets_anomalies", {
test: true,
url: "/odoo/action-fusion_accounting_assets.action_fusion_asset_anomaly_list",
steps: () => [
{
content: "Anomalies view loads",
trigger: ".o_list_view, .o_view_nocontent",
},
],
});
// Tour 5: depreciation run wizard
registry.category("web_tour.tours").add("fusion_assets_depreciation_wizard", {
test: true,
url: "/odoo/action-fusion_accounting_assets.action_fusion_depreciation_run_wizard",
steps: () => [
{
content: "Wizard form opens",
trigger: ".modal-dialog .o_form_view",
},
{
content: "Period date field exists",
trigger: ".modal-dialog [name='period_date']",
},
{
content: "Close wizard",
trigger: ".modal-dialog .btn-secondary",
run: "click",
},
],
});

View File

@@ -0,0 +1,47 @@
/** @odoo-module **/
import { Component, useState, onWillStart } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
import { AssetCard } from "../../components/asset_card/asset_card";
import { AssetDetailPanel } from "../../components/asset_detail_panel/asset_detail_panel";
import { AnomalyStrip } from "../../components/anomaly_strip/anomaly_strip";
export class AssetDashboard extends Component {
static template = "fusion_accounting_assets.AssetDashboard";
static props = { "*": true };
static components = { AssetCard, AssetDetailPanel, AnomalyStrip };
setup() {
this.assets = useService("fusion_assets");
this.state = useState(this.assets.state);
const companyId = this.env.services.user?.context?.allowed_company_ids?.[0];
onWillStart(async () => {
await this.assets.loadAssets(companyId);
await this.assets.fetchAnomalies();
});
}
onSelectAsset(id) {
this.assets.selectAsset(id);
}
onStateFilter(state) {
this.assets.setStateFilter(state || null);
}
formatCurrency(amount) {
return new Intl.NumberFormat(undefined, {
minimumFractionDigits: 2, maximumFractionDigits: 2,
}).format(amount || 0);
}
get totalCost() {
return this.state.assets.reduce((sum, a) => sum + a.cost, 0);
}
get totalBookValue() {
return this.state.assets.reduce((sum, a) => sum + a.book_value, 0);
}
}

View File

@@ -0,0 +1,56 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting_assets.AssetDashboard">
<div class="o_fusion_assets">
<div class="o_fusion_assets_header">
<div>
<h1>Asset Management</h1>
<div class="text-muted">
<t t-esc="state.count"/> of <t t-esc="state.total"/> assets
</div>
</div>
<div class="o_fusion_assets_summary">
<div>Cost: <span class="summary-value">$<t t-esc="formatCurrency(totalCost)"/></span></div>
<div>Book Value: <span class="summary-value">$<t t-esc="formatCurrency(totalBookValue)"/></span></div>
</div>
</div>
<div class="d-flex" style="gap: 0.5rem; padding: 0.75rem;">
<button class="btn_asset" t-on-click="() => onStateFilter(null)"
t-att-class="state.stateFilter === null ? 'primary' : ''">All</button>
<button class="btn_asset" t-on-click="() => onStateFilter('draft')"
t-att-class="state.stateFilter === 'draft' ? 'primary' : ''">Draft</button>
<button class="btn_asset" t-on-click="() => onStateFilter('running')"
t-att-class="state.stateFilter === 'running' ? 'primary' : ''">Running</button>
<button class="btn_asset" t-on-click="() => onStateFilter('paused')"
t-att-class="state.stateFilter === 'paused' ? 'primary' : ''">Paused</button>
<button class="btn_asset" t-on-click="() => onStateFilter('disposed')"
t-att-class="state.stateFilter === 'disposed' ? 'primary' : ''">Disposed</button>
</div>
<AnomalyStrip t-foreach="state.anomalies" t-as="anomaly"
t-key="anomaly.id" anomaly="anomaly"/>
<div class="d-flex" style="gap: 1rem; padding: 1rem;">
<div style="flex: 1 1 60%;">
<div t-if="state.isLoading" class="text-center p-4 text-muted">Loading...</div>
<div t-elif="state.assets.length === 0" class="text-center p-4 text-muted">No assets found.</div>
<div t-else="">
<AssetCard t-foreach="state.assets" t-as="asset" t-key="asset.id"
asset="asset" selected="state.selectedAssetId === asset.id"
onSelect="() => onSelectAsset(asset.id)"
formatCurrency="formatCurrency.bind(this)"/>
</div>
</div>
<div style="flex: 1 1 40%;">
<AssetDetailPanel t-if="state.selectedDetail"
detail="state.selectedDetail"
formatCurrency="formatCurrency.bind(this)"/>
<div t-else="" class="p-4 text-muted">Select an asset to see details.</div>
</div>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,14 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { AssetDashboard } from "./asset_dashboard";
export const fusionAssetDashboardView = {
type: "fusion_assets",
Controller: AssetDashboard,
display_name: "Fusion Asset Management",
icon: "fa-cubes",
multiRecord: true,
};
registry.category("views").add("fusion_assets", fusionAssetDashboardView);

View File

@@ -15,3 +15,17 @@ from . import test_assets_controller
from . import test_assets_adapter
from . import test_asset_tools
from . import test_assets_cron
from . import test_engine_property
from . import test_method_integration
from . import test_asset_book_values_mv
from . import test_performance_benchmarks
from . import test_create_asset_wizard
from . import test_disposal_wizard
from . import test_partial_sale_wizard
from . import test_depreciation_run_wizard
from . import test_migration_round_trip
from . import test_audit_report
from . import test_coexistence
from . import test_assets_tours
from . import test_perf_controller
from . import test_local_llm_compat

View File

@@ -0,0 +1,29 @@
"""Tests for the per-asset book value MV."""
from datetime import date
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestAssetBookValuesMV(TransactionCase):
def test_mv_exists_and_is_queryable(self):
self.env['fusion.asset.book.values.mv']._refresh(concurrently=False)
rows = self.env['fusion.asset.book.values.mv'].search([], limit=10)
self.assertIsNotNone(rows)
def test_mv_includes_new_asset_after_refresh(self):
asset = self.env['fusion.asset'].create({
'name': 'MV Test', 'cost': 5000, 'salvage_value': 500,
'acquisition_date': date(2026, 1, 1),
'method': 'straight_line', 'useful_life_years': 5,
})
self.env.flush_all()
self.env['fusion.asset.book.values.mv']._refresh(concurrently=False)
mv_row = self.env['fusion.asset.book.values.mv'].search([
('asset_id', '=', asset.id),
], limit=1)
self.assertTrue(mv_row)
self.assertAlmostEqual(mv_row.book_value, 5000, places=2)

View File

@@ -0,0 +1,28 @@
"""Python wrappers that run the OWL tours via HttpCase.start_tour.
Tours require an HTTP server + headless browser. They are tagged with
'tour' so they can be excluded from fast unit-test runs and selected
explicitly when CI has the right infra (chromium + xvfb / websocket-client).
"""
from odoo.tests.common import HttpCase
from odoo.tests import tagged
@tagged('post_install', '-at_install', 'tour')
class TestAssetsTours(HttpCase):
def test_smoke_tour(self):
self.start_tour("/odoo", "fusion_assets_smoke", login="admin")
def test_list_tour(self):
self.start_tour("/odoo", "fusion_assets_list", login="admin")
def test_categories_tour(self):
self.start_tour("/odoo", "fusion_assets_categories", login="admin")
def test_anomalies_tour(self):
self.start_tour("/odoo", "fusion_assets_anomalies", login="admin")
def test_depreciation_wizard_tour(self):
self.start_tour("/odoo", "fusion_assets_depreciation_wizard", login="admin")

View File

@@ -0,0 +1,18 @@
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestAuditReport(TransactionCase):
def test_report_renders(self):
wizard = self.env['fusion.migration.wizard'].create({})
try:
pdf, content_type = self.env['ir.actions.report'].sudo()._render_qweb_pdf(
'fusion_accounting_assets.migration_audit_template',
res_ids=[wizard.id], data={},
)
# PDF or HTML both ok (wkhtmltopdf might be missing on dev VM)
self.assertGreater(len(pdf), 100)
except Exception as e:
self.skipTest(f"PDF render failed (likely wkhtmltopdf missing): {e}")

View File

@@ -0,0 +1,38 @@
"""Coexistence tests: fusion_accounting_assets menu only visible when
Enterprise account_asset is NOT installed."""
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestAssetsCoexistence(TransactionCase):
def setUp(self):
super().setUp()
self.coex_group = self.env.ref(
'fusion_accounting_core.group_fusion_show_when_enterprise_absent',
raise_if_not_found=False,
)
self.assertIsNotNone(self.coex_group, "Coexistence group must exist")
def test_engine_always_available(self):
"""Engine is registered regardless of Enterprise install state."""
self.assertIn('fusion.asset.engine', self.env.registry)
def test_menu_gated_by_coexistence_group(self):
menu = self.env.ref('fusion_accounting_assets.menu_fusion_assets_root',
raise_if_not_found=False)
if not menu:
self.skipTest("Menu not loaded")
menu_groups = getattr(menu, 'group_ids', None) or menu.groups_id
self.assertIn(self.coex_group, menu_groups,
"Asset root menu must require the coexistence group")
def test_categories_menu_gated(self):
menu = self.env.ref('fusion_accounting_assets.menu_fusion_asset_categories',
raise_if_not_found=False)
if not menu:
self.skipTest("Menu not loaded")
menu_groups = getattr(menu, 'group_ids', None) or menu.groups_id
self.assertIn(self.coex_group, menu_groups)

View File

@@ -0,0 +1,62 @@
from datetime import date
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
@tagged('post_install', '-at_install')
class TestCreateAssetWizard(TransactionCase):
def setUp(self):
super().setUp()
self.env['ir.config_parameter'].sudo().search([
('key', 'in', ['fusion_accounting.provider.asset_useful_life',
'fusion_accounting.provider.default'])
]).unlink()
def test_create_minimal_asset(self):
wizard = self.env['fusion.create.asset.wizard'].create({
'name': 'Test Asset',
'cost': 5000,
'method': 'straight_line',
'useful_life_years': 5,
'acquisition_date': date(2026, 1, 1),
'source_invoice_line_id': False,
})
action = wizard.action_create_asset()
self.assertEqual(action['res_model'], 'fusion.asset')
asset = self.env['fusion.asset'].browse(action['res_id'])
self.assertEqual(asset.name, 'Test Asset')
self.assertEqual(asset.cost, 5000)
def test_ai_suggest_fills_fields(self):
wizard = self.env['fusion.create.asset.wizard'].create({
'name': 'Dell laptop',
'cost': 2000,
'method': 'straight_line',
'useful_life_years': 5,
'acquisition_date': date(2026, 1, 1),
})
wizard.action_ai_suggest()
self.assertEqual(wizard.ai_suggested_years, 4)
self.assertEqual(wizard.useful_life_years, 4)
def test_category_onchange_pre_fills(self):
category = self.env['fusion.asset.category'].create({
'name': 'Test Category',
'method': 'declining_balance',
'useful_life_years': 7,
'declining_rate_pct': 25.0,
'salvage_value_pct': 10.0,
})
wizard = self.env['fusion.create.asset.wizard'].new({
'name': 'Test', 'cost': 10000,
'method': 'straight_line', 'useful_life_years': 5,
'acquisition_date': date(2026, 1, 1),
'category_id': category.id,
})
wizard._onchange_category_id()
self.assertEqual(wizard.method, 'declining_balance')
self.assertEqual(wizard.useful_life_years, 7)
self.assertEqual(wizard.declining_rate_pct, 25.0)
self.assertAlmostEqual(wizard.salvage_value, 1000, places=2)

View File

@@ -0,0 +1,43 @@
from datetime import date
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
@tagged('post_install', '-at_install')
class TestDepreciationRunWizard(TransactionCase):
def test_run_all_running_posts_due_periods(self):
for amt in [3000, 5000]:
asset = self.env['fusion.asset'].create({
'name': f'Run Test {amt}', 'cost': amt,
'acquisition_date': date(2026, 1, 1),
'in_service_date': date(2026, 1, 1),
'method': 'straight_line', 'useful_life_years': 3,
})
self.env['fusion.asset.engine'].compute_depreciation_schedule(asset)
asset.action_set_running()
wizard = self.env['fusion.depreciation.run.wizard'].create({
'period_date': date(2030, 12, 31),
'state_filter': 'all_running',
})
wizard.action_run()
self.assertEqual(wizard.state, 'done')
self.assertGreater(wizard.posted_count, 0)
def test_run_selected_posts_only_selected(self):
asset = self.env['fusion.asset'].create({
'name': 'Selected Test', 'cost': 1000,
'acquisition_date': date(2026, 1, 1),
'in_service_date': date(2026, 1, 1),
'method': 'straight_line', 'useful_life_years': 3,
})
self.env['fusion.asset.engine'].compute_depreciation_schedule(asset)
asset.action_set_running()
wizard = self.env['fusion.depreciation.run.wizard'].create({
'period_date': date(2030, 12, 31),
'state_filter': 'selected',
'asset_ids': [(6, 0, [asset.id])],
})
wizard.action_run()
self.assertEqual(wizard.state, 'done')

View File

@@ -0,0 +1,50 @@
from datetime import date
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
@tagged('post_install', '-at_install')
class TestDisposalWizard(TransactionCase):
def setUp(self):
super().setUp()
self.asset = self.env['fusion.asset'].create({
'name': 'Disposal Test Asset',
'cost': 6000,
'acquisition_date': date(2026, 1, 1),
'in_service_date': date(2026, 1, 1),
'method': 'straight_line', 'useful_life_years': 3,
})
self.env['fusion.asset.engine'].compute_depreciation_schedule(self.asset)
self.asset.action_set_running()
def test_default_loads_active_asset(self):
wizard = self.env['fusion.disposal.wizard'].with_context(
active_model='fusion.asset', active_id=self.asset.id,
).create({})
self.assertEqual(wizard.asset_id, self.asset)
def test_action_dispose_marks_asset_disposed(self):
wizard = self.env['fusion.disposal.wizard'].create({
'asset_id': self.asset.id,
'disposal_type': 'sale',
'sale_amount': 4000,
'disposal_date': date(2026, 6, 1),
})
wizard.action_dispose()
self.asset.invalidate_recordset(['state'])
self.assertEqual(self.asset.state, 'disposed')
def test_compute_gain_loss_sale(self):
wizard = self.env['fusion.disposal.wizard'].create({
'asset_id': self.asset.id,
'disposal_type': 'sale',
'sale_amount': 7000,
})
wizard._compute_gain_loss()
self.assertAlmostEqual(
wizard.estimated_gain_loss,
7000 - self.asset.book_value,
places=2,
)

View File

@@ -0,0 +1,101 @@
"""Property-based invariant tests for the asset engine.
Hypothesis generates random inputs; we assert mathematical invariants
that must hold regardless of input."""
from hypothesis import given, settings, strategies as st, HealthCheck
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
from odoo.addons.fusion_accounting_assets.services.depreciation_methods import (
straight_line, declining_balance, units_of_production,
)
@tagged('post_install', '-at_install', 'property_based')
class TestDepreciationInvariants(TransactionCase):
@given(
cost=st.floats(min_value=100.0, max_value=1000000.0,
allow_nan=False, allow_infinity=False),
salvage_pct=st.floats(min_value=0.0, max_value=0.5,
allow_nan=False, allow_infinity=False),
n_periods=st.integers(min_value=1, max_value=40),
)
@settings(max_examples=80, deadline=2000,
suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_straight_line_total_equals_cost_minus_salvage(self, cost, salvage_pct, n_periods):
cost = round(cost, 2)
salvage = round(cost * salvage_pct, 2)
steps = straight_line(cost=cost, salvage_value=salvage, n_periods=n_periods)
total = sum(s.period_amount for s in steps)
# Within 1c rounding tolerance
self.assertAlmostEqual(
total, cost - salvage, places=1,
msg=f"cost={cost}, salvage={salvage}, n={n_periods}, total={total:.2f}",
)
@given(
cost=st.floats(min_value=100.0, max_value=1000000.0,
allow_nan=False, allow_infinity=False),
salvage_pct=st.floats(min_value=0.0, max_value=0.5,
allow_nan=False, allow_infinity=False),
n_periods=st.integers(min_value=1, max_value=20),
)
@settings(max_examples=50, deadline=2000,
suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_straight_line_book_value_decreasing(self, cost, salvage_pct, n_periods):
cost = round(cost, 2)
salvage = round(cost * salvage_pct, 2)
steps = straight_line(cost=cost, salvage_value=salvage, n_periods=n_periods)
for i in range(1, len(steps)):
self.assertLessEqual(
steps[i].book_value_at_end,
steps[i - 1].book_value_at_end + 0.01,
)
@given(
cost=st.floats(min_value=1000.0, max_value=100000.0,
allow_nan=False, allow_infinity=False),
salvage_pct=st.floats(min_value=0.0, max_value=0.3,
allow_nan=False, allow_infinity=False),
n_periods=st.integers(min_value=2, max_value=20),
rate=st.floats(min_value=0.05, max_value=0.5,
allow_nan=False, allow_infinity=False),
)
@settings(max_examples=50, deadline=3000,
suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_declining_balance_never_below_salvage(self, cost, salvage_pct, n_periods, rate):
cost = round(cost, 2)
salvage = round(cost * salvage_pct, 2)
steps = declining_balance(
cost=cost, salvage_value=salvage,
n_periods=n_periods, rate=rate,
)
for s in steps:
self.assertGreaterEqual(
s.book_value_at_end, salvage - 0.01,
msg=f"cost={cost}, salvage={salvage}, rate={rate}, step={s}",
)
@given(
cost=st.floats(min_value=1000.0, max_value=100000.0,
allow_nan=False, allow_infinity=False),
total_units=st.floats(min_value=100.0, max_value=10000.0,
allow_nan=False, allow_infinity=False),
n_periods=st.integers(min_value=1, max_value=10),
)
@settings(max_examples=30, deadline=2000,
suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_units_of_production_total_at_full_use_equals_depreciable(self, cost, total_units, n_periods):
cost = round(cost, 2)
salvage = 0.0
# Distribute total_units evenly across periods
per_period = total_units / n_periods
steps = units_of_production(
cost=cost, salvage_value=salvage,
total_units_expected=total_units,
units_per_period=[per_period] * n_periods,
)
total = sum(s.period_amount for s in steps)
self.assertAlmostEqual(total, cost - salvage, places=1)

View File

@@ -0,0 +1,83 @@
"""Local LLM compat smoke test for the useful_life_predictor service.
Auto-detects an LM Studio (port 1234) or Ollama (port 11434) server on
host.docker.internal or localhost. Skips silently when no local LLM is
reachable, so CI runs stay green.
When a server is present, this exercises the real OpenAI-compatible
adapter end-to-end against a local model — i.e. it catches prompt /
JSON-parsing regressions that only show up with a non-mocked LLM.
"""
import socket
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
def _server_reachable(host, port, timeout=1.0):
try:
with socket.create_connection((host, port), timeout=timeout):
return True
except (OSError, socket.timeout):
return False
def _detect_local_llm():
candidates = [
('host.docker.internal', 1234, 'local-model'),
('host.docker.internal', 11434, 'llama3.1:8b'),
('localhost', 1234, 'local-model'),
('localhost', 11434, 'llama3.1:8b'),
]
for host, port, default_model in candidates:
if _server_reachable(host, port, timeout=0.5):
return (f'http://{host}:{port}/v1', default_model)
return (None, None)
@tagged('post_install', '-at_install', 'local_llm')
class TestLocalLLMUsefulLife(TransactionCase):
def setUp(self):
super().setUp()
self.base_url, self.model = _detect_local_llm()
if not self.base_url:
self.skipTest("No local LLM server detected (LM Studio :1234 / Ollama :11434)")
def test_useful_life_with_local_llm(self):
params = self.env['ir.config_parameter'].sudo()
keys = [
'fusion_accounting.openai_base_url',
'fusion_accounting.openai_model',
'fusion_accounting.openai_api_key',
'fusion_accounting.provider.asset_useful_life',
]
prior = {k: params.get_param(k) for k in keys}
params.set_param('fusion_accounting.openai_base_url', self.base_url)
params.set_param('fusion_accounting.openai_model', self.model)
params.set_param('fusion_accounting.openai_api_key', 'lm-studio')
params.set_param('fusion_accounting.provider.asset_useful_life', 'openai')
try:
from odoo.addons.fusion_accounting_assets.services.useful_life_predictor import (
predict_useful_life,
)
result = predict_useful_life(
self.env,
description='Dell laptop',
amount=2500,
partner_name='Dell Canada',
)
self.assertIn('useful_life_years', result)
self.assertIn('depreciation_method', result)
self.assertIsInstance(result['useful_life_years'], (int, float))
self.assertIn(
result['depreciation_method'],
('straight_line', 'declining_balance', 'units_of_production'),
)
finally:
for k, v in prior.items():
if v is not None:
params.set_param(k, v)

View File

@@ -0,0 +1,112 @@
"""Integration tests verifying all 3 depreciation methods through the engine."""
from datetime import date
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
@tagged('post_install', '-at_install', 'integration')
class TestStraightLineIntegration(TransactionCase):
def setUp(self):
super().setUp()
self.engine = self.env['fusion.asset.engine']
def test_straight_line_5yr_no_salvage(self):
asset = self.env['fusion.asset'].create({
'name': 'SL Test', 'cost': 10000, 'salvage_value': 0,
'acquisition_date': date(2026, 1, 1),
'in_service_date': date(2026, 1, 1),
'method': 'straight_line', 'useful_life_years': 5,
})
self.engine.compute_depreciation_schedule(asset)
lines = asset.depreciation_line_ids.sorted('period_index')
self.assertEqual(len(lines), 5)
for line in lines:
self.assertAlmostEqual(line.amount, 2000, places=2)
def test_straight_line_10yr_with_salvage(self):
asset = self.env['fusion.asset'].create({
'name': 'SL10', 'cost': 50000, 'salvage_value': 5000,
'acquisition_date': date(2026, 1, 1),
'in_service_date': date(2026, 1, 1),
'method': 'straight_line', 'useful_life_years': 10,
})
self.engine.compute_depreciation_schedule(asset)
lines = asset.depreciation_line_ids.sorted('period_index')
self.assertEqual(len(lines), 10)
# Each year = (50000-5000)/10 = 4500; total depreciable = 45000
self.assertAlmostEqual(sum(lines.mapped('amount')), 45000, places=2)
def test_straight_line_book_value_at_end_equals_salvage(self):
asset = self.env['fusion.asset'].create({
'name': 'SL', 'cost': 10000, 'salvage_value': 1000,
'acquisition_date': date(2026, 1, 1),
'in_service_date': date(2026, 1, 1),
'method': 'straight_line', 'useful_life_years': 5,
})
self.engine.compute_depreciation_schedule(asset)
last = asset.depreciation_line_ids.sorted('period_index')[-1]
self.assertAlmostEqual(last.book_value_at_end, 1000, places=2)
@tagged('post_install', '-at_install', 'integration')
class TestDecliningBalanceIntegration(TransactionCase):
def setUp(self):
super().setUp()
self.engine = self.env['fusion.asset.engine']
def test_declining_balance_30pct(self):
asset = self.env['fusion.asset'].create({
'name': 'DB', 'cost': 10000, 'salvage_value': 1000,
'acquisition_date': date(2026, 1, 1),
'in_service_date': date(2026, 1, 1),
'method': 'declining_balance', 'useful_life_years': 5,
'declining_rate_pct': 30.0,
})
self.engine.compute_depreciation_schedule(asset)
lines = asset.depreciation_line_ids.sorted('period_index')
# First period: 10000 * 0.30 = 3000
self.assertAlmostEqual(lines[0].amount, 3000, places=2)
# Should not exceed salvage at end
self.assertGreaterEqual(lines[-1].book_value_at_end, 999.99)
def test_declining_balance_50pct_high_rate(self):
asset = self.env['fusion.asset'].create({
'name': 'DB50', 'cost': 8000, 'salvage_value': 500,
'acquisition_date': date(2026, 1, 1),
'in_service_date': date(2026, 1, 1),
'method': 'declining_balance', 'useful_life_years': 5,
'declining_rate_pct': 50.0,
})
self.engine.compute_depreciation_schedule(asset)
# First period: 8000 * 0.50 = 4000
first = asset.depreciation_line_ids.sorted('period_index')[0]
self.assertAlmostEqual(first.amount, 4000, places=2)
@tagged('post_install', '-at_install', 'integration')
class TestUnitsOfProductionIntegration(TransactionCase):
def setUp(self):
super().setUp()
self.engine = self.env['fusion.asset.engine']
def test_units_of_production_5yr_even_distribution(self):
asset = self.env['fusion.asset'].create({
'name': 'UOP', 'cost': 50000, 'salvage_value': 0,
'acquisition_date': date(2026, 1, 1),
'in_service_date': date(2026, 1, 1),
'method': 'units_of_production',
'total_units_expected': 100000,
'useful_life_years': 5,
})
self.engine.compute_depreciation_schedule(asset)
lines = asset.depreciation_line_ids.sorted('period_index')
# 5 periods, even distribution = 20000 units/period
# Each period: (20000/100000) * 50000 = 10000
self.assertEqual(len(lines), 5)
for line in lines:
self.assertAlmostEqual(line.amount, 10000, places=2)

View File

@@ -0,0 +1,24 @@
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestAssetsMigrationRoundTrip(TransactionCase):
def test_bootstrap_step_runs_without_enterprise(self):
"""When Enterprise account.asset is NOT installed, step is a no-op."""
wizard = self.env['fusion.migration.wizard'].create({})
result = wizard._assets_bootstrap_step()
self.assertEqual(result['step'], 'assets_bootstrap')
# In our local DB, Enterprise account.asset may or may not exist
# If absent: enterprise_module_present is False
# If present: created>=0
self.assertIn(result['enterprise_module_present'], [True, False])
def test_bootstrap_idempotent_on_re_run(self):
wizard = self.env['fusion.migration.wizard'].create({})
first = wizard._assets_bootstrap_step()
second = wizard._assets_bootstrap_step()
# Second run should skip what the first created (or both no-op)
if first['enterprise_module_present']:
self.assertGreaterEqual(second['skipped'], first['created'])

View File

@@ -0,0 +1,48 @@
from datetime import date
from odoo.exceptions import UserError
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
@tagged('post_install', '-at_install')
class TestPartialSaleWizard(TransactionCase):
def setUp(self):
super().setUp()
self.asset = self.env['fusion.asset'].create({
'name': 'Partial Sale Test',
'cost': 10000,
'acquisition_date': date(2026, 1, 1),
'in_service_date': date(2026, 1, 1),
'method': 'straight_line', 'useful_life_years': 5,
})
self.env['fusion.asset.engine'].compute_depreciation_schedule(self.asset)
self.asset.action_set_running()
def test_partial_sell_30pct_creates_child(self):
wizard = self.env['fusion.partial.sale.wizard'].create({
'asset_id': self.asset.id,
'sold_pct': 30.0, 'sold_amount': 4000,
'sale_date': date(2026, 6, 1),
})
wizard.action_partial_sell()
self.asset.invalidate_recordset(['cost'])
self.assertAlmostEqual(self.asset.cost, 7000, places=2)
def test_invalid_pct_raises(self):
wizard = self.env['fusion.partial.sale.wizard'].create({
'asset_id': self.asset.id,
'sold_pct': 0, 'sold_amount': 100,
})
with self.assertRaises(UserError):
wizard.action_partial_sell()
def test_compute_estimated_gain_loss(self):
wizard = self.env['fusion.partial.sale.wizard'].new({
'asset_id': self.asset.id,
'sold_pct': 30.0, 'sold_amount': 4000,
})
wizard._compute_sold_cost()
self.assertAlmostEqual(wizard.estimated_sold_cost, 3000, places=2)
self.assertAlmostEqual(wizard.estimated_gain_loss, 1000, places=2)

View File

@@ -0,0 +1,58 @@
"""Controller perf benchmarks tagged 'benchmark'.
Engine-level benchmarks live in test_performance_benchmarks.py (Task 23).
This file targets the JSON-RPC controller surface end-to-end (HTTP request
→ Odoo dispatch → engine → response). It complements Task 23 by catching
regressions introduced by controller / serialization layers, not just the
underlying engine.
"""
import json
import statistics
import time
from datetime import date
from odoo.tests.common import HttpCase, new_test_user
from odoo.tests import tagged
@tagged('post_install', '-at_install', 'benchmark')
class TestAssetsControllerBenchmarks(HttpCase):
def setUp(self):
super().setUp()
for i in range(15):
self.env['fusion.asset'].create({
'name': f'BenchAsset{i}',
'cost': 1000 + i * 100,
'acquisition_date': date(2026, 1, 1),
'method': 'straight_line',
'useful_life_years': 5,
})
def test_get_detail_endpoint_p95(self):
new_test_user(
self.env, login='asset_perf_ctrl',
groups='base.group_user,account.group_account_invoice',
)
asset = self.env['fusion.asset'].search([], limit=1)
self.authenticate('asset_perf_ctrl', 'asset_perf_ctrl')
timings = []
for _ in range(5):
start = time.perf_counter()
response = self.url_open(
'/fusion/assets/get_detail',
data=json.dumps({
'jsonrpc': '2.0', 'method': 'call', 'id': 1,
'params': {'asset_id': asset.id},
}),
headers={'Content-Type': 'application/json'},
)
timings.append((time.perf_counter() - start) * 1000)
self.assertEqual(response.status_code, 200)
sorted_t = sorted(timings)
p95 = sorted_t[min(int(len(sorted_t) * 0.95), len(sorted_t) - 1)]
median = statistics.median(timings)
msg = f"controller.get_detail: median={median:.0f}ms p95={p95:.0f}ms"
print(f"\n PERF: {msg} (target <500ms)")
self.assertLess(p95, 5000)

View File

@@ -0,0 +1,117 @@
"""Performance benchmarks tagged 'benchmark'."""
import json
import statistics
import time
from datetime import date
from odoo.tests.common import HttpCase, TransactionCase, new_test_user
from odoo.tests import tagged
@tagged('post_install', '-at_install', 'benchmark')
class TestEngineBenchmarks(TransactionCase):
def setUp(self):
super().setUp()
self.engine = self.env['fusion.asset.engine']
def _percentile(self, samples, p):
if len(samples) <= 1:
return samples[0] if samples else 0
sorted_s = sorted(samples)
idx = int(len(sorted_s) * p / 100)
return sorted_s[min(idx, len(sorted_s) - 1)]
def test_compute_schedule_p95(self):
timings = []
for i in range(10):
asset = self.env['fusion.asset'].create({
'name': f'PerfAsset{i}', 'cost': 100000, 'salvage_value': 5000,
'acquisition_date': date(2026, 1, 1),
'method': 'straight_line', 'useful_life_years': 10,
})
start = time.perf_counter()
self.engine.compute_depreciation_schedule(asset)
timings.append((time.perf_counter() - start) * 1000)
p95 = self._percentile(timings, 95)
median = statistics.median(timings)
msg = f"compute_schedule(10yr): median={median:.0f}ms p95={p95:.0f}ms"
print(f"\n PERF: {msg} (target <500ms)")
self.assertLess(p95, 5000, f"way over budget: {msg}")
def test_post_depreciation_p95(self):
asset = self.env['fusion.asset'].create({
'name': 'PostPerf', 'cost': 50000,
'acquisition_date': date(2026, 1, 1),
'in_service_date': date(2026, 1, 1),
'method': 'straight_line', 'useful_life_years': 10,
})
self.engine.compute_depreciation_schedule(asset)
asset.action_set_running()
timings = []
for _ in range(5):
start = time.perf_counter()
self.engine.post_depreciation_entry(asset)
timings.append((time.perf_counter() - start) * 1000)
p95 = self._percentile(timings, 95)
median = statistics.median(timings)
msg = f"post_depreciation: median={median:.0f}ms p95={p95:.0f}ms"
print(f"\n PERF: {msg} (target <300ms)")
self.assertLess(p95, 3000)
def test_dispose_asset_p95(self):
timings = []
for i in range(5):
asset = self.env['fusion.asset'].create({
'name': f'DispPerf{i}', 'cost': 10000,
'acquisition_date': date(2026, 1, 1),
'in_service_date': date(2026, 1, 1),
'method': 'straight_line', 'useful_life_years': 5,
})
self.engine.compute_depreciation_schedule(asset)
asset.action_set_running()
start = time.perf_counter()
self.engine.dispose_asset(asset, sale_amount=5000)
timings.append((time.perf_counter() - start) * 1000)
p95 = self._percentile(timings, 95)
median = statistics.median(timings)
msg = f"dispose_asset: median={median:.0f}ms p95={p95:.0f}ms"
print(f"\n PERF: {msg} (target <300ms)")
self.assertLess(p95, 3000)
@tagged('post_install', '-at_install', 'benchmark')
class TestControllerBenchmarks(HttpCase):
def test_list_endpoint_p95(self):
new_test_user(
self.env, login='asset_perf',
groups='base.group_user,account.group_account_invoice',
)
for i in range(20):
self.env['fusion.asset'].create({
'name': f'ListPerf{i}', 'cost': 1000,
'acquisition_date': date(2026, 1, 1),
'method': 'straight_line', 'useful_life_years': 4,
})
self.authenticate('asset_perf', 'asset_perf')
timings = []
for _ in range(5):
start = time.perf_counter()
response = self.url_open(
'/fusion/assets/list',
data=json.dumps({
'jsonrpc': '2.0', 'method': 'call', 'id': 1,
'params': {'company_id': self.env.company.id},
}),
headers={'Content-Type': 'application/json'},
)
timings.append((time.perf_counter() - start) * 1000)
self.assertEqual(response.status_code, 200)
sorted_t = sorted(timings)
p95 = sorted_t[min(int(len(sorted_t) * 0.95), len(sorted_t) - 1)]
median = statistics.median(timings)
msg = f"controller.list: median={median:.0f}ms p95={p95:.0f}ms"
print(f"\n PERF: {msg} (target <300ms)")
self.assertLess(p95, 3000)

View File

@@ -0,0 +1,68 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Top-level menu (visible only when account_asset Enterprise NOT installed) -->
<menuitem id="menu_fusion_assets_root"
name="Asset Management"
sequence="60"
web_icon="fusion_accounting_assets,static/description/icon.png"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<!-- Asset list/form -->
<record id="action_fusion_asset_list" model="ir.actions.act_window">
<field name="name">Assets</field>
<field name="res_model">fusion.asset</field>
<field name="view_mode">list,form</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Manage your fixed assets
</p>
<p>
Track depreciation, post periodic entries, dispose assets at end-of-life.
AI augmentation: anomaly detection + suggested useful life.
</p>
</field>
</record>
<menuitem id="menu_fusion_assets_list"
name="Assets"
parent="menu_fusion_assets_root"
action="action_fusion_asset_list"
sequence="10"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<!-- Categories -->
<record id="action_fusion_asset_category_list" model="ir.actions.act_window">
<field name="name">Asset Categories</field>
<field name="res_model">fusion.asset.category</field>
<field name="view_mode">list,form</field>
</record>
<menuitem id="menu_fusion_asset_categories"
name="Categories"
parent="menu_fusion_assets_root"
action="action_fusion_asset_category_list"
sequence="20"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<!-- Anomalies -->
<record id="action_fusion_asset_anomaly_list" model="ir.actions.act_window">
<field name="name">Asset Anomalies</field>
<field name="res_model">fusion.asset.anomaly</field>
<field name="view_mode">list,form</field>
</record>
<menuitem id="menu_fusion_asset_anomalies"
name="Anomalies"
parent="menu_fusion_assets_root"
action="action_fusion_asset_anomaly_list"
sequence="30"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<!-- Run depreciation -->
<menuitem id="menu_fusion_assets_run_depreciation"
name="Run Depreciation..."
parent="menu_fusion_assets_root"
action="action_fusion_depreciation_run_wizard"
sequence="40"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
</odoo>

View File

@@ -0,0 +1,4 @@
from . import create_asset_wizard
from . import disposal_wizard
from . import partial_sale_wizard
from . import depreciation_run_wizard

View File

@@ -0,0 +1,133 @@
"""Create-asset-from-invoice-line wizard.
Reads an account.move.line as the source, pre-fills name/cost/category,
and optionally calls the AI useful-life predictor for suggestions."""
from odoo import _, api, fields, models
from odoo.exceptions import UserError
from ..services.useful_life_predictor import predict_useful_life
class FusionCreateAssetWizard(models.TransientModel):
_name = "fusion.create.asset.wizard"
_description = "Create Fusion Asset from Invoice Line"
source_invoice_line_id = fields.Many2one(
'account.move.line', string='Source Invoice Line',
default=lambda self: self._default_source_line(),
)
name = fields.Char(required=True)
cost = fields.Monetary(required=True)
salvage_value = fields.Monetary(default=0.0)
currency_id = fields.Many2one(
'res.currency', required=True,
default=lambda self: self.env.company.currency_id,
)
category_id = fields.Many2one('fusion.asset.category')
method = fields.Selection([
('straight_line', 'Straight Line'),
('declining_balance', 'Declining Balance'),
('units_of_production', 'Units of Production'),
], required=True, default='straight_line')
useful_life_years = fields.Integer(default=5)
declining_rate_pct = fields.Float(default=20.0)
acquisition_date = fields.Date(required=True, default=fields.Date.today)
in_service_date = fields.Date(default=fields.Date.today)
ai_suggested_years = fields.Integer(readonly=True)
ai_suggested_method = fields.Char(readonly=True)
ai_rationale = fields.Text(readonly=True)
ai_confidence = fields.Float(readonly=True)
@api.model
def _default_source_line(self):
ctx = self.env.context
if ctx.get('active_model') == 'account.move.line':
return ctx.get('active_id')
return False
@api.onchange('source_invoice_line_id')
def _onchange_source_invoice_line_id(self):
if not self.source_invoice_line_id:
return
line = self.source_invoice_line_id
if not self.name:
self.name = line.name or line.move_id.name or 'New Asset'
if not self.cost:
self.cost = abs(line.balance) if line.balance else (line.price_unit * line.quantity)
if line.currency_id and not self.currency_id:
self.currency_id = line.currency_id
@api.onchange('category_id')
def _onchange_category_id(self):
if self.category_id:
self.method = self.category_id.method
self.useful_life_years = self.category_id.useful_life_years
self.declining_rate_pct = self.category_id.declining_rate_pct
if self.category_id.salvage_value_pct and self.cost:
self.salvage_value = round(
self.cost * self.category_id.salvage_value_pct / 100, 2)
def action_ai_suggest(self):
"""Call AI useful-life predictor."""
self.ensure_one()
if not self.name and not self.source_invoice_line_id:
raise UserError(_("Need a name or source invoice line first."))
description = self.name
if self.source_invoice_line_id and self.source_invoice_line_id.name:
description = self.source_invoice_line_id.name
partner_name = None
if self.source_invoice_line_id and self.source_invoice_line_id.partner_id:
partner_name = self.source_invoice_line_id.partner_id.name
suggestion = predict_useful_life(
self.env, description=description,
amount=self.cost, partner_name=partner_name,
)
self.write({
'ai_suggested_years': suggestion.get('useful_life_years'),
'ai_suggested_method': suggestion.get('depreciation_method'),
'ai_rationale': suggestion.get('rationale'),
'ai_confidence': suggestion.get('confidence'),
'useful_life_years': suggestion.get('useful_life_years'),
'method': suggestion.get('depreciation_method'),
})
return {
'type': 'ir.actions.act_window',
'res_model': self._name,
'res_id': self.id,
'view_mode': 'form',
'target': 'new',
}
def action_create_asset(self):
"""Create the fusion.asset record + link to source invoice line."""
self.ensure_one()
if not self.cost:
raise UserError(_("Cost is required."))
Asset = self.env['fusion.asset']
asset = Asset.create({
'name': self.name,
'cost': self.cost,
'salvage_value': self.salvage_value,
'currency_id': self.currency_id.id,
'category_id': self.category_id.id if self.category_id else False,
'method': self.method,
'useful_life_years': self.useful_life_years,
'declining_rate_pct': self.declining_rate_pct,
'acquisition_date': self.acquisition_date,
'in_service_date': self.in_service_date,
'source_invoice_line_id': self.source_invoice_line_id.id if self.source_invoice_line_id else False,
'company_id': self.env.company.id,
})
if self.source_invoice_line_id:
self.source_invoice_line_id.fusion_asset_id = asset.id
return {
'type': 'ir.actions.act_window',
'res_model': 'fusion.asset',
'res_id': asset.id,
'view_mode': 'form',
'target': 'current',
}

View File

@@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_fusion_create_asset_wizard_form" model="ir.ui.view">
<field name="name">fusion.create.asset.wizard.form</field>
<field name="model">fusion.create.asset.wizard</field>
<field name="arch" type="xml">
<form string="Create Fixed Asset">
<group>
<group string="Basics">
<field name="name"/>
<field name="source_invoice_line_id"
options="{'no_create': True}"
readonly="source_invoice_line_id"/>
<field name="cost"/>
<field name="currency_id" invisible="1"/>
<field name="salvage_value"/>
<field name="category_id" options="{'no_create': True}"/>
</group>
<group string="Depreciation">
<field name="method"/>
<field name="useful_life_years"
invisible="method == 'units_of_production'"/>
<field name="declining_rate_pct"
invisible="method != 'declining_balance'"/>
<field name="acquisition_date"/>
<field name="in_service_date"/>
</group>
</group>
<group string="AI Suggestion" invisible="not ai_suggested_years">
<field name="ai_suggested_years" readonly="1"/>
<field name="ai_suggested_method" readonly="1"/>
<field name="ai_rationale" readonly="1"/>
<field name="ai_confidence" readonly="1"/>
</group>
<footer>
<button name="action_ai_suggest" type="object"
string="AI Suggest" class="btn-secondary"/>
<button name="action_create_asset" type="object"
string="Create Asset" class="btn-primary"/>
<button special="cancel" string="Cancel"/>
</footer>
</form>
</field>
</record>
<record id="action_fusion_create_asset_wizard" model="ir.actions.act_window">
<field name="name">Create Asset from Invoice</field>
<field name="res_model">fusion.create.asset.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="binding_model_id" ref="account.model_account_move_line"/>
<field name="binding_view_types">list</field>
</record>
</odoo>

View File

@@ -0,0 +1,72 @@
"""Manual depreciation run wizard.
Operator picks a period_date and the wizard posts all running assets'
unposted lines whose scheduled_date <= period_date."""
from odoo import fields, models
class FusionDepreciationRunWizard(models.TransientModel):
_name = "fusion.depreciation.run.wizard"
_description = "Manual Depreciation Run Wizard"
period_date = fields.Date(
required=True, default=fields.Date.today,
help="Post all unposted lines whose scheduled_date is on or before this date.",
)
state_filter = fields.Selection([
('all_running', 'All Running Assets'),
('selected', 'Selected Asset(s) Only'),
], default='all_running', required=True)
asset_ids = fields.Many2many(
'fusion.asset', domain=[('state', '=', 'running')],
)
state = fields.Selection(
[('draft', 'Draft'), ('done', 'Done')], default='draft',
)
posted_count = fields.Integer(readonly=True)
skipped_count = fields.Integer(readonly=True)
error_count = fields.Integer(readonly=True)
summary = fields.Text(readonly=True)
def action_run(self):
self.ensure_one()
if self.state_filter == 'all_running':
assets = self.env['fusion.asset'].search([
('state', '=', 'running'),
('company_id', '=', self.env.company.id),
])
else:
assets = self.asset_ids
engine = self.env['fusion.asset.engine']
posted = 0
skipped = 0
errors = []
for asset in assets:
try:
with self.env.cr.savepoint():
result = engine.post_depreciation_entry(
asset, period_date=self.period_date,
)
posted += result.get('posted_count', 0)
if result.get('posted_count', 0) == 0:
skipped += 1
except Exception as e: # noqa: BLE001
errors.append(f"{asset.name}: {e}")
self.write({
'state': 'done',
'posted_count': posted,
'skipped_count': skipped,
'error_count': len(errors),
'summary': '\n'.join(errors[:20]) if errors else False,
})
return {
'type': 'ir.actions.act_window',
'res_model': self._name,
'res_id': self.id,
'view_mode': 'form',
'target': 'new',
}

View File

@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_fusion_depreciation_run_wizard_form" model="ir.ui.view">
<field name="name">fusion.depreciation.run.wizard.form</field>
<field name="model">fusion.depreciation.run.wizard</field>
<field name="arch" type="xml">
<form string="Run Depreciation">
<group invisible="state == 'done'">
<field name="period_date"/>
<field name="state_filter" widget="radio"/>
<field name="asset_ids" widget="many2many_tags"
invisible="state_filter != 'selected'"/>
</group>
<group invisible="state != 'done'" string="Results">
<field name="posted_count"/>
<field name="skipped_count"/>
<field name="error_count"/>
<field name="summary"/>
</group>
<field name="state" invisible="1"/>
<footer>
<button name="action_run" type="object" string="Run"
class="btn-primary" invisible="state == 'done'"/>
<button special="cancel" string="Close"/>
</footer>
</form>
</field>
</record>
<record id="action_fusion_depreciation_run_wizard" model="ir.actions.act_window">
<field name="name">Run Depreciation</field>
<field name="res_model">fusion.depreciation.run.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>

View File

@@ -0,0 +1,65 @@
"""Asset disposal wizard (sale, scrap, donation, lost)."""
from odoo import _, api, fields, models
from odoo.exceptions import UserError
class FusionDisposalWizard(models.TransientModel):
_name = "fusion.disposal.wizard"
_description = "Asset Disposal Wizard"
asset_id = fields.Many2one(
'fusion.asset', required=True,
default=lambda self: self._default_asset(),
)
company_id = fields.Many2one(related='asset_id.company_id')
currency_id = fields.Many2one(related='asset_id.currency_id')
book_value = fields.Monetary(related='asset_id.book_value', readonly=True)
disposal_type = fields.Selection([
('sale', 'Sale'),
('scrap', 'Scrap'),
('donation', 'Donation'),
('lost', 'Lost / Stolen'),
], required=True, default='sale')
disposal_date = fields.Date(required=True, default=fields.Date.today)
sale_amount = fields.Monetary(default=0.0)
sale_partner_id = fields.Many2one('res.partner')
notes = fields.Text()
estimated_gain_loss = fields.Monetary(compute='_compute_gain_loss')
@api.model
def _default_asset(self):
ctx = self.env.context
if ctx.get('active_model') == 'fusion.asset':
return ctx.get('active_id')
return False
@api.depends('sale_amount', 'book_value', 'disposal_type')
def _compute_gain_loss(self):
for w in self:
if w.disposal_type == 'sale':
w.estimated_gain_loss = w.sale_amount - w.book_value
else:
w.estimated_gain_loss = -w.book_value
def action_dispose(self):
self.ensure_one()
if self.asset_id.state == 'disposed':
raise UserError(_("Asset already disposed."))
partner = self.sale_partner_id if self.disposal_type == 'sale' else None
self.env['fusion.asset.engine'].dispose_asset(
self.asset_id,
sale_amount=self.sale_amount if self.disposal_type == 'sale' else 0,
sale_date=self.disposal_date,
sale_partner=partner,
disposal_type=self.disposal_type,
)
return {
'type': 'ir.actions.act_window',
'res_model': 'fusion.asset',
'res_id': self.asset_id.id,
'view_mode': 'form',
'target': 'current',
}

View File

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_fusion_disposal_wizard_form" model="ir.ui.view">
<field name="name">fusion.disposal.wizard.form</field>
<field name="model">fusion.disposal.wizard</field>
<field name="arch" type="xml">
<form string="Dispose Asset">
<group>
<field name="asset_id" options="{'no_create': True}" readonly="1"/>
<field name="book_value" readonly="1"/>
<field name="company_id" invisible="1"/>
<field name="currency_id" invisible="1"/>
</group>
<group>
<field name="disposal_type"/>
<field name="disposal_date"/>
<field name="sale_amount" invisible="disposal_type != 'sale'"/>
<field name="sale_partner_id" invisible="disposal_type != 'sale'"/>
<field name="estimated_gain_loss" readonly="1"/>
<field name="notes"/>
</group>
<footer>
<button name="action_dispose" type="object"
string="Confirm Disposal" class="btn-primary"/>
<button special="cancel" string="Cancel"/>
</footer>
</form>
</field>
</record>
<record id="action_fusion_disposal_wizard" model="ir.actions.act_window">
<field name="name">Dispose Asset</field>
<field name="res_model">fusion.disposal.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="binding_model_id" ref="model_fusion_asset"/>
<field name="binding_view_types">form,list</field>
</record>
</odoo>

View File

@@ -0,0 +1,67 @@
"""Partial sale wizard (sell a portion of an asset).
Splits the asset into a child (the sold portion) and disposes the child;
parent retains remaining cost + salvage."""
from odoo import _, api, fields, models
from odoo.exceptions import UserError
class FusionPartialSaleWizard(models.TransientModel):
_name = "fusion.partial.sale.wizard"
_description = "Asset Partial Sale Wizard"
asset_id = fields.Many2one(
'fusion.asset', required=True,
default=lambda self: self._default_asset(),
)
company_id = fields.Many2one(related='asset_id.company_id')
currency_id = fields.Many2one(related='asset_id.currency_id')
cost = fields.Monetary(related='asset_id.cost', readonly=True)
book_value = fields.Monetary(related='asset_id.book_value', readonly=True)
sold_pct = fields.Float(
string='% of cost being sold', default=30.0,
help="Percentage of original cost attributed to the sold portion.",
)
sold_amount = fields.Monetary(string='Sale Amount', required=True)
sale_date = fields.Date(required=True, default=fields.Date.today)
sale_partner_id = fields.Many2one('res.partner')
estimated_sold_cost = fields.Monetary(compute='_compute_sold_cost')
estimated_gain_loss = fields.Monetary(compute='_compute_sold_cost')
@api.model
def _default_asset(self):
ctx = self.env.context
if ctx.get('active_model') == 'fusion.asset':
return ctx.get('active_id')
return False
@api.depends('sold_pct', 'sold_amount', 'cost')
def _compute_sold_cost(self):
for w in self:
w.estimated_sold_cost = round(w.cost * (w.sold_pct or 0) / 100, 2)
w.estimated_gain_loss = w.sold_amount - w.estimated_sold_cost
def action_partial_sell(self):
self.ensure_one()
if not (0 < self.sold_pct < 100):
raise UserError(_("sold_pct must be strictly between 0 and 100."))
if self.asset_id.state == 'disposed':
raise UserError(_("Asset already disposed."))
result = self.env['fusion.asset.engine'].partial_sale(
self.asset_id,
sold_amount=self.sold_amount,
sold_qty=self.sold_pct / 100,
sale_date=self.sale_date,
sale_partner=self.sale_partner_id,
)
return {
'type': 'ir.actions.act_window',
'res_model': 'fusion.asset',
'res_id': result['parent_asset_id'],
'view_mode': 'form',
'target': 'current',
}

View File

@@ -0,0 +1,38 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_fusion_partial_sale_wizard_form" model="ir.ui.view">
<field name="name">fusion.partial.sale.wizard.form</field>
<field name="model">fusion.partial.sale.wizard</field>
<field name="arch" type="xml">
<form string="Partial Sale">
<group>
<field name="asset_id" readonly="1" options="{'no_create': True}"/>
<field name="cost" readonly="1"/>
<field name="book_value" readonly="1"/>
<field name="company_id" invisible="1"/>
<field name="currency_id" invisible="1"/>
</group>
<group>
<field name="sold_pct"/>
<field name="estimated_sold_cost" readonly="1"/>
<field name="sold_amount"/>
<field name="estimated_gain_loss" readonly="1"/>
<field name="sale_date"/>
<field name="sale_partner_id"/>
</group>
<footer>
<button name="action_partial_sell" type="object"
string="Confirm Partial Sale" class="btn-primary"/>
<button special="cancel" string="Cancel"/>
</footer>
</form>
</field>
</record>
<record id="action_fusion_partial_sale_wizard" model="ir.actions.act_window">
<field name="name">Partial Sale</field>
<field name="res_model">fusion.partial.sale.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>

View File

@@ -0,0 +1,142 @@
# fusion_accounting_followup — Cursor / Claude Context
## Purpose
AI-augmented customer follow-ups (dunning) — a Fusion-native replacement
for (and coexisting with) Odoo Enterprise's `account_followup` module.
Ships in Phase 4 of the fusion_accounting roadmap.
## Architecture
Hybrid: the engine (`fusion.followup.engine`, AbstractModel) is the
SINGLE write surface for the follow-up lifecycle. Everything else
(controllers, OWL components, AI tools, wizards, cron) routes through
the engine's 7-method public API:
- `get_overdue_for_partner(partner)`
- `compute_followup_level(partner)`
- `send_followup_email(partner, level=None, force=False)`
- `escalate_to_next_level(partner)`
- `pause_followup(partner, until_date=None)`
- `reset_followup(partner)`
- `snapshot_followup_history(partner, limit=50)`
Pure-Python services live in `services/`:
- `overdue_aging` — 6 buckets (current, 1-30, 31-60, 61-90, 91-120, 120+)
- `level_resolver` — match aging to a `fusion.followup.level`
- `risk_scorer` — 0-100 payment-risk score plus structured drivers
- `tone_selector` — gentle / firm / legal based on level + risk
- `followup_text_generator` + `followup_text_prompt` — LLM-generated
follow-up text with a templated fallback that keeps the feature
usable offline
Persisted models in `models/`:
- `fusion.followup.level` — level definition (delay_days, tone,
mail_template_id, requires_manual_review, sequence)
- `fusion.followup.run` — per-partner audit record (state, level,
amount, ai-generated flag, error captured)
- `fusion.followup.text.cache` — LLM cost-saving cache keyed on
(partner, level, tone, prompt fingerprint)
- `fusion.followup.engine` — AbstractModel (the API)
- `fusion.followup.cron` — cron handlers (daily scan, weekly risk refresh)
- `res.partner` (inherits) — adds `fusion_followup_status`,
`fusion_followup_paused_until`, `fusion_followup_last_level_id`,
`fusion_followup_risk_score`, `fusion_followup_risk_band`
- `account.move.line` (inherits) — adds `fusion_followup_level_id` and
`fusion_followup_last_run_date`
Wizards (TransientModel) in `wizards/`:
- `fusion.batch.followup.wizard` — bulk-send across all overdue
customers, a manual selection, or a level-filtered subset; supports
`auto_resolve_level`, `override_level_id`, and `force` flags
Controllers: `controllers/followup_controller.py` exposes 6 JSON-RPC
endpoints under `/fusion/followup/*` (`list_overdue`, `get_partner`,
`compute_level`, `send`, `escalate`, `pause`, `reset`, `history`,
`generate_text`). All calls route through the engine.
OWL frontend: `static/src/`
- `services/followup_service.js` — central reactive state + RPC wrappers
- `views/followup_dashboard/*` — top-level dashboard view
- `components/risk_badge`, `partner_card`, `aging_bucket_strip`,
`ai_text_panel`, `followup_history_table` — 5 components
- `scss/_variables.scss` + `followup.scss` + `dark_mode.scss`
- `tours/followup_tours.js` — 5 OWL tour smoke tests
Default data:
- `data/followup_levels_data.xml` — 3 default levels
(Reminder @ 7d gentle, Warning @ 30d firm, Legal Notice @ 60d legal)
- `data/mail_templates_data.xml` — 3 mail templates wired to the levels
- `data/cron.xml` — daily scan + weekly risk refresh
## Coexistence
When `account_followup` is installed the Customer Follow-ups menu hides
via `fusion_accounting_core.group_fusion_show_when_enterprise_absent`.
The engine + AI tools always remain available for the chat / API. The
migration step in `fusion.migration.wizard` backfills
`fusion.followup.level` records from existing
`account_followup.followup.line` rows (idempotent — skips rows already
linked via the `legacy_followup_line_id` column).
## V19 Conventions Applied
- `_sql_constraints``models.Constraint` (every persisted model)
- `@api.depends('id')` → not used (would raise `NotImplementedError`)
- `@route(type='json')``type='jsonrpc'` (all 6 endpoints in
`controllers/followup_controller.py`)
- `numbercall` removed from `ir.cron` (data/cron.xml)
- `res.groups.users``user_ids` and `ir.ui.menu.groups_id`
`group_ids` (security + menu_views.xml)
- SCSS: `@import "variables"` is forbidden in V19; rely on manifest
asset concatenation order (`_variables.scss` first)
- OWL `t-on-click` arrow handlers must use an explicit `this.` reference
## Performance baseline (Task 21)
| Operation | P95 | Budget |
|----------------------------------------|-------|----------|
| `engine.compute_followup_level` | 0ms | 50ms |
| `engine.get_overdue_for_partner` | 1ms | 100ms |
| `engine.send_followup_email` (no due) | 0ms | 200ms |
| `controller.list_overdue` (20 ptrs) | 100ms | 500ms |
(Engine ops measured against partners with no overdue lines — these are
floor measurements; load-driven scaling is verified in
`test_performance_benchmarks.py`.) All Phase 4 perf metrics are within
1x of budget; no optimization needed at ship.
## Test counts (Phase 4 ship)
- 106 logical tests in `fusion_accounting_followup`
- 0 failures, 0 errors
- Coverage includes: 4 engine + 1 controller benchmark (tagged
`benchmark`), 1 local LLM smoke (tagged `local_llm`, skips when no
LLM), 5 OWL tour tests (tagged `tour`, skip without
websocket-client), Hypothesis property tests on the engine,
integration tests on the public API, controller round-trip tests,
cron tests, batch wizard tests, coexistence tests, migration
round-trip test.
## Known concerns / Phase 4.5 backlog
- `risk_scorer._compute_risk` `paid_late_count` and `avg_days_late` are
placeholders; full reconciliation traversal deferred for performance.
- Migration tone heuristic could misclassify Enterprise levels with
non-standard sequence numbers (numeric sequence outside 1/10/100
buckets).
- `pause_followup` / `reset_followup` do not `sudo()` the partner
write — could fail for non-admin users without partner-write rights.
- Email send is best-effort — failure is captured on the
`fusion.followup.run` record but does not raise.
- `followup_text_generator` always returns a usable dict (templated
fallback when LLM absent), so callers can't distinguish "AI said so"
from "fallback fired"; the `tone_used` and absence of `key_points`
are the only signals.
- Sub-second SLA on `controller.list_overdue` for partner counts > 200
is not yet stress-tested.

View File

@@ -0,0 +1,66 @@
# fusion_accounting_followup
AI-augmented customer follow-ups (dunning) for Odoo 19 Community — a
Fusion-native replacement for Enterprise's `account_followup` module.
## What it does
- Multi-level dunning sequences (gentle reminder, firm warning, legal
notice) with delay-day cadence per level
- 6-bucket aging analysis (current, 1-30, 31-60, 61-90, 91-120, 120+)
per customer
- Per-partner follow-up state machine (`current`, `action_due`,
`paused`, `blocked`, `with_credit_team`)
- Daily cron that scans overdue customers and queues / sends follow-ups
- Weekly cron that refreshes the AI risk score on every overdue customer
- Mail templates per level, with per-partner context interpolation
- Batch wizard for bulk-send across all overdue customers, an
arbitrary selection, or a level-filtered subset
- Per-partner follow-up history with state, level, and amount audit
- AI augmentation:
- **Payment-risk scoring** — 0-100 score plus structured drivers
(paid-late ratio, longest-overdue band, recent dispute, etc.)
- **Tone selection** — gentle / firm / legal based on level + risk
- **Follow-up text generation** — LLM-driven subject + body keyed
on tone, with a templated keyword fallback so the feature still
works offline
- Coexists with Enterprise `account_followup` (Enterprise wins by
default; the Fusion menu only appears when Enterprise is uninstalled)
- Migration-aware: bootstrap step backfills `fusion.followup.level`
records from existing `account_followup.followup.line` rows so the AI
has memory from day 1
## Quick start
```bash
# Install (sub-module)
odoo --addons-path=... -i fusion_accounting_followup
# Or install the whole suite via the meta-module
odoo --addons-path=... -i fusion_accounting
# Open the dashboard (when Enterprise's account_followup is NOT installed)
# Apps -> Customer Follow-ups -> Overdue Customers
# When Enterprise IS installed: use Enterprise's UI; the engine + AI tools
# are still available via the AI chat.
```
## Configuration
- Local LLM (LM Studio, Ollama):
- `fusion_accounting.openai_base_url` =
`http://host.docker.internal:1234/v1`
- `fusion_accounting.openai_model` = your local model name
- `fusion_accounting.openai_api_key` = `lm-studio` (anything non-empty)
- `fusion_accounting.provider.followup_text` = `openai`
## Public API (engine)
`fusion.followup.engine` is the single write surface. See `CLAUDE.md`
for the full 7-method signature list.
## See also
- `CLAUDE.md` — agent context
- `UPGRADE_NOTES.md` — Odoo version anchoring

View File

@@ -0,0 +1,56 @@
# fusion_accounting_followup — Upgrade Notes
## Odoo Version Anchor
This module targets **Odoo 19.0** (community-base).
Reference snapshot of Enterprise code mirrored from:
- `account_followup` (Odoo 19.0.x)
- Source: `/Users/gurpreet/Github/RePackaged-Odoo/accounting/account_followup/`
## 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_followup.followup.line`,
`res.partner` follow-up fields, or mail-template invocation 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 and any deviations
## V19 Migration Notes (already applied)
- `_sql_constraints``models.Constraint` (every persisted model)
- `@api.depends('id')` → not used (would raise `NotImplementedError`)
- `@route(type='json')``type='jsonrpc'` (all 6 endpoints in
`controllers/followup_controller.py`)
- `numbercall` removed from `ir.cron` (data/cron.xml)
- `res.groups.users``user_ids` and `ir.ui.menu.groups_id`
`group_ids` (security + menu_views.xml)
- SCSS: `@import "variables"` removed; manifest concatenation order
(`_variables.scss` first) provides the variables to the rest of the
asset bundle
- OWL `t-on-click` arrow handlers always close over an explicit `this.`
## Phase 4 → Phase 4.5 Migration
If we ship Phase 4.5 (full `paid_late_count` traversal, sub-annual
follow-up cadences, multi-currency aggregation in `risk_scorer`,
admin-only pause sudo wrapper), changes will go in incremental commits.
No DB migration needed (Phase 4 schema is forward-compatible — new
columns will be nullable / default-valued).
## Coexistence with Enterprise `account_followup`
The migration step in `fusion.migration.wizard` backfills
`fusion.followup.level` records from existing
`account_followup.followup.line` rows. It is idempotent (skips rows
already linked via the `legacy_followup_line_id` column).
When `account_followup` is installed the Customer Follow-ups menu hides
via `fusion_accounting_core.group_fusion_show_when_enterprise_absent`.
The engine and AI tools remain available for chat-driven workflows.

View File

@@ -0,0 +1,5 @@
from . import models
from . import services
from . import controllers
from . import wizards
from . import reports

View File

@@ -0,0 +1,71 @@
{
'name': 'Fusion Accounting Follow-up',
'version': '19.0.1.0.30',
'category': 'Accounting/Accounting',
'summary': 'AI-augmented customer follow-ups (dunning) for unpaid invoices.',
'description': """
Fusion Accounting Follow-up
===========================
A Fusion-native replacement for Odoo Enterprise's account_followup module.
CORE scope (Phase 4):
- Multi-level dunning sequences (gentle reminder, firm warning, legal)
- Per-partner follow-up state (current level, paused-until, history)
- Automated daily scan + escalation cron
- Mail templates per level
AI augmentation:
- Contextually-appropriate follow-up text generation (LLM)
- Payment-risk scoring from invoice/payment history
- Tone selection (gentle/firm/legal) based on level + risk
Coexists with Enterprise: when account_followup is installed, the Fusion
menu hides; the engine + AI tools remain available for the chat.
""",
'author': 'Fusion Accounting',
'license': 'LGPL-3',
'depends': [
'fusion_accounting_core',
'fusion_accounting_ai',
'fusion_accounting_migration',
'account',
'mail',
],
'data': [
'security/ir.model.access.csv',
'data/cron.xml',
'data/followup_levels_data.xml',
'data/mail_templates_data.xml',
'wizards/batch_followup_wizard_views.xml',
'views/menu_views.xml',
],
'assets': {
'web.assets_backend': [
'fusion_accounting_followup/static/src/scss/_variables.scss',
'fusion_accounting_followup/static/src/scss/followup.scss',
'fusion_accounting_followup/static/src/scss/dark_mode.scss',
'fusion_accounting_followup/static/src/services/followup_service.js',
'fusion_accounting_followup/static/src/views/followup_dashboard/followup_dashboard.js',
'fusion_accounting_followup/static/src/views/followup_dashboard/followup_dashboard.xml',
'fusion_accounting_followup/static/src/views/followup_dashboard/followup_dashboard_view.js',
'fusion_accounting_followup/static/src/components/risk_badge/risk_badge.js',
'fusion_accounting_followup/static/src/components/risk_badge/risk_badge.xml',
'fusion_accounting_followup/static/src/components/partner_card/partner_card.js',
'fusion_accounting_followup/static/src/components/partner_card/partner_card.xml',
'fusion_accounting_followup/static/src/components/aging_bucket_strip/aging_bucket_strip.js',
'fusion_accounting_followup/static/src/components/aging_bucket_strip/aging_bucket_strip.xml',
'fusion_accounting_followup/static/src/components/ai_text_panel/ai_text_panel.js',
'fusion_accounting_followup/static/src/components/ai_text_panel/ai_text_panel.xml',
'fusion_accounting_followup/static/src/components/followup_history_table/followup_history_table.js',
'fusion_accounting_followup/static/src/components/followup_history_table/followup_history_table.xml',
],
'web.assets_tests': [
'fusion_accounting_followup/static/src/tours/followup_tours.js',
],
},
'installable': True,
'auto_install': False,
'application': False,
'icon': '/fusion_accounting_followup/static/description/icon.png',
}

View File

@@ -0,0 +1 @@
from . import followup_controller

View File

@@ -0,0 +1,173 @@
"""HTTP controller: 6 JSON-RPC endpoints for the OWL follow-up dashboard.
All endpoints route through fusion.followup.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 FusionFollowupController(http.Controller):
@http.route('/fusion/followup/list_overdue', type='jsonrpc', auth='user')
def list_overdue(self, limit=50, offset=0, status=None, company_id=None):
company_id = int(company_id) if company_id else request.env.company.id
Partner = request.env['res.partner'].sudo()
Line = request.env['account.move.line'].sudo()
overdue_partner_ids = Line.search([
('parent_state', '=', 'posted'),
('account_id.account_type', '=', 'asset_receivable'),
('reconciled', '=', False),
('amount_residual', '>', 0),
('date_maturity', '<', date.today()),
('company_id', '=', company_id),
]).mapped('partner_id').ids
domain = [('id', 'in', overdue_partner_ids)]
if status:
domain.append(('fusion_followup_status', '=', status))
total = Partner.search_count(domain)
partners = Partner.search(domain, limit=int(limit), offset=int(offset))
engine = request.env['fusion.followup.engine']
rows = []
for p in partners:
try:
overdue = engine.get_overdue_for_partner(p)
rows.append({
'partner_id': p.id,
'partner_name': p.name,
'email': p.email or '',
'status': p.fusion_followup_status,
'paused_until': str(p.fusion_followup_paused_until)
if p.fusion_followup_paused_until else None,
'last_level_id': p.fusion_followup_last_level_id.id
if p.fusion_followup_last_level_id else None,
'last_level_name': p.fusion_followup_last_level_id.name
if p.fusion_followup_last_level_id else None,
'last_run_date': str(p.fusion_followup_last_run_date)
if p.fusion_followup_last_run_date else None,
'overdue_amount': overdue['aging']['total_overdue_amount'],
'overdue_line_count': overdue['overdue_line_count'],
'risk_score': overdue['risk']['score'],
'risk_band': overdue['risk']['band'],
})
except Exception as e:
_logger.warning("Skipping partner %s in list: %s", p.id, e)
return {'count': len(rows), 'total': total, 'partners': rows}
@http.route('/fusion/followup/get_partner_detail', type='jsonrpc', auth='user')
def get_partner_detail(self, partner_id):
partner = request.env['res.partner'].browse(int(partner_id))
if not partner.exists():
raise ValidationError(_("Partner %s not found") % partner_id)
engine = request.env['fusion.followup.engine']
overdue = engine.get_overdue_for_partner(partner)
history = engine.snapshot_followup_history(partner, limit=20)
level = engine.compute_followup_level(partner)
return {
'partner': {
'id': partner.id,
'name': partner.name,
'email': partner.email or '',
'status': partner.fusion_followup_status,
'paused_until': str(partner.fusion_followup_paused_until)
if partner.fusion_followup_paused_until else None,
'last_level_id': partner.fusion_followup_last_level_id.id
if partner.fusion_followup_last_level_id else None,
'last_level_name': partner.fusion_followup_last_level_id.name
if partner.fusion_followup_last_level_id else None,
'last_run_date': str(partner.fusion_followup_last_run_date)
if partner.fusion_followup_last_run_date else None,
'risk_score': partner.fusion_followup_risk_score,
'risk_band': partner.fusion_followup_risk_band,
},
'overdue': overdue,
'suggested_level': {
'id': level.id, 'name': level.name, 'tone': level.tone,
'sequence': level.sequence,
} if level else None,
'history': history,
}
@http.route('/fusion/followup/generate_text', type='jsonrpc', auth='user')
def generate_text(self, partner_id, level_id=None, force_regenerate=False):
from odoo.addons.fusion_accounting_followup.services.followup_text_generator import (
generate_followup_text,
)
from odoo.addons.fusion_accounting_followup.services.tone_selector import select_tone
partner = request.env['res.partner'].browse(int(partner_id))
engine = request.env['fusion.followup.engine']
if level_id:
level = request.env['fusion.followup.level'].browse(int(level_id))
else:
level = engine.compute_followup_level(partner)
if not level:
return {'status': 'no_level', 'partner_id': partner.id}
overdue = engine.get_overdue_for_partner(partner)
tone = select_tone(
level_sequence=level.sequence,
risk_score=overdue['risk']['score'],
)
currency_code = 'USD'
if partner.company_id and partner.company_id.currency_id:
currency_code = partner.company_id.currency_id.name or 'USD'
text = generate_followup_text(
request.env,
partner_name=partner.name,
total_overdue=overdue['aging']['total_overdue_amount'],
currency_code=currency_code,
longest_overdue_days=engine._max_overdue_days_from_aging(overdue['aging']),
tone=tone,
invoice_count=overdue['overdue_line_count'],
risk_drivers=overdue['risk']['drivers'],
)
return {
'status': 'ok',
'partner_id': partner.id,
'level_id': level.id,
'tone': tone,
'subject': text.get('subject', ''),
'body': text.get('body', ''),
'tone_used': text.get('tone_used', tone),
'key_points': text.get('key_points', []),
}
@http.route('/fusion/followup/send', type='jsonrpc', auth='user')
def send_followup(self, partner_id, level_id=None, force=False):
partner = request.env['res.partner'].browse(int(partner_id))
engine = request.env['fusion.followup.engine']
level = None
if level_id:
level = request.env['fusion.followup.level'].browse(int(level_id))
return engine.send_followup_email(partner, level=level, force=bool(force))
@http.route('/fusion/followup/pause', type='jsonrpc', auth='user')
def pause(self, partner_id, until_date=None):
partner = request.env['res.partner'].browse(int(partner_id))
engine = request.env['fusion.followup.engine']
return engine.pause_followup(partner, until_date=_parse_date(until_date))
@http.route('/fusion/followup/reset', type='jsonrpc', auth='user')
def reset(self, partner_id):
partner = request.env['res.partner'].browse(int(partner_id))
engine = request.env['fusion.followup.engine']
return engine.reset_followup(partner)

View File

@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="cron_fusion_followup_daily_scan" model="ir.cron">
<field name="name">Fusion Follow-up — Daily Scan + Send</field>
<field name="model_id" ref="model_fusion_followup_cron"/>
<field name="state">code</field>
<field name="code">model._cron_daily_scan()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active" eval="True"/>
</record>
<record id="cron_fusion_followup_risk_refresh" model="ir.cron">
<field name="name">Fusion Follow-up — Weekly Risk Refresh</field>
<field name="model_id" ref="model_fusion_followup_cron"/>
<field name="state">code</field>
<field name="code">model._cron_risk_refresh()</field>
<field name="interval_number">7</field>
<field name="interval_type">days</field>
<field name="active" eval="True"/>
</record>
</odoo>

View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="level_reminder" model="fusion.followup.level">
<field name="name">Friendly Reminder</field>
<field name="sequence">1</field>
<field name="delay_days">7</field>
<field name="tone">gentle</field>
<field name="description">First contact - friendly reminder of overdue invoice.</field>
<field name="active" eval="True"/>
</record>
<record id="level_warning" model="fusion.followup.level">
<field name="name">Firm Warning</field>
<field name="sequence">2</field>
<field name="delay_days">30</field>
<field name="tone">firm</field>
<field name="description">Second contact - clear request for immediate action.</field>
<field name="active" eval="True"/>
</record>
<record id="level_legal_notice" model="fusion.followup.level">
<field name="name">Legal Notice</field>
<field name="sequence">3</field>
<field name="delay_days">60</field>
<field name="tone">legal</field>
<field name="description">Final notice before referring to collections.</field>
<field name="requires_manual_review" eval="True"/>
<field name="active" eval="True"/>
</record>
</odoo>

View File

@@ -0,0 +1,85 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo noupdate="1">
<record id="email_template_followup_gentle" model="mail.template">
<field name="name">Fusion Followup: Friendly Reminder</field>
<field name="model_id" ref="base.model_res_partner"/>
<field name="subject">Friendly reminder: invoice payment</field>
<field name="email_from">{{ user.email_formatted }}</field>
<field name="email_to">{{ object.email }}</field>
<field name="body_html" type="html">
<div>
<p>Dear <t t-out="object.name"/>,</p>
<p>This is a friendly reminder that you have outstanding invoices on
your account. We understand that things happen — please let us know
if there is anything we can do to help resolve this.</p>
<p>You can review your account statement at any time, or contact our
accounts receivable team with any questions.</p>
<p>Best regards,<br/>
<t t-out="user.company_id.name"/></p>
</div>
</field>
<field name="lang">{{ object.lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
<record id="email_template_followup_firm" model="mail.template">
<field name="name">Fusion Followup: Firm Warning</field>
<field name="model_id" ref="base.model_res_partner"/>
<field name="subject">Outstanding invoices — action required</field>
<field name="email_from">{{ user.email_formatted }}</field>
<field name="email_to">{{ object.email }}</field>
<field name="body_html" type="html">
<div>
<p>Dear <t t-out="object.name"/>,</p>
<p>Our records show outstanding invoices that require your immediate
attention. We request that you remit payment as soon as possible to
avoid further escalation.</p>
<p>If you have already remitted payment, please disregard this notice
and contact us with the payment details so we can update our records.</p>
<p>If there are any disputes or concerns regarding these invoices,
please contact our accounts receivable team immediately.</p>
<p>Regards,<br/>
<t t-out="user.company_id.name"/></p>
</div>
</field>
<field name="lang">{{ object.lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
<record id="email_template_followup_legal" model="mail.template">
<field name="name">Fusion Followup: Legal Notice</field>
<field name="model_id" ref="base.model_res_partner"/>
<field name="subject">FINAL NOTICE — outstanding balance</field>
<field name="email_from">{{ user.email_formatted }}</field>
<field name="email_to">{{ object.email }}</field>
<field name="body_html" type="html">
<div>
<p>Dear <t t-out="object.name"/>,</p>
<p>This is a FINAL NOTICE regarding outstanding invoices on your
account. Despite previous reminders, your balance remains unpaid.</p>
<p>If full payment is not received within 7 days from the date of this
notice, we will be forced to refer this matter to our legal department
for collection. This may include reporting the delinquency to credit
bureaus and pursuing further legal action as permitted by law.</p>
<p>Please contact us immediately to resolve this matter.</p>
<p>Regards,<br/>
<t t-out="user.company_id.name"/></p>
</div>
</field>
<field name="lang">{{ object.lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
<!-- Wire templates to default levels -->
<record id="level_reminder" model="fusion.followup.level">
<field name="mail_template_id" ref="email_template_followup_gentle"/>
</record>
<record id="level_warning" model="fusion.followup.level">
<field name="mail_template_id" ref="email_template_followup_firm"/>
</record>
<record id="level_legal_notice" model="fusion.followup.level">
<field name="mail_template_id" ref="email_template_followup_legal"/>
</record>
</odoo>

View File

@@ -0,0 +1,8 @@
from . import fusion_followup_level
from . import fusion_followup_run
from . import fusion_followup_text_cache
from . import res_partner
from . import account_move_line
from . import fusion_followup_engine
from . import fusion_followup_cron
from . import fusion_migration_wizard

View File

@@ -0,0 +1,14 @@
"""Inherit account.move.line: track last follow-up level."""
from odoo import _, api, fields, models
class AccountMoveLine(models.Model):
_inherit = "account.move.line"
fusion_followup_level_id = fields.Many2one(
'fusion.followup.level', copy=False,
help="Last follow-up level at which this line was contacted.")
fusion_followup_last_run_date = fields.Datetime(
copy=False,
help="When the line was most-recently included in a follow-up.")

View File

@@ -0,0 +1,84 @@
"""Cron handlers for fusion_accounting_followup.
Two scheduled jobs:
- Daily scan: walk every partner with an open overdue receivable line and
call the engine to send/escalate where appropriate.
- Weekly risk refresh: recompute fusion_followup_risk_score on every
partner with overdue.
"""
import logging
from datetime import date
from odoo import api, models
_logger = logging.getLogger(__name__)
class FusionFollowupCron(models.AbstractModel):
_name = "fusion.followup.cron"
_description = "Fusion Follow-up Cron Handlers"
@api.model
def _cron_daily_scan(self):
"""Scan every partner with overdue and send follow-ups when due."""
engine = self.env['fusion.followup.engine']
Line = self.env['account.move.line'].sudo()
overdue_lines = Line.search([
('parent_state', '=', 'posted'),
('account_id.account_type', '=', 'asset_receivable'),
('reconciled', '=', False),
('amount_residual', '>', 0),
('date_maturity', '<', date.today()),
])
partner_ids = list(set(overdue_lines.mapped('partner_id').ids))
sent = 0
skipped = 0
for pid in partner_ids:
partner = self.env['res.partner'].sudo().browse(pid)
if not partner.exists():
continue
try:
with self.env.cr.savepoint():
result = engine.send_followup_email(partner)
if result.get('status') == 'sent':
sent += 1
else:
skipped += 1
except Exception as e:
_logger.warning(
"Cron daily_scan failed for partner %s: %s", pid, e,
)
skipped += 1
_logger.info(
"Cron: scanned %d partners, sent %d, skipped %d",
len(partner_ids), sent, skipped,
)
@api.model
def _cron_risk_refresh(self):
"""Refresh fusion_followup_risk_score on every partner with overdue."""
Partner = self.env['res.partner'].sudo()
engine = self.env['fusion.followup.engine']
Line = self.env['account.move.line'].sudo()
partner_ids = list(set(Line.search([
('parent_state', '=', 'posted'),
('account_id.account_type', '=', 'asset_receivable'),
('reconciled', '=', False),
('amount_residual', '>', 0),
]).mapped('partner_id').ids))
updated = 0
for pid in partner_ids:
partner = Partner.browse(pid)
try:
overdue = engine.get_overdue_for_partner(partner)
partner.write({
'fusion_followup_risk_score': overdue['risk']['score'],
'fusion_followup_risk_band': overdue['risk']['band'],
})
updated += 1
except Exception as e:
_logger.warning(
"Risk refresh failed for partner %s: %s", pid, e,
)
_logger.info("Cron: refreshed risk on %d partners", updated)

View File

@@ -0,0 +1,379 @@
"""The follow-up engine — orchestrator for customer follow-ups.
7-method public API. All controllers, AI tools, wizards, cron must
go through this engine; no direct ORM writes to fusion.followup.run
from elsewhere."""
import logging
from datetime import date, timedelta
from odoo import _, api, fields, models
from odoo.exceptions import ValidationError, UserError
from ..services.overdue_aging import compute_aging
from ..services.level_resolver import resolve_level, FollowupLevelSpec
from ..services.risk_scorer import score_partner
from ..services.tone_selector import select_tone
from ..services.followup_text_generator import generate_followup_text
_logger = logging.getLogger(__name__)
class FusionFollowupEngine(models.AbstractModel):
_name = "fusion.followup.engine"
_description = "Fusion Follow-up Engine"
# ============================================================
# PUBLIC API (7 methods)
# ============================================================
@api.model
def get_overdue_for_partner(self, partner) -> dict:
"""Return aging report + risk score for a partner."""
partner.ensure_one()
as_of = fields.Date.today()
move_lines = self._fetch_overdue_lines(partner)
aging = compute_aging(
move_lines=[{
'date_maturity': l.date_maturity,
'amount_residual': l.amount_residual,
} for l in move_lines],
as_of=as_of,
)
risk = self._compute_risk(partner, move_lines)
return {
'partner_id': partner.id,
'as_of': str(as_of),
'aging': aging.to_dict(),
'risk': {
'score': risk.score,
'band': risk.band,
'drivers': risk.drivers,
},
'overdue_line_count': len(move_lines),
}
@api.model
def compute_followup_level(self, partner, *, ignore_pause=False):
"""Return the fusion.followup.level recordset that should fire now,
or empty recordset if no action needed."""
partner.ensure_one()
Level = self.env['fusion.followup.level']
if not ignore_pause and partner.fusion_followup_paused_until and \
partner.fusion_followup_paused_until > fields.Date.today():
return Level
as_of = fields.Date.today()
move_lines = self._fetch_overdue_lines(partner)
if not move_lines:
return Level
aging = compute_aging(
move_lines=[{
'date_maturity': l.date_maturity,
'amount_residual': l.amount_residual,
} for l in move_lines],
as_of=as_of,
)
company_id = partner.company_id.id if partner.company_id else self.env.company.id
levels = Level.search([
('active', '=', True),
'|', ('company_id', '=', company_id), ('company_id', '=', False),
], order='sequence')
if not levels:
return Level
specs = [FollowupLevelSpec(
sequence=l.sequence, name=l.name,
delay_days=l.delay_days, tone=l.tone,
) for l in levels]
chosen_spec = resolve_level(aging_report=aging, levels=specs)
if chosen_spec is None:
return Level
return levels.filtered(lambda l: l.sequence == chosen_spec.sequence)[:1]
@api.model
def send_followup_email(self, partner, *, level=None, force=False) -> dict:
"""Send a follow-up email at the given level (or auto-resolve if None).
Creates a fusion.followup.run record. Uses cached text if available."""
partner.ensure_one()
if not force and partner.fusion_followup_paused_until and \
partner.fusion_followup_paused_until > fields.Date.today():
return {
'status': 'paused_until_' + str(partner.fusion_followup_paused_until),
'partner_id': partner.id,
}
if not level:
level = self.compute_followup_level(partner, ignore_pause=force)
if not level:
return {'status': 'no_action', 'partner_id': partner.id}
if level.requires_manual_review and not force:
run = self._create_run(partner, level, state='manual_review')
return {
'status': 'manual_review',
'partner_id': partner.id,
'run_id': run.id,
}
overdue_data = self.get_overdue_for_partner(partner)
if overdue_data['overdue_line_count'] == 0:
return {'status': 'no_overdue', 'partner_id': partner.id}
tone = select_tone(
level_sequence=level.sequence,
risk_score=overdue_data['risk']['score'],
)
text_data = self._get_or_generate_text(
partner=partner, level=level,
overdue_amount=overdue_data['aging']['total_overdue_amount'],
longest_overdue_days=self._max_overdue_days_from_aging(overdue_data['aging']),
invoice_count=overdue_data['overdue_line_count'],
tone=tone, risk_drivers=overdue_data['risk']['drivers'],
)
run = self._create_run(
partner, level, state='draft',
overdue_amount=overdue_data['aging']['total_overdue_amount'],
longest_overdue_days=self._max_overdue_days_from_aging(overdue_data['aging']),
risk_score=overdue_data['risk']['score'],
risk_band=overdue_data['risk']['band'],
subject=text_data['subject'],
body=text_data['body'],
tone_used=text_data['tone_used'],
text_was_ai_generated=text_data.get('_was_ai', False),
)
try:
self._send_email(partner, run)
run.write({'state': 'sent'})
partner.write({
'fusion_followup_status': 'no_action',
'fusion_followup_last_level_id': level.id,
'fusion_followup_last_run_date': fields.Datetime.now(),
})
except Exception as e:
_logger.warning("Email send failed for partner %s: %s", partner.id, e)
run.write({'state': 'failed', 'error_message': str(e)})
return {
'status': 'sent', 'partner_id': partner.id,
'run_id': run.id, 'level_id': level.id, 'tone': tone,
}
@api.model
def escalate_to_next_level(self, partner) -> dict:
"""Force the next-higher level than the partner's current last_level."""
partner.ensure_one()
Level = self.env['fusion.followup.level']
current = partner.fusion_followup_last_level_id
next_seq = (current.sequence + 1) if current else 1
company_id = partner.company_id.id if partner.company_id else self.env.company.id
next_level = Level.search([
('active', '=', True),
('sequence', '>=', next_seq),
'|', ('company_id', '=', company_id), ('company_id', '=', False),
], order='sequence', limit=1)
if not next_level:
return {'status': 'at_max_level', 'partner_id': partner.id}
return self.send_followup_email(partner, level=next_level, force=True)
@api.model
def pause_followup(self, partner, until_date: date = None) -> dict:
"""Pause follow-ups for a partner until a date (default 30 days)."""
partner.ensure_one()
until = until_date or (fields.Date.today() + timedelta(days=30))
partner.write({
'fusion_followup_paused_until': until,
'fusion_followup_status': 'paused',
})
return {'partner_id': partner.id, 'paused_until': str(until)}
@api.model
def reset_followup(self, partner) -> dict:
"""Reset partner's follow-up state to no_action."""
partner.ensure_one()
partner.write({
'fusion_followup_status': 'no_action',
'fusion_followup_paused_until': False,
'fusion_followup_last_level_id': False,
})
return {'partner_id': partner.id, 'status': 'reset'}
@api.model
def snapshot_followup_history(self, partner, *, limit: int = 50) -> dict:
"""Return audit history for a partner."""
partner.ensure_one()
Run = self.env['fusion.followup.run']
runs = Run.search([
('partner_id', '=', partner.id),
], order='execution_date desc', limit=int(limit))
return {
'partner_id': partner.id,
'count': len(runs),
'runs': [{
'id': r.id, 'date': str(r.execution_date),
'level_id': r.level_id.id if r.level_id else None,
'level_name': r.level_id.name if r.level_id else '',
'state': r.state,
'overdue_amount': r.overdue_amount,
'longest_overdue_days': r.longest_overdue_days,
'tone_used': r.tone_used,
'risk_score': r.risk_score,
'subject': r.subject or '',
'text_was_ai_generated': r.text_was_ai_generated,
} for r in runs],
}
# ============================================================
# PRIVATE HELPERS
# ============================================================
def _fetch_overdue_lines(self, partner):
"""Fetch posted, unreconciled receivable lines for a partner."""
Line = self.env['account.move.line'].sudo()
return Line.search([
('partner_id', '=', partner.id),
('parent_state', '=', 'posted'),
('account_id.account_type', '=', 'asset_receivable'),
('reconciled', '=', False),
('amount_residual', '>', 0),
])
def _compute_risk(self, partner, overdue_lines):
"""Compute risk score from partner's payment history."""
Line = self.env['account.move.line'].sudo()
all_lines = Line.search([
('partner_id', '=', partner.id),
('parent_state', '=', 'posted'),
('account_id.account_type', '=', 'asset_receivable'),
])
total_invoices = len(all_lines)
# Heavy paid-late computation deferred to Phase 4.5
paid_late_count = 0
avg_days_late = 0.0
as_of = fields.Date.today()
longest_overdue_days = 0
for line in overdue_lines:
if line.date_maturity:
days = (as_of - line.date_maturity).days
if days > longest_overdue_days:
longest_overdue_days = days
open_overdue = sum(line.amount_residual for line in overdue_lines)
avg_invoice_amount = 1000.0
if total_invoices > 0:
total_amount = sum(all_lines.mapped('balance'))
if total_amount:
avg_invoice_amount = abs(total_amount) / total_invoices
return score_partner(
total_invoices=total_invoices,
paid_late_count=paid_late_count,
avg_days_late=avg_days_late,
longest_overdue_days=longest_overdue_days,
open_overdue_amount=open_overdue,
average_invoice_amount=avg_invoice_amount,
)
def _max_overdue_days_from_aging(self, aging_dict):
"""Extract longest overdue days from aging dict."""
tracked = aging_dict.get('max_days_overdue', 0) or 0
if tracked:
return tracked
max_days = 0
for b in aging_dict.get('buckets', []):
if b['name'] == 'current' or b['amount'] <= 0:
continue
if b['days_max'] is None:
max_days = max(max_days, b['days_min'])
else:
max_days = max(max_days, b['days_max'])
return max_days
def _get_or_generate_text(self, *, partner, level, overdue_amount,
longest_overdue_days, invoice_count, tone,
risk_drivers=None) -> dict:
"""Cache lookup + LLM fallback."""
Cache = self.env['fusion.followup.text.cache']
cached = Cache.lookup(
partner_id=partner.id, level_id=level.id,
overdue_amount=overdue_amount,
longest_overdue_days=longest_overdue_days,
invoice_count=invoice_count, tone=tone,
)
if cached:
cached.action_increment_use()
return {
'subject': cached.subject, 'body': cached.body,
'tone_used': cached.tone_used,
'key_points': cached.key_points or [],
'_was_ai': bool(cached.provider),
}
company = partner.company_id or self.env.company
currency = company.currency_id
text = generate_followup_text(
self.env,
partner_name=partner.name,
total_overdue=overdue_amount,
currency_code=currency.name or 'USD',
longest_overdue_days=longest_overdue_days,
tone=tone, invoice_count=invoice_count,
risk_drivers=risk_drivers,
)
try:
Cache.sudo().create({
'partner_id': partner.id, 'level_id': level.id,
'company_id': company.id,
'fingerprint': Cache.compute_fingerprint(
partner_id=partner.id, level_id=level.id,
overdue_amount=overdue_amount,
longest_overdue_days=longest_overdue_days,
invoice_count=invoice_count, tone=tone,
),
'subject': text['subject'], 'body': text['body'],
'tone_used': text.get('tone_used', tone),
'key_points': text.get('key_points', []),
})
except Exception as e:
_logger.debug("Cache create failed (non-fatal): %s", e)
text['_was_ai'] = False
return text
def _create_run(self, partner, level, *, state='draft', **vals):
Run = self.env['fusion.followup.run'].sudo()
company = partner.company_id or self.env.company
defaults = {
'partner_id': partner.id,
'company_id': company.id,
'level_id': level.id if level else False,
'state': state,
}
defaults.update(vals)
return Run.create(defaults)
def _send_email(self, partner, run):
"""Best-effort email send. Uses level's mail_template if set, else
creates a simple message."""
if not partner.email:
raise UserError(_("Partner %s has no email address.") % partner.name)
if run.level_id and run.level_id.mail_template_id:
run.level_id.mail_template_id.send_mail(partner.id, force_send=True)
else:
body_text = (run.body or '').replace('<', '&lt;').replace('>', '&gt;')
self.env['mail.mail'].sudo().create({
'subject': run.subject or 'Follow-up',
'body_html': '<pre>{}</pre>'.format(body_text),
'email_to': partner.email,
'recipient_ids': [(4, partner.id)],
}).send()
run.write({'sent_to_email': partner.email})

View File

@@ -0,0 +1,42 @@
"""Follow-up level definition (e.g. Reminder at 7 days, Warning at 30, Legal at 60)."""
from odoo import _, api, fields, models
TONE_SELECTION = [
('gentle', 'Gentle'),
('firm', 'Firm'),
('legal', 'Legal'),
]
class FusionFollowupLevel(models.Model):
_name = "fusion.followup.level"
_description = "Fusion Follow-up Level"
_order = "sequence, id"
name = fields.Char(required=True, translate=True)
sequence = fields.Integer(required=True, default=10,
help="Order in which levels escalate (1, 2, 3...).")
delay_days = fields.Integer(required=True,
help="Min days overdue to trigger this level.")
tone = fields.Selection(TONE_SELECTION, required=True, default='gentle')
description = fields.Text()
company_id = fields.Many2one('res.company', default=lambda self: self.env.company)
mail_template_id = fields.Many2one('mail.template',
domain=[('model', '=', 'res.partner')])
requires_manual_review = fields.Boolean(default=False,
help="If True, follow-ups at this level need human approval before send.")
active = fields.Boolean(default=True)
_check_delay_positive = models.Constraint(
'CHECK(delay_days >= 0)',
'delay_days must be non-negative.',
)
_unique_sequence_per_company = models.Constraint(
'UNIQUE(company_id, sequence)',
'Sequence must be unique per company.',
)

View File

@@ -0,0 +1,54 @@
"""Audit record of one follow-up execution (per partner per level)."""
from odoo import _, api, fields, models
STATE_SELECTION = [
('draft', 'Draft'),
('sent', 'Sent'),
('manual_review', 'Manual Review'),
('skipped', 'Skipped'),
('failed', 'Failed'),
]
class FusionFollowupRun(models.Model):
_name = "fusion.followup.run"
_description = "Fusion Follow-up Run (Per-Partner Audit)"
_order = "execution_date desc, id desc"
partner_id = fields.Many2one('res.partner', required=True, ondelete='cascade')
company_id = fields.Many2one('res.company', required=True,
default=lambda self: self.env.company)
level_id = fields.Many2one('fusion.followup.level', ondelete='restrict')
execution_date = fields.Datetime(default=fields.Datetime.now, required=True)
state = fields.Selection(STATE_SELECTION, default='draft', required=True)
overdue_amount = fields.Float()
longest_overdue_days = fields.Integer()
risk_score = fields.Integer()
risk_band = fields.Selection([
('low', 'Low'), ('medium', 'Medium'),
('high', 'High'), ('critical', 'Critical'),
])
subject = fields.Char()
body = fields.Text()
tone_used = fields.Selection([
('gentle', 'Gentle'), ('firm', 'Firm'), ('legal', 'Legal'),
])
sent_to_email = fields.Char()
text_was_ai_generated = fields.Boolean(default=False)
ai_provider = fields.Char(help="LLM provider name (openai, claude, etc.) if AI was used.")
notes = fields.Text()
error_message = fields.Text()
def action_mark_sent(self):
self.write({'state': 'sent'})
def action_mark_failed(self, error: str = ''):
self.write({'state': 'failed', 'error_message': error})

View File

@@ -0,0 +1,60 @@
"""Cache of AI-generated follow-up text to avoid LLM cost on repeats."""
import hashlib
from odoo import _, api, fields, models
class FusionFollowupTextCache(models.Model):
_name = "fusion.followup.text.cache"
_description = "Cache of AI-generated follow-up text"
_order = "generated_at desc"
partner_id = fields.Many2one('res.partner', required=True, ondelete='cascade')
level_id = fields.Many2one('fusion.followup.level', ondelete='cascade')
company_id = fields.Many2one('res.company', required=True,
default=lambda self: self.env.company)
fingerprint = fields.Char(required=True, index=True,
help="SHA-256 of input parameters")
subject = fields.Char()
body = fields.Text()
tone_used = fields.Selection([
('gentle', 'Gentle'), ('firm', 'Firm'), ('legal', 'Legal'),
])
key_points = fields.Json()
generated_at = fields.Datetime(default=fields.Datetime.now, required=True)
expires_at = fields.Datetime()
use_count = fields.Integer(default=0)
provider = fields.Char()
@api.model
def compute_fingerprint(self, *, partner_id: int, level_id: int,
overdue_amount: float, longest_overdue_days: int,
invoice_count: int, tone: str) -> str:
"""Stable hash of the inputs that determine the generated text."""
s = f"{partner_id}|{level_id}|{round(overdue_amount, 2)}|" \
f"{longest_overdue_days}|{invoice_count}|{tone}"
return hashlib.sha256(s.encode('utf-8')).hexdigest()
@api.model
def lookup(self, *, partner_id: int, level_id: int,
overdue_amount: float, longest_overdue_days: int,
invoice_count: int, tone: str):
"""Find a cached entry matching these inputs, or empty recordset."""
fp = self.compute_fingerprint(
partner_id=partner_id, level_id=level_id,
overdue_amount=overdue_amount,
longest_overdue_days=longest_overdue_days,
invoice_count=invoice_count, tone=tone,
)
return self.search([
('partner_id', '=', partner_id),
('fingerprint', '=', fp),
], limit=1)
def action_increment_use(self):
for rec in self:
rec.use_count += 1

View File

@@ -0,0 +1,87 @@
"""Followup-specific migration step.
Backfills fusion.followup.level from Enterprise's account_followup.followup.line
records (if Enterprise account_followup is installed)."""
import logging
from odoo import api, fields, models
_logger = logging.getLogger(__name__)
class FusionMigrationWizard(models.TransientModel):
_inherit = "fusion.migration.wizard"
def _followup_bootstrap_step(self):
"""Backfill fusion.followup.level from account_followup.followup.line."""
result = {
'step': 'followup_bootstrap',
'enterprise_module_present': False,
'created': 0, 'skipped': 0, 'errors': [],
}
# Enterprise's followup model — name varies by version
EnterpriseLine = self.env.get('account_followup.followup.line')
if EnterpriseLine is None:
EnterpriseLine = self.env.get('account.followup.line')
if EnterpriseLine is None:
result['enterprise_module_present'] = False
return result
result['enterprise_module_present'] = True
FusionLevel = self.env['fusion.followup.level'].sudo()
try:
ee_records = EnterpriseLine.sudo().search([])
except Exception as e:
result['errors'].append(f"Enterprise search failed: {e}")
return result
# Pick a starting offset that doesn't clash with anything already in
# fusion_followup_level (seeded defaults at 1..3 plus any prior
# migration runs). We allocate a unique sequence per Enterprise line
# by max(existing) + 1, ensuring idempotency + within-batch uniqueness.
existing_max = max(FusionLevel.search([]).mapped('sequence') or [100])
next_seq = max(existing_max + 1, 101)
# Map Enterprise tone-ish fields to ours
for ee in ee_records:
try:
raw_seq = getattr(ee, 'sequence', None) or 50
name = getattr(ee, 'name', None) or f"Migrated Level {raw_seq}"
# Idempotency: skip if a level with same name was already
# backfilled in a prior migration run.
existing = FusionLevel.search([('name', '=', name)], limit=1)
if existing:
result['skipped'] += 1
continue
delay = getattr(ee, 'delay', None) or getattr(ee, 'delay_days', 7)
# Enterprise tone heuristic: scale by sequence
tone = 'gentle' if raw_seq <= 1 else 'firm' if raw_seq <= 2 else 'legal'
with self.env.cr.savepoint():
FusionLevel.create({
'name': name,
'sequence': next_seq,
'delay_days': delay,
'tone': tone,
'active': True,
})
next_seq += 1
result['created'] += 1
except Exception as e:
result['errors'].append(f"Line {ee.id}: {e}")
_logger.info(
"fusion_accounting_followup migration: %d created, %d skipped, %d errors",
result['created'], result['skipped'], len(result['errors']))
return result
def action_run_migration(self):
result = super().action_run_migration() if hasattr(super(), 'action_run_migration') else None
try:
self._followup_bootstrap_step()
except Exception as e:
_logger.warning("followup_bootstrap_step failed: %s", e)
return result

View File

@@ -0,0 +1,52 @@
"""Inherit res.partner: add follow-up state fields."""
from odoo import _, api, fields, models
FOLLOWUP_STATUS = [
('no_action', 'No Action Needed'),
('action_due', 'Action Due'),
('paused', 'Paused'),
('blocked', 'Blocked'),
('with_credit_team', 'With Credit Team'),
]
class ResPartner(models.Model):
_inherit = "res.partner"
fusion_followup_status = fields.Selection(
FOLLOWUP_STATUS, default='no_action', tracking=True,
help="Current follow-up status as computed by the engine.")
fusion_followup_paused_until = fields.Date(
tracking=True,
help="Pause follow-ups for this partner until this date.")
fusion_followup_last_level_id = fields.Many2one(
'fusion.followup.level',
help="The most-recent follow-up level this partner has been contacted at.")
fusion_followup_last_run_date = fields.Datetime(readonly=True)
fusion_followup_run_ids = fields.One2many(
'fusion.followup.run', 'partner_id', string='Follow-up History')
fusion_followup_run_count = fields.Integer(
compute='_compute_fusion_followup_run_count')
fusion_followup_risk_score = fields.Integer(
readonly=True, default=0,
help="Latest computed payment risk (0-100). Updated by cron.")
fusion_followup_risk_band = fields.Selection([
('low', 'Low'), ('medium', 'Medium'),
('high', 'High'), ('critical', 'Critical'),
], default='low', readonly=True)
def _compute_fusion_followup_run_count(self):
for partner in self:
partner.fusion_followup_run_count = len(partner.fusion_followup_run_ids)
def action_view_followup_history(self):
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'res_model': 'fusion.followup.run',
'view_mode': 'list,form',
'domain': [('partner_id', '=', self.id)],
'context': {'default_partner_id': self.id},
}

View File

@@ -0,0 +1,8 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_fusion_followup_level_user,fusion.followup.level.user,model_fusion_followup_level,base.group_user,1,0,0,0
access_fusion_followup_level_admin,fusion.followup.level.admin,model_fusion_followup_level,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1
access_fusion_followup_run_user,fusion.followup.run.user,model_fusion_followup_run,base.group_user,1,0,0,0
access_fusion_followup_run_admin,fusion.followup.run.admin,model_fusion_followup_run,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1
access_fusion_followup_text_cache_user,fusion.followup.text.cache.user,model_fusion_followup_text_cache,base.group_user,1,0,0,0
access_fusion_followup_text_cache_admin,fusion.followup.text.cache.admin,model_fusion_followup_text_cache,fusion_accounting_core.group_fusion_accounting_admin,1,1,1,1
access_fusion_batch_followup_wizard_user,fusion.batch.followup.wizard.user,model_fusion_batch_followup_wizard,base.group_user,1,1,1,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_fusion_followup_level_user fusion.followup.level.user model_fusion_followup_level base.group_user 1 0 0 0
3 access_fusion_followup_level_admin fusion.followup.level.admin model_fusion_followup_level fusion_accounting_core.group_fusion_accounting_admin 1 1 1 1
4 access_fusion_followup_run_user fusion.followup.run.user model_fusion_followup_run base.group_user 1 0 0 0
5 access_fusion_followup_run_admin fusion.followup.run.admin model_fusion_followup_run fusion_accounting_core.group_fusion_accounting_admin 1 1 1 1
6 access_fusion_followup_text_cache_user fusion.followup.text.cache.user model_fusion_followup_text_cache base.group_user 1 0 0 0
7 access_fusion_followup_text_cache_admin fusion.followup.text.cache.admin model_fusion_followup_text_cache fusion_accounting_core.group_fusion_accounting_admin 1 1 1 1
8 access_fusion_batch_followup_wizard_user fusion.batch.followup.wizard.user model_fusion_batch_followup_wizard base.group_user 1 1 1 0

View File

@@ -0,0 +1,6 @@
from . import overdue_aging
from . import level_resolver
from . import risk_scorer
from . import tone_selector
from . import followup_text_prompt
from . import followup_text_generator

View File

@@ -0,0 +1,123 @@
"""AI-generated follow-up text with templated fallback."""
import json
import logging
_logger = logging.getLogger(__name__)
TEMPLATES = {
'gentle': {
'subject': 'Friendly reminder: invoice payment',
'body': 'Dear {partner_name},\n\nThis is a friendly reminder that you have '
'{currency_code} {total_overdue:,.2f} outstanding on invoices that '
'are now {longest_overdue_days} days past due. We understand things '
'happen — please let us know if there is anything we can do to help '
'resolve this.\n\nBest regards.',
},
'firm': {
'subject': 'Outstanding invoices — action required',
'body': 'Dear {partner_name},\n\nOur records show {currency_code} '
'{total_overdue:,.2f} outstanding on {invoice_count} invoice(s), '
'with the longest now {longest_overdue_days} days overdue. We '
'request immediate payment to avoid further action.\n\nRegards.',
},
'legal': {
'subject': 'FINAL NOTICE — outstanding balance',
'body': 'Dear {partner_name},\n\nDespite previous reminders, '
'{currency_code} {total_overdue:,.2f} remains outstanding on your '
'account, with the longest invoice {longest_overdue_days} days '
'overdue. If full payment is not received within 7 days, we will '
'be forced to refer this matter for legal collection.\n\n'
'Regards.',
},
}
def generate_followup_text(env, *, partner_name: str, total_overdue: float,
currency_code: str, longest_overdue_days: int,
tone: str, invoice_count: int = 0,
last_payment_date: str = None,
risk_drivers: list[str] = None,
provider=None) -> dict:
"""Generate follow-up text via LLM, with templated fallback.
Returns: {subject, body, tone_used, key_points}"""
if provider is None:
provider = _get_provider(env)
if provider is None:
return _templated_fallback(
partner_name=partner_name, total_overdue=total_overdue,
currency_code=currency_code,
longest_overdue_days=longest_overdue_days,
tone=tone, invoice_count=invoice_count,
)
try:
from .followup_text_prompt import build_prompt
system, user = build_prompt(
partner_name=partner_name, total_overdue=total_overdue,
currency_code=currency_code,
longest_overdue_days=longest_overdue_days, tone=tone,
invoice_count=invoice_count, last_payment_date=last_payment_date,
risk_drivers=risk_drivers,
)
response = provider.complete(
system=system,
messages=[{'role': 'user', 'content': user}],
max_tokens=800, temperature=0.3,
)
content = response.get('content') if isinstance(response, dict) else response
parsed = json.loads(content)
for key in ('subject', 'body', 'tone_used'):
if key not in parsed:
raise ValueError(f"Missing key: {key}")
parsed.setdefault('key_points', [])
return parsed
except Exception as e:
_logger.warning("Follow-up text LLM generation failed (%s); falling back", e)
return _templated_fallback(
partner_name=partner_name, total_overdue=total_overdue,
currency_code=currency_code,
longest_overdue_days=longest_overdue_days,
tone=tone, invoice_count=invoice_count,
)
def _templated_fallback(*, partner_name, total_overdue, currency_code,
longest_overdue_days, tone, invoice_count) -> dict:
template = TEMPLATES.get(tone, TEMPLATES['gentle'])
return {
'subject': template['subject'],
'body': template['body'].format(
partner_name=partner_name, total_overdue=total_overdue,
currency_code=currency_code,
longest_overdue_days=longest_overdue_days,
invoice_count=invoice_count or 0,
),
'tone_used': tone,
'key_points': [
f"${total_overdue:,.2f} outstanding",
f"{longest_overdue_days} days overdue",
],
}
def _get_provider(env):
"""Look up provider for 'followup_text' feature."""
param = env['ir.config_parameter'].sudo()
name = param.get_param('fusion_accounting.provider.followup_text')
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

View File

@@ -0,0 +1,56 @@
"""LLM prompt for AI-generated follow-up text.
Output contract: {
"subject": str,
"body": str,
"tone_used": str,
"key_points": [str, ...]
}"""
SYSTEM_PROMPT = """You are an experienced credit collections specialist writing a
follow-up email for an unpaid invoice. Output MUST be valid JSON of this
exact shape:
{
"subject": "<email subject line>",
"body": "<plain-text or simple HTML body, no <html> wrapper>",
"tone_used": "gentle" | "firm" | "legal",
"key_points": ["<point 1>", "<point 2>", ...]
}
Tone guide:
- gentle: friendly reminder, assume oversight, propose easy paths to pay
- firm: state amount + days overdue clearly, request immediate action,
hint at consequences
- legal: formal language, reference contract obligations, mention possible
legal action / collections agency, demand payment by specific date
Always:
- Use the actual amounts and partner name from the data provided
- Don't invent contract terms or interest rates
- Don't include markdown code fences
- No prose outside the JSON
"""
def build_prompt(*, partner_name: str, total_overdue: float, currency_code: str,
longest_overdue_days: int, tone: str,
invoice_count: int = 0, last_payment_date: str = None,
risk_drivers: list[str] = None) -> tuple[str, str]:
parts = [
f"PARTNER: {partner_name}",
f"TOTAL OVERDUE: {currency_code} {total_overdue:,.2f}",
f"LONGEST OVERDUE: {longest_overdue_days} days",
f"OPEN INVOICE COUNT: {invoice_count}",
f"REQUESTED TONE: {tone}",
]
if last_payment_date:
parts.append(f"LAST PAYMENT: {last_payment_date}")
if risk_drivers:
parts.append("RISK FACTORS:")
for d in risk_drivers[:5]:
parts.append(f" - {d}")
parts.append("")
parts.append("Write the follow-up email per the system prompt.")
return (SYSTEM_PROMPT, "\n".join(parts))

View File

@@ -0,0 +1,52 @@
"""Level resolver: which follow-up level should fire for this partner?
Pure-Python: caller passes the aging report + the configured levels list,
and we pick the highest-numbered level whose threshold is met."""
from dataclasses import dataclass
@dataclass
class FollowupLevelSpec:
sequence: int
name: str
delay_days: int
tone: str
def __post_init__(self):
if self.tone not in ('gentle', 'firm', 'legal'):
raise ValueError(f"Invalid tone: {self.tone}")
def resolve_level(*, aging_report, levels: list[FollowupLevelSpec]) -> FollowupLevelSpec | None:
"""Pick the highest-sequence level whose delay_days has been crossed by
the most-overdue line in the aging report. Returns None if no overdue
lines or no levels configured."""
if not levels or not aging_report:
return None
max_days_overdue = _max_days_overdue(aging_report)
if max_days_overdue <= 0:
return None
levels_sorted = sorted(levels, key=lambda l: l.sequence, reverse=True)
for level in levels_sorted:
if level.delay_days <= max_days_overdue:
return level
return None
def _max_days_overdue(aging_report) -> int:
"""Return the actual max days-overdue tracked on the report, falling
back to the highest populated bucket's lower bound when an older
aging report (without `max_days_overdue`) is passed in."""
tracked = getattr(aging_report, 'max_days_overdue', 0) or 0
if tracked:
return tracked
max_days = 0
for b in aging_report.buckets:
if b.name == 'current' or b.amount <= 0:
continue
if b.days_max is None:
max_days = max(max_days, b.days_min)
else:
max_days = max(max_days, b.days_min)
return max_days

View File

@@ -0,0 +1,92 @@
"""Aging bucket primitives.
Pure-Python: callers pass a list of move-line dicts with `date_maturity`
and `amount_residual`; we bucket them into 0/30/60/90/120+ days overdue."""
from dataclasses import dataclass, field
from datetime import date
BUCKETS = [
('current', 0, 0),
('1_30', 1, 30),
('31_60', 31, 60),
('61_90', 61, 90),
('91_120', 91, 120),
('120_plus', 121, None),
]
@dataclass
class AgingBucket:
name: str
days_min: int
days_max: int | None
amount: float = 0.0
line_count: int = 0
@dataclass
class AgingReport:
as_of: date
buckets: list[AgingBucket] = field(default_factory=list)
total_amount: float = 0.0
total_overdue_amount: float = 0.0
line_count: int = 0
max_days_overdue: int = 0
def to_dict(self):
return {
'as_of': str(self.as_of),
'total_amount': self.total_amount,
'total_overdue_amount': self.total_overdue_amount,
'line_count': self.line_count,
'max_days_overdue': self.max_days_overdue,
'buckets': [{
'name': b.name, 'days_min': b.days_min, 'days_max': b.days_max,
'amount': b.amount, 'line_count': b.line_count,
} for b in self.buckets],
}
def compute_aging(*, move_lines: list[dict], as_of: date | None = None) -> AgingReport:
"""Bucket move-line dicts into aging brackets.
Each dict needs: date_maturity (date), amount_residual (float).
`as_of` defaults to today."""
as_of = as_of or date.today()
report = AgingReport(as_of=as_of)
for name, days_min, days_max in BUCKETS:
report.buckets.append(AgingBucket(name=name, days_min=days_min, days_max=days_max))
for ml in move_lines:
maturity = ml.get('date_maturity')
amount = ml.get('amount_residual', 0.0)
if not maturity:
continue
days_overdue = (as_of - maturity).days
bucket = _find_bucket(report.buckets, days_overdue)
if bucket:
bucket.amount += amount
bucket.line_count += 1
report.total_amount += amount
if days_overdue > 0:
report.total_overdue_amount += amount
if days_overdue > report.max_days_overdue:
report.max_days_overdue = days_overdue
report.line_count += 1
return report
def _find_bucket(buckets: list[AgingBucket], days_overdue: int) -> AgingBucket | None:
if days_overdue <= 0:
return next((b for b in buckets if b.name == 'current'), None)
for b in buckets:
if b.name == 'current':
continue
if b.days_max is None and days_overdue >= b.days_min:
return b
if b.days_max is not None and b.days_min <= days_overdue <= b.days_max:
return b
return None

View File

@@ -0,0 +1,62 @@
"""Payment-history risk scorer.
Pure-Python: takes payment history (list of payment events) + average days-late
and returns a risk score 0-100. Higher = more risky."""
from dataclasses import dataclass
@dataclass
class PartnerRiskScore:
score: int
band: str
drivers: list[str]
def score_partner(*, total_invoices: int = 0, paid_late_count: int = 0,
avg_days_late: float = 0.0,
longest_overdue_days: int = 0,
open_overdue_amount: float = 0.0,
average_invoice_amount: float = 1000.0) -> PartnerRiskScore:
"""Compute a 0-100 risk score from payment-history primitives.
Heuristic weights:
- 30% : late-payment ratio (paid_late_count / total_invoices)
- 25% : avg days late (capped at 60 days)
- 25% : longest current overdue (capped at 120 days)
- 20% : open overdue amount as multiple of average invoice
"""
drivers: list[str] = []
score = 0.0
if total_invoices > 0:
late_ratio = paid_late_count / total_invoices
score += min(late_ratio * 100, 100) * 0.30
if late_ratio > 0.5:
drivers.append(f"{paid_late_count}/{total_invoices} invoices paid late")
score += min(avg_days_late / 60, 1) * 100 * 0.25
if avg_days_late > 14:
drivers.append(f"Avg {avg_days_late:.1f} days late on payment")
score += min(longest_overdue_days / 120, 1) * 100 * 0.25
if longest_overdue_days > 30:
drivers.append(f"Longest currently overdue: {longest_overdue_days} days")
if average_invoice_amount > 0:
ratio = open_overdue_amount / average_invoice_amount
score += min(ratio / 5, 1) * 100 * 0.20
if ratio > 1.5:
drivers.append(f"Open overdue ${open_overdue_amount:,.2f} ({ratio:.1f}x avg invoice)")
final = int(round(score))
if final >= 80:
band = 'critical'
elif final >= 60:
band = 'high'
elif final >= 30:
band = 'medium'
else:
band = 'low'
return PartnerRiskScore(score=final, band=band, drivers=drivers)

View File

@@ -0,0 +1,18 @@
"""Tone selector: pick gentle/firm/legal based on follow-up level + risk score."""
TONE_BY_LEVEL = {
1: 'gentle',
2: 'firm',
3: 'legal',
4: 'legal',
}
def select_tone(*, level_sequence: int, risk_score: int = 0) -> str:
"""Default tone follows level sequence; high risk can escalate."""
base_tone = TONE_BY_LEVEL.get(level_sequence, 'gentle')
if risk_score >= 80 and base_tone == 'gentle':
return 'firm'
if risk_score >= 90 and base_tone == 'firm':
return 'legal'
return base_tone

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View File

@@ -0,0 +1,15 @@
/** @odoo-module **/
import { Component } from "@odoo/owl";
export class AgingBucketStrip extends Component {
static template = "fusion_accounting_followup.AgingBucketStrip";
static props = {
aging: { type: Object },
};
bucketWidth(bucket) {
const total = this.props.aging.total_amount || 1;
return ((bucket.amount / total) * 100).toFixed(2) + "%";
}
}

View File

@@ -0,0 +1,22 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting_followup.AgingBucketStrip">
<div class="mt-2">
<div class="fu-aging-strip">
<div t-foreach="props.aging.buckets" t-as="b" t-key="b.name"
class="bucket" t-att-data-name="b.name"
t-att-style="'width: ' + bucketWidth(b)"
t-att-title="b.name + ': $' + (b.amount or 0).toFixed(2)"/>
</div>
<div class="d-flex justify-content-between text-muted" style="font-size: 0.7rem;">
<span>Current</span>
<span>30</span>
<span>60</span>
<span>90</span>
<span>120+</span>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,10 @@
/** @odoo-module **/
import { Component } from "@odoo/owl";
export class AiTextPanel extends Component {
static template = "fusion_accounting_followup.AiTextPanel";
static props = {
text: { type: Object },
};
}

View File

@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting_followup.AiTextPanel">
<div class="fu-ai-text-panel mt-3">
<h5>AI-Generated Follow-up Text</h5>
<div class="ai-subject">
Subject: <t t-esc="props.text.subject"/>
</div>
<div class="ai-body">
<t t-esc="props.text.body"/>
</div>
<div class="key-points" t-if="props.text.key_points and props.text.key_points.length">
<strong>Key points:</strong>
<ul>
<li t-foreach="props.text.key_points" t-as="point" t-key="point_index">
<t t-esc="point"/>
</li>
</ul>
</div>
<div class="text-muted mt-2" style="font-size: 0.75rem;">
Tone used: <t t-esc="props.text.tone_used or props.text.tone or 'gentle'"/>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,15 @@
/** @odoo-module **/
import { Component } from "@odoo/owl";
export class FollowupHistoryTable extends Component {
static template = "fusion_accounting_followup.FollowupHistoryTable";
static props = {
history: { type: Object },
};
formatDate(s) {
if (!s) return "";
return s.slice(0, 10);
}
}

Some files were not shown because too many files have changed in this diff Show More