Compare commits

..

34 Commits

Author SHA1 Message Date
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
77 changed files with 4147 additions and 9 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

@@ -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,5 @@
from . import models
from . import services
from . import controllers
from . import wizards
from . import reports

View File

@@ -0,0 +1,68 @@
{
'name': 'Fusion Accounting Follow-up',
'version': '19.0.1.0.28',
'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',
],
},
'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);
}
}

View File

@@ -0,0 +1,33 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting_followup.FollowupHistoryTable">
<div class="mt-4">
<h5>Follow-up History (<t t-esc="props.history.count or 0"/>)</h5>
<table t-if="props.history.runs and props.history.runs.length" class="fu-history-table">
<thead>
<tr>
<th>Date</th>
<th>Level</th>
<th>Tone</th>
<th>State</th>
<th class="text-end">Overdue</th>
</tr>
</thead>
<tbody>
<tr t-foreach="props.history.runs" t-as="run" t-key="run.id">
<td><t t-esc="formatDate(run.date)"/></td>
<td><t t-esc="run.level_name or '-'"/></td>
<td><t t-esc="run.tone_used or '-'"/></td>
<td><t t-esc="run.state"/></td>
<td class="text-end">
<t t-if="run.overdue_amount">$<t t-esc="run.overdue_amount.toFixed(2)"/></t>
</td>
</tr>
</tbody>
</table>
<div t-else="" class="text-muted">No history yet.</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,15 @@
/** @odoo-module **/
import { Component } from "@odoo/owl";
import { RiskBadge } from "../risk_badge/risk_badge";
export class PartnerCard extends Component {
static template = "fusion_accounting_followup.PartnerCard";
static props = {
partner: { type: Object },
selected: { type: Boolean, optional: true },
onSelect: { type: Function },
formatCurrency: { type: Function },
};
static components = { RiskBadge };
}

View File

@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting_followup.PartnerCard">
<div class="o_fusion_followup_card"
t-att-class="props.selected ? 'selected' : ''"
t-on-click="props.onSelect">
<div class="o_fusion_followup_card_header">
<div class="partner-name"><t t-esc="props.partner.partner_name"/></div>
<div>
<span class="fu-status-badge" t-att-data-status="props.partner.status">
<t t-esc="props.partner.status"/>
</span>
</div>
</div>
<div class="partner-numbers">
<div>
<span class="label">Overdue:</span>
<span class="value">$<t t-esc="props.formatCurrency(props.partner.overdue_amount)"/></span>
</div>
<div>
<span class="label">Lines:</span>
<span class="value"><t t-esc="props.partner.overdue_line_count or 0"/></span>
</div>
<div>
<span class="label">Risk:</span>
<RiskBadge band="props.partner.risk_band" score="props.partner.risk_score"/>
</div>
<div t-if="props.partner.last_level_name">
<span class="label">Last:</span>
<span class="value"><t t-esc="props.partner.last_level_name"/></span>
</div>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,11 @@
/** @odoo-module **/
import { Component } from "@odoo/owl";
export class RiskBadge extends Component {
static template = "fusion_accounting_followup.RiskBadge";
static props = {
band: { type: String, optional: true },
score: { type: Number, optional: true },
};
}

View File

@@ -0,0 +1,11 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting_followup.RiskBadge">
<span class="fu-risk-badge" t-att-data-band="props.band || 'low'">
<t t-esc="props.band || 'low'"/>
<t t-if="props.score !== undefined"> (<t t-esc="props.score"/>)</t>
</span>
</t>
</templates>

View File

@@ -0,0 +1,51 @@
// Fusion follow-up design tokens (extends Phases 1-3 tokens for consistency).
$fu-bg-primary: #ffffff;
$fu-bg-secondary: #f9fafb;
$fu-bg-tertiary: #f3f4f6;
$fu-border: #e5e7eb;
$fu-text-primary: #111827;
$fu-text-secondary: #6b7280;
$fu-text-muted: #9ca3af;
$fu-accent: #3b82f6;
$fu-accent-bg: #eff6ff;
// Status colors
$fu-status-no-action: #6b7280;
$fu-status-action-due: #f59e0b;
$fu-status-paused: #6366f1;
$fu-status-blocked: #ef4444;
$fu-status-with-credit: #8b5cf6;
// Risk band colors
$fu-risk-low: #10b981;
$fu-risk-low-bg: #ecfdf5;
$fu-risk-medium: #f59e0b;
$fu-risk-medium-bg: #fffbeb;
$fu-risk-high: #ef4444;
$fu-risk-high-bg: #fef2f2;
$fu-risk-critical: #b91c1c;
$fu-risk-critical-bg: #fef2f2;
// Aging bucket colors (escalating intensity)
$fu-bucket-current: #10b981;
$fu-bucket-1-30: #fbbf24;
$fu-bucket-31-60: #f59e0b;
$fu-bucket-61-90: #ef4444;
$fu-bucket-91-120: #dc2626;
$fu-bucket-120-plus: #7f1d1d;
$fu-space-1: 0.25rem;
$fu-space-2: 0.5rem;
$fu-space-3: 0.75rem;
$fu-space-4: 1rem;
$fu-space-6: 1.5rem;
$fu-font-size-xs: 0.75rem;
$fu-font-size-sm: 0.875rem;
$fu-font-size-base: 1rem;
$fu-font-size-lg: 1.125rem;
$fu-font-size-xl: 1.25rem;
$fu-border-radius: 0.375rem;
$fu-border-radius-md: 0.5rem;

View File

@@ -0,0 +1,27 @@
// Variables come from _variables.scss (loaded first in the asset bundle).
[data-color-scheme="dark"] .o_fusion_followup {
background: #1f2937; color: #f9fafb;
&_header, &_card, .fu-ai-text-panel {
background: #111827; border-color: #374151; color: #f9fafb;
}
&_card {
&:hover { border-color: #60a5fa; }
&.selected { background: #1e3a8a; border-color: #60a5fa; }
.partner-numbers .label { color: #9ca3af; }
.partner-numbers .value { color: #f9fafb; }
}
.btn_fu {
background: #374151; border-color: #4b5563; color: #f9fafb;
&:hover { background: #4b5563; }
&.primary { background: #3b82f6; }
}
.fu-ai-text-panel {
.ai-subject { background: #1e3a8a; }
.ai-body { background: #1f2937; }
}
}

View File

@@ -0,0 +1,190 @@
// Variables come from _variables.scss (loaded first in the asset bundle).
.o_fusion_followup {
background: $fu-bg-secondary;
min-height: 100vh;
&_header {
background: $fu-bg-primary;
border-bottom: 1px solid $fu-border;
padding: $fu-space-4 $fu-space-6;
display: flex;
justify-content: space-between;
align-items: center;
h1 { font-size: $fu-font-size-xl; margin: 0; }
.summary {
display: flex;
gap: $fu-space-6;
font-size: $fu-font-size-sm;
color: $fu-text-secondary;
.summary-value {
font-weight: 600;
color: $fu-text-primary;
margin-left: $fu-space-1;
}
}
}
&_card {
background: $fu-bg-primary;
border: 1px solid $fu-border;
border-radius: $fu-border-radius-md;
padding: $fu-space-4;
margin-bottom: $fu-space-3;
cursor: pointer;
transition: all 200ms ease-in-out;
&:hover {
border-color: $fu-accent;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
}
&.selected {
border-color: $fu-accent;
background: $fu-accent-bg;
}
&_header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: $fu-space-2;
.partner-name {
font-weight: 600;
font-size: $fu-font-size-base;
}
}
.partner-numbers {
display: grid;
grid-template-columns: 1fr 1fr;
gap: $fu-space-2;
font-size: $fu-font-size-sm;
color: $fu-text-secondary;
.label { font-weight: 500; margin-right: $fu-space-2; }
.value { color: $fu-text-primary; font-weight: 500; }
}
}
.btn_fu {
padding: $fu-space-2 $fu-space-4;
border-radius: $fu-border-radius;
background: $fu-bg-primary;
border: 1px solid $fu-border;
color: $fu-text-primary;
font-size: $fu-font-size-sm;
cursor: pointer;
&:hover { background: $fu-bg-tertiary; }
&.primary { background: $fu-accent; border-color: $fu-accent; color: white;
&:hover { background: darken($fu-accent, 8%); } }
&.danger { background: $fu-status-blocked; border-color: $fu-status-blocked; color: white; }
}
}
.fu-status-badge {
padding: $fu-space-1 $fu-space-2;
border-radius: $fu-border-radius;
font-size: $fu-font-size-xs;
font-weight: 500;
text-transform: uppercase;
&[data-status="no_action"] { background: lighten($fu-status-no-action, 40%); color: $fu-status-no-action; }
&[data-status="action_due"] { background: lighten($fu-status-action-due, 35%); color: $fu-status-action-due; }
&[data-status="paused"] { background: lighten($fu-status-paused, 35%); color: $fu-status-paused; }
&[data-status="blocked"] { background: lighten($fu-status-blocked, 35%); color: $fu-status-blocked; }
&[data-status="with_credit_team"] { background: lighten($fu-status-with-credit, 35%); color: $fu-status-with-credit; }
}
.fu-risk-badge {
display: inline-flex;
align-items: center;
padding: $fu-space-1 $fu-space-2;
border-radius: $fu-border-radius;
font-weight: 600;
font-size: $fu-font-size-xs;
&[data-band="low"] { background: $fu-risk-low-bg; color: $fu-risk-low; }
&[data-band="medium"] { background: $fu-risk-medium-bg; color: $fu-risk-medium; }
&[data-band="high"] { background: $fu-risk-high-bg; color: $fu-risk-high; }
&[data-band="critical"] { background: $fu-risk-critical-bg; color: $fu-risk-critical; font-weight: 700; }
}
.fu-aging-strip {
display: flex;
gap: 2px;
height: 8px;
border-radius: $fu-border-radius;
overflow: hidden;
margin: $fu-space-2 0;
.bucket {
height: 100%;
&[data-name="current"] { background: $fu-bucket-current; }
&[data-name="1_30"] { background: $fu-bucket-1-30; }
&[data-name="31_60"] { background: $fu-bucket-31-60; }
&[data-name="61_90"] { background: $fu-bucket-61-90; }
&[data-name="91_120"] { background: $fu-bucket-91-120; }
&[data-name="120_plus"] { background: $fu-bucket-120-plus; }
}
}
.fu-ai-text-panel {
background: $fu-bg-primary;
border: 1px solid $fu-border;
border-radius: $fu-border-radius-md;
padding: $fu-space-4;
h5 { margin: 0 0 $fu-space-2; font-size: $fu-font-size-base; }
.ai-subject {
font-weight: 600;
margin-bottom: $fu-space-2;
padding: $fu-space-2;
background: $fu-accent-bg;
border-radius: $fu-border-radius;
}
.ai-body {
white-space: pre-wrap;
font-family: monospace;
font-size: $fu-font-size-sm;
padding: $fu-space-3;
background: $fu-bg-secondary;
border-radius: $fu-border-radius;
max-height: 300px;
overflow-y: auto;
}
.key-points {
margin-top: $fu-space-3;
font-size: $fu-font-size-sm;
color: $fu-text-secondary;
ul { margin: 0; padding-left: $fu-space-4; }
}
}
.fu-history-table {
width: 100%;
font-size: $fu-font-size-sm;
th {
background: $fu-bg-tertiary;
padding: $fu-space-2 $fu-space-3;
text-align: left;
font-weight: 600;
color: $fu-text-secondary;
}
td {
padding: $fu-space-2 $fu-space-3;
border-bottom: 1px solid lighten($fu-border, 5%);
}
}

View File

@@ -0,0 +1,145 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { reactive } from "@odoo/owl";
const ENDPOINT_BASE = "/fusion/followup";
export class FollowupService {
constructor(env, services) {
this.env = env;
this.rpc = services.rpc;
this.notification = services.notification;
this.state = reactive({
partners: [],
count: 0,
total: 0,
statusFilter: null,
isLoading: false,
isProcessing: false,
selectedPartnerId: null,
selectedDetail: null,
companyId: null,
limit: 50,
offset: 0,
generatedText: null,
});
}
async loadOverdue(companyId = null) {
this.state.companyId = companyId;
this.state.isLoading = true;
try {
const result = await this.rpc(`${ENDPOINT_BASE}/list_overdue`, {
status: this.state.statusFilter,
limit: this.state.limit,
offset: this.state.offset,
company_id: companyId,
});
this.state.partners = result.partners;
this.state.count = result.count;
this.state.total = result.total;
} finally {
this.state.isLoading = false;
}
}
async selectPartner(partnerId) {
this.state.selectedPartnerId = partnerId;
this.state.selectedDetail = null;
this.state.generatedText = null;
try {
this.state.selectedDetail = await this.rpc(`${ENDPOINT_BASE}/get_partner_detail`, {
partner_id: partnerId,
});
} catch (err) {
this.notification.add(`Failed to load partner detail: ${err.message || err}`, { type: "danger" });
}
}
async generateText(partnerId, levelId = null, forceRegenerate = false) {
this.state.isProcessing = true;
try {
this.state.generatedText = await this.rpc(`${ENDPOINT_BASE}/generate_text`, {
partner_id: partnerId, level_id: levelId,
force_regenerate: forceRegenerate,
});
return this.state.generatedText;
} catch (err) {
this.notification.add(`Generate failed: ${err.message || err}`, { type: "danger" });
throw err;
} finally {
this.state.isProcessing = false;
}
}
async sendFollowup(partnerId, levelId = null, force = false) {
this.state.isProcessing = true;
try {
const result = await this.rpc(`${ENDPOINT_BASE}/send`, {
partner_id: partnerId, level_id: levelId, force: force,
});
const status = result.status || "unknown";
const type = status === "sent" ? "success" : status.startsWith("paused") ? "warning" : "info";
this.notification.add(`Send result: ${status}`, { type: type });
if (this.state.selectedPartnerId === partnerId) {
await this.selectPartner(partnerId);
}
await this.loadOverdue(this.state.companyId);
return result;
} catch (err) {
this.notification.add(`Send failed: ${err.message || err}`, { type: "danger" });
throw err;
} finally {
this.state.isProcessing = false;
}
}
async pausePartner(partnerId, untilDate = null) {
try {
const result = await this.rpc(`${ENDPOINT_BASE}/pause`, {
partner_id: partnerId, until_date: untilDate,
});
this.notification.add(`Paused until ${result.paused_until}`, { type: "info" });
if (this.state.selectedPartnerId === partnerId) {
await this.selectPartner(partnerId);
}
await this.loadOverdue(this.state.companyId);
return result;
} catch (err) {
this.notification.add(`Pause failed: ${err.message || err}`, { type: "danger" });
throw err;
}
}
async resetPartner(partnerId) {
try {
const result = await this.rpc(`${ENDPOINT_BASE}/reset`, {
partner_id: partnerId,
});
this.notification.add(`Reset`, { type: "info" });
if (this.state.selectedPartnerId === partnerId) {
await this.selectPartner(partnerId);
}
await this.loadOverdue(this.state.companyId);
return result;
} catch (err) {
this.notification.add(`Reset failed: ${err.message || err}`, { type: "danger" });
throw err;
}
}
setStatusFilter(status) {
this.state.statusFilter = status;
this.state.offset = 0;
this.loadOverdue(this.state.companyId);
}
}
export const followupService = {
dependencies: ["rpc", "notification"],
start(env, services) { return new FollowupService(env, services); },
};
registry.category("services").add("fusion_followup", followupService);

View File

@@ -0,0 +1,69 @@
/** @odoo-module **/
import { Component, useState, onWillStart } from "@odoo/owl";
import { useService } from "@web/core/utils/hooks";
import { PartnerCard } from "../../components/partner_card/partner_card";
import { AgingBucketStrip } from "../../components/aging_bucket_strip/aging_bucket_strip";
import { RiskBadge } from "../../components/risk_badge/risk_badge";
import { AiTextPanel } from "../../components/ai_text_panel/ai_text_panel";
import { FollowupHistoryTable } from "../../components/followup_history_table/followup_history_table";
export class FollowupDashboard extends Component {
static template = "fusion_accounting_followup.FollowupDashboard";
static props = { "*": true };
static components = { PartnerCard, AgingBucketStrip, RiskBadge, AiTextPanel, FollowupHistoryTable };
setup() {
this.followup = useService("fusion_followup");
this.state = useState(this.followup.state);
const companyId = this.env.services.user?.context?.allowed_company_ids?.[0];
onWillStart(async () => {
await this.followup.loadOverdue(companyId);
});
}
onSelectPartner(partnerId) {
this.followup.selectPartner(partnerId);
}
onStatusFilter(status) {
this.followup.setStatusFilter(status || null);
}
async onGenerateText() {
if (!this.state.selectedPartnerId) return;
await this.followup.generateText(this.state.selectedPartnerId);
}
async onSend() {
if (!this.state.selectedPartnerId) return;
await this.followup.sendFollowup(this.state.selectedPartnerId, null, true);
}
async onPause() {
if (!this.state.selectedPartnerId) return;
const days = parseInt(prompt("Pause for how many days?", "30"));
if (isNaN(days)) return;
const until = new Date();
until.setDate(until.getDate() + days);
await this.followup.pausePartner(
this.state.selectedPartnerId, until.toISOString().slice(0, 10));
}
async onReset() {
if (!this.state.selectedPartnerId) return;
await this.followup.resetPartner(this.state.selectedPartnerId);
}
formatCurrency(amount) {
return new Intl.NumberFormat(undefined, {
minimumFractionDigits: 2, maximumFractionDigits: 2,
}).format(amount || 0);
}
get totalOverdue() {
return this.state.partners.reduce((sum, p) => sum + (p.overdue_amount || 0), 0);
}
}

View File

@@ -0,0 +1,65 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_accounting_followup.FollowupDashboard">
<div class="o_fusion_followup">
<div class="o_fusion_followup_header">
<div>
<h1>Customer Follow-ups</h1>
<div class="text-muted"><t t-esc="state.count"/> of <t t-esc="state.total"/> partners with overdue</div>
</div>
<div class="summary">
<div>Total overdue: <span class="summary-value">$<t t-esc="formatCurrency(totalOverdue)"/></span></div>
</div>
</div>
<div class="d-flex" style="gap: 0.5rem; padding: 0.75rem;">
<button class="btn_fu" t-on-click="() => this.onStatusFilter(null)"
t-att-class="state.statusFilter === null ? 'primary' : ''">All</button>
<button class="btn_fu" t-on-click="() => this.onStatusFilter('action_due')"
t-att-class="state.statusFilter === 'action_due' ? 'primary' : ''">Action Due</button>
<button class="btn_fu" t-on-click="() => this.onStatusFilter('paused')"
t-att-class="state.statusFilter === 'paused' ? 'primary' : ''">Paused</button>
<button class="btn_fu" t-on-click="() => this.onStatusFilter('blocked')"
t-att-class="state.statusFilter === 'blocked' ? 'primary' : ''">Blocked</button>
</div>
<div class="d-flex" style="gap: 1rem; padding: 1rem;">
<div style="flex: 1 1 50%;">
<div t-if="state.isLoading" class="text-center p-4 text-muted">Loading...</div>
<div t-elif="state.partners.length === 0" class="text-center p-4 text-muted">No overdue partners.</div>
<div t-else="">
<PartnerCard t-foreach="state.partners" t-as="partner" t-key="partner.partner_id"
partner="partner" selected="state.selectedPartnerId === partner.partner_id"
onSelect="() => this.onSelectPartner(partner.partner_id)"
formatCurrency="formatCurrency.bind(this)"/>
</div>
</div>
<div style="flex: 1 1 50%;">
<div t-if="state.selectedDetail">
<h3><t t-esc="state.selectedDetail.partner.name"/></h3>
<div class="text-muted">
<t t-if="state.selectedDetail.partner.email"><t t-esc="state.selectedDetail.partner.email"/></t>
</div>
<div class="mt-2">
<RiskBadge band="state.selectedDetail.partner.risk_band"
score="state.selectedDetail.partner.risk_score"/>
</div>
<AgingBucketStrip aging="state.selectedDetail.overdue.aging"/>
<div class="d-flex mt-3" style="gap: 0.5rem; flex-wrap: wrap;">
<button class="btn_fu" t-on-click="onGenerateText">Generate Text</button>
<button class="btn_fu primary" t-on-click="onSend">Send Now</button>
<button class="btn_fu" t-on-click="onPause">Pause</button>
<button class="btn_fu" t-on-click="onReset">Reset</button>
</div>
<AiTextPanel t-if="state.generatedText" text="state.generatedText"/>
<FollowupHistoryTable t-if="state.selectedDetail.history"
history="state.selectedDetail.history"/>
</div>
<div t-else="" class="p-4 text-muted">Select a partner.</div>
</div>
</div>
</div>
</t>
</templates>

View File

@@ -0,0 +1,14 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { FollowupDashboard } from "./followup_dashboard";
export const fusionFollowupDashboardView = {
type: "fusion_followup",
Controller: FollowupDashboard,
display_name: "Fusion Customer Follow-ups",
icon: "fa-bell",
multiRecord: true,
};
registry.category("views").add("fusion_followup", fusionFollowupDashboardView);

View File

@@ -0,0 +1,22 @@
from . import test_overdue_aging
from . import test_level_resolver
from . import test_risk_scorer
from . import test_tone_selector
from . import test_followup_text_generator
from . import test_fusion_followup_level
from . import test_fusion_followup_run
from . import test_fusion_followup_text_cache
from . import test_res_partner_inherit
from . import test_account_move_line_inherit
from . import test_fusion_followup_engine
from . import test_engine_integration
from . import test_followup_controller
from . import test_followup_adapter
from . import test_followup_tools
from . import test_followup_cron
from . import test_engine_property
from . import test_followup_full_flow
from . import test_performance_benchmarks
from . import test_batch_followup_wizard
from . import test_migration_round_trip
from . import test_coexistence

View File

@@ -0,0 +1,34 @@
from odoo import fields as odoo_fields
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestAccountMoveLineFollowup(TransactionCase):
"""Verify follow-up tracking fields are added to account.move.line."""
def test_fields_exist_on_model(self):
"""Both new fields are declared on account.move.line."""
AML = self.env['account.move.line']
self.assertIn('fusion_followup_level_id', AML._fields)
self.assertIn('fusion_followup_last_run_date', AML._fields)
self.assertEqual(
AML._fields['fusion_followup_level_id'].comodel_name,
'fusion.followup.level',
)
def test_assign_level_and_date_on_existing_line(self):
"""We can write the new fields onto an existing move line."""
line = self.env['account.move.line'].search([], limit=1)
if not line:
self.skipTest("No account.move.line records present in DB to test against.")
level = self.env['fusion.followup.level'].create({
'name': 'Reminder', 'sequence': 601, 'delay_days': 7, 'tone': 'gentle',
})
when = odoo_fields.Datetime.now()
line.write({
'fusion_followup_level_id': level.id,
'fusion_followup_last_run_date': when,
})
self.assertEqual(line.fusion_followup_level_id, level)
self.assertEqual(line.fusion_followup_last_run_date, when)

View File

@@ -0,0 +1,37 @@
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
from odoo.exceptions import UserError
@tagged('post_install', '-at_install')
class TestBatchFollowupWizard(TransactionCase):
def test_default_loads_active_ids(self):
partners = self.env['res.partner'].create([
{'name': 'B1'}, {'name': 'B2'},
])
wizard = self.env['fusion.batch.followup.wizard'].with_context(
active_model='res.partner', active_ids=partners.ids,
).create({})
self.assertEqual(set(wizard.partner_ids.ids), set(partners.ids))
def test_selected_scope_no_partners_raises(self):
wizard = self.env['fusion.batch.followup.wizard'].create({
'scope': 'selected', 'partner_ids': [(6, 0, [])],
})
with self.assertRaises(UserError):
wizard.action_run()
def test_run_completes_with_no_overdue_partners(self):
partners = self.env['res.partner'].create([
{'name': 'NoOverdue1'}, {'name': 'NoOverdue2'},
])
wizard = self.env['fusion.batch.followup.wizard'].create({
'scope': 'selected',
'partner_ids': [(6, 0, partners.ids)],
'force': True,
})
wizard.action_run()
self.assertEqual(wizard.state, 'done')
# 2 partners with no overdue → both skipped
self.assertEqual(wizard.skipped_count, 2)

View File

@@ -0,0 +1,37 @@
"""Coexistence tests: fusion_accounting_followup menu only visible when
Enterprise account_followup is NOT installed."""
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestFollowupCoexistence(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):
self.assertIn('fusion.followup.engine', self.env.registry)
def test_menu_gated_by_coexistence_group(self):
menu = self.env.ref('fusion_accounting_followup.menu_fusion_followup_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,
"Followup root menu must require the coexistence group")
def test_levels_menu_gated(self):
menu = self.env.ref('fusion_accounting_followup.menu_fusion_followup_levels',
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,76 @@
"""Integration tests: full follow-up flow with real overdue invoices."""
from datetime import date, timedelta
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
@tagged('post_install', '-at_install', 'integration')
class TestFollowupEngineIntegration(TransactionCase):
def setUp(self):
super().setUp()
self.engine = self.env['fusion.followup.engine']
self.partner = self.env['res.partner'].create({
'name': 'Integration Partner', 'email': 'integ@test.local',
})
for seq, name, days, tone in [(801, 'Test Reminder', 7, 'gentle'),
(802, 'Test Warning', 30, 'firm'),
(803, 'Test Legal', 60, 'legal')]:
self.env['fusion.followup.level'].create({
'name': name, 'sequence': seq, 'delay_days': days, 'tone': tone,
})
line = self.env['account.move.line'].search([
('parent_state', '=', 'posted'),
('account_id.account_type', '=', 'asset_receivable'),
('reconciled', '=', False),
('amount_residual', '>', 0),
], limit=1)
if not line:
self.skipTest("No posted unreconciled receivable lines in test DB")
line.write({
'partner_id': self.partner.id,
'date_maturity': date.today() - timedelta(days=20),
})
def test_get_overdue_finds_lines(self):
result = self.engine.get_overdue_for_partner(self.partner)
self.assertGreater(result['overdue_line_count'], 0)
self.assertGreater(result['aging']['total_overdue_amount'], 0)
def test_compute_level_picks_reminder_at_20_days(self):
level = self.engine.compute_followup_level(self.partner)
self.assertTrue(level)
self.assertGreater(level.delay_days, 0)
def test_send_followup_creates_run(self):
result = self.engine.send_followup_email(self.partner, force=True)
self.assertIn(result['status'], ('sent', 'manual_review'))
if 'run_id' in result:
run = self.env['fusion.followup.run'].browse(result['run_id'])
self.assertEqual(run.partner_id, self.partner)
def test_pause_blocks_send_unless_force(self):
self.engine.pause_followup(self.partner,
until_date=date.today() + timedelta(days=30))
result = self.engine.send_followup_email(self.partner)
self.assertTrue(result['status'].startswith('paused'))
result_force = self.engine.send_followup_email(self.partner, force=True)
self.assertIn(result_force['status'], ('sent', 'manual_review'))
def test_history_grows_with_each_send(self):
Run = self.env['fusion.followup.run']
before = Run.search_count([('partner_id', '=', self.partner.id)])
self.engine.send_followup_email(self.partner, force=True)
after = Run.search_count([('partner_id', '=', self.partner.id)])
self.assertGreater(after, before)
def test_text_cache_used_on_repeat_call(self):
Cache = self.env['fusion.followup.text.cache']
self.engine.send_followup_email(self.partner, force=True)
cache_count_after_first = Cache.search_count([('partner_id', '=', self.partner.id)])
self.engine.send_followup_email(self.partner, force=True)
cache_count_after_second = Cache.search_count([('partner_id', '=', self.partner.id)])
self.assertEqual(cache_count_after_first, cache_count_after_second,
"Repeat send with same params should not create new cache row")

View File

@@ -0,0 +1,92 @@
"""Property-based invariants for follow-up services."""
from datetime import date, timedelta
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_followup.services.overdue_aging import (
compute_aging, BUCKETS,
)
from odoo.addons.fusion_accounting_followup.services.risk_scorer import score_partner
from odoo.addons.fusion_accounting_followup.services.tone_selector import select_tone
@tagged('post_install', '-at_install', 'property_based')
class TestAgingInvariants(TransactionCase):
@given(
as_of=st.dates(min_value=date(2020, 1, 1), max_value=date(2030, 12, 31)),
amounts=st.lists(
st.tuples(
st.integers(min_value=-180, max_value=180),
st.floats(min_value=0.01, max_value=100000,
allow_nan=False, allow_infinity=False),
),
min_size=0, max_size=20,
),
)
@settings(max_examples=80, deadline=2000,
suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_buckets_sum_equals_total(self, as_of, amounts):
lines = [
{'date_maturity': as_of + timedelta(days=offset),
'amount_residual': round(amt, 2)}
for offset, amt in amounts
]
report = compute_aging(move_lines=lines, as_of=as_of)
bucket_sum = sum(b.amount for b in report.buckets)
self.assertAlmostEqual(bucket_sum, report.total_amount, places=1)
@given(
as_of=st.dates(min_value=date(2020, 1, 1), max_value=date(2030, 12, 31)),
days_overdue=st.integers(min_value=1, max_value=365),
amount=st.floats(min_value=0.01, max_value=10000,
allow_nan=False, allow_infinity=False),
)
@settings(max_examples=50, deadline=2000,
suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_overdue_amount_excludes_current(self, as_of, days_overdue, amount):
lines = [
{'date_maturity': as_of - timedelta(days=days_overdue),
'amount_residual': round(amount, 2)},
{'date_maturity': as_of + timedelta(days=10),
'amount_residual': 100.0},
]
report = compute_aging(move_lines=lines, as_of=as_of)
self.assertAlmostEqual(report.total_overdue_amount, round(amount, 2), places=1)
@given(
invoices=st.integers(min_value=0, max_value=100),
late=st.integers(min_value=0, max_value=100),
days_late=st.floats(min_value=0, max_value=180,
allow_nan=False, allow_infinity=False),
)
@settings(max_examples=80, deadline=2000,
suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_risk_score_in_range(self, invoices, late, days_late):
late = min(late, invoices) if invoices > 0 else 0
result = score_partner(
total_invoices=invoices, paid_late_count=late,
avg_days_late=days_late,
longest_overdue_days=int(days_late),
open_overdue_amount=invoices * 1000.0,
average_invoice_amount=1000.0,
)
self.assertGreaterEqual(result.score, 0)
self.assertLessEqual(result.score, 100)
@tagged('post_install', '-at_install', 'property_based')
class TestToneInvariants(TransactionCase):
@given(
sequence=st.integers(min_value=1, max_value=10),
risk=st.integers(min_value=0, max_value=100),
)
@settings(max_examples=50, deadline=1000,
suppress_health_check=[HealthCheck.function_scoped_fixture])
def test_tone_always_in_valid_set(self, sequence, risk):
tone = select_tone(level_sequence=sequence, risk_score=risk)
self.assertIn(tone, ('gentle', 'firm', 'legal'))

View File

@@ -0,0 +1,42 @@
"""FollowupAdapter wiring tests — engine paths."""
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
from odoo.addons.fusion_accounting_ai.services.data_adapters.followup import (
FollowupAdapter,
)
@tagged('post_install', '-at_install')
class TestFollowupAdapter(TransactionCase):
def setUp(self):
super().setUp()
self.adapter = FollowupAdapter(self.env)
def test_list_overdue_via_fusion_returns_dict(self):
result = self.adapter.list_overdue_via_fusion(
company_id=self.env.company.id,
)
self.assertIn('partners', result)
self.assertIn('total', result)
self.assertIn('count', result)
def test_list_overdue_via_community_returns_error(self):
result = self.adapter.list_overdue_via_community()
self.assertIn('error', result)
def test_send_followup_via_fusion_no_overdue(self):
partner = self.env['res.partner'].create({'name': 'AdapterTest'})
result = self.adapter.send_followup_via_fusion(
partner_id=partner.id, force=True,
)
self.assertIn(
result.get('status', ''),
('no_action', 'no_overdue', 'sent', 'manual_review'),
)
def test_send_followup_via_community_returns_error(self):
result = self.adapter.send_followup_via_community(partner_id=1)
self.assertIn('error', result)

View File

@@ -0,0 +1,80 @@
"""HttpCase tests for the 6 follow-up JSON-RPC endpoints."""
import json
from datetime import date, timedelta
from odoo.tests import tagged
from odoo.tests.common import HttpCase, new_test_user
@tagged('post_install', '-at_install')
class TestFollowupController(HttpCase):
def setUp(self):
super().setUp()
self.user = new_test_user(
self.env, login='fu_test_user',
groups='base.group_user,base.group_partner_manager,'
'account.group_account_invoice',
)
def _jsonrpc(self, endpoint, params):
self.authenticate('fu_test_user', 'fu_test_user')
url = f'/fusion/followup/{endpoint}'
body = {'jsonrpc': '2.0', 'method': 'call', 'params': params, 'id': 1}
response = self.url_open(
url, data=json.dumps(body),
headers={'Content-Type': 'application/json'},
)
self.assertEqual(
response.status_code, 200,
f"{endpoint} returned {response.status_code}: {response.text[:300]}",
)
result = response.json()
if 'error' in result:
self.fail(f"{endpoint} errored: {result['error']}")
return result.get('result', {})
def test_list_overdue_returns_dict(self):
result = self._jsonrpc('list_overdue', {'company_id': self.env.company.id})
self.assertIn('partners', result)
self.assertIn('total', result)
def test_get_partner_detail(self):
partner = self.env['res.partner'].create({
'name': 'Ctrl Test Partner', 'email': 'ctrl@test.local',
})
result = self._jsonrpc('get_partner_detail', {'partner_id': partner.id})
self.assertEqual(result['partner']['id'], partner.id)
self.assertIn('overdue', result)
self.assertIn('history', result)
def test_pause_sets_paused_until(self):
partner = self.env['res.partner'].create({'name': 'Pause Test'})
future = (date.today() + timedelta(days=20)).isoformat()
result = self._jsonrpc('pause', {
'partner_id': partner.id, 'until_date': future,
})
self.assertEqual(result['paused_until'], future)
def test_reset_clears_status(self):
partner = self.env['res.partner'].create({
'name': 'Reset Test',
'fusion_followup_status': 'paused',
})
result = self._jsonrpc('reset', {'partner_id': partner.id})
self.assertEqual(result['status'], 'reset')
def test_send_no_overdue_returns_no_action(self):
partner = self.env['res.partner'].create({
'name': 'No Overdue', 'email': 'no@test.local',
})
result = self._jsonrpc('send', {
'partner_id': partner.id, 'force': True,
})
self.assertIn(result.get('status'), ('no_action', 'no_overdue'))
def test_generate_text_no_level_returns_no_level(self):
partner = self.env['res.partner'].create({'name': 'NoLevel Test'})
result = self._jsonrpc('generate_text', {'partner_id': partner.id})
self.assertIn(result.get('status'), ('no_level', 'ok'))

View File

@@ -0,0 +1,18 @@
"""Smoke tests for the fusion follow-up cron handlers."""
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
@tagged('post_install', '-at_install')
class TestFollowupCron(TransactionCase):
def setUp(self):
super().setUp()
self.cron = self.env['fusion.followup.cron']
def test_cron_daily_scan_runs(self):
self.cron._cron_daily_scan()
def test_cron_risk_refresh_runs(self):
self.cron._cron_risk_refresh()

View File

@@ -0,0 +1,84 @@
"""End-to-end integration: scan -> escalate -> send -> reset."""
from datetime import date, timedelta
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
@tagged('post_install', '-at_install', 'integration')
class TestFollowupFullFlow(TransactionCase):
def setUp(self):
super().setUp()
self.engine = self.env['fusion.followup.engine']
self.partner = self.env['res.partner'].create({
'name': 'Full Flow Partner', 'email': 'flow@test.local',
})
for seq, name, days, tone in [(701, 'FlowReminder', 7, 'gentle'),
(702, 'FlowWarning', 30, 'firm'),
(703, 'FlowLegal', 60, 'legal')]:
self.env['fusion.followup.level'].create({
'name': name, 'sequence': seq,
'delay_days': days, 'tone': tone,
})
line = self.env['account.move.line'].search([
('parent_state', '=', 'posted'),
('account_id.account_type', '=', 'asset_receivable'),
('reconciled', '=', False),
('amount_residual', '>', 0),
], limit=1)
if not line:
self.skipTest("No posted unreconciled receivable lines in test DB")
line.write({
'partner_id': self.partner.id,
'date_maturity': date.today() - timedelta(days=20),
})
def test_full_flow_scan_send_reset(self):
level = self.engine.compute_followup_level(self.partner)
self.assertTrue(level)
self.assertGreater(level.delay_days, 0)
Run = self.env['fusion.followup.run']
before = Run.search_count([('partner_id', '=', self.partner.id)])
result = self.engine.send_followup_email(self.partner, force=True)
after = Run.search_count([('partner_id', '=', self.partner.id)])
self.assertGreater(after, before)
self.assertIn(result['status'], ('sent', 'manual_review'))
self.engine.pause_followup(self.partner,
until_date=date.today() + timedelta(days=14))
result_paused = self.engine.send_followup_email(self.partner)
self.assertTrue(result_paused['status'].startswith('paused'))
self.engine.reset_followup(self.partner)
self.partner.invalidate_recordset(['fusion_followup_status'])
self.assertEqual(self.partner.fusion_followup_status, 'no_action')
def test_escalate_advances_to_next_level(self):
Level = self.env['fusion.followup.level']
level1 = Level.search([('sequence', '=', 701)], limit=1)
self.engine.send_followup_email(self.partner, level=level1, force=True)
self.partner.invalidate_recordset(['fusion_followup_last_level_id'])
result = self.engine.escalate_to_next_level(self.partner)
self.assertIn('partner_id', result)
self.partner.invalidate_recordset(['fusion_followup_last_level_id'])
if self.partner.fusion_followup_last_level_id:
self.assertGreaterEqual(self.partner.fusion_followup_last_level_id.sequence, 702)
def test_text_cache_reused_on_repeat(self):
Cache = self.env['fusion.followup.text.cache']
self.engine.send_followup_email(self.partner, force=True)
after_first = Cache.search_count([('partner_id', '=', self.partner.id)])
self.engine.send_followup_email(self.partner, force=True)
after_second = Cache.search_count([('partner_id', '=', self.partner.id)])
self.assertEqual(after_first, after_second)
def test_history_records_each_send(self):
Run = self.env['fusion.followup.run']
before = Run.search_count([('partner_id', '=', self.partner.id)])
self.engine.send_followup_email(self.partner, force=True)
self.engine.send_followup_email(self.partner, force=True)
after = Run.search_count([('partner_id', '=', self.partner.id)])
self.assertEqual(after - before, 2)

View File

@@ -0,0 +1,80 @@
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
from odoo.addons.fusion_accounting_followup.services.followup_text_generator import (
generate_followup_text,
)
from odoo.addons.fusion_accounting_followup.services.followup_text_prompt import (
SYSTEM_PROMPT, build_prompt,
)
@tagged('post_install', '-at_install')
class TestFollowupTextGenerator(TransactionCase):
def setUp(self):
super().setUp()
self.env['ir.config_parameter'].sudo().search([
('key', 'in', ['fusion_accounting.provider.followup_text',
'fusion_accounting.provider.default'])
]).unlink()
def test_fallback_gentle(self):
result = generate_followup_text(
self.env, partner_name='Acme Corp', total_overdue=1500,
currency_code='USD', longest_overdue_days=15, tone='gentle',
invoice_count=2,
)
self.assertEqual(result['tone_used'], 'gentle')
self.assertIn('Acme Corp', result['body'])
self.assertIn('1,500.00', result['body'])
def test_fallback_firm(self):
result = generate_followup_text(
self.env, partner_name='Acme', total_overdue=5000,
currency_code='USD', longest_overdue_days=45, tone='firm',
invoice_count=3,
)
self.assertEqual(result['tone_used'], 'firm')
def test_fallback_legal(self):
result = generate_followup_text(
self.env, partner_name='Acme', total_overdue=10000,
currency_code='USD', longest_overdue_days=90, tone='legal',
invoice_count=5,
)
self.assertEqual(result['tone_used'], 'legal')
self.assertIn('FINAL NOTICE', result['subject'])
def test_returns_required_keys(self):
result = generate_followup_text(
self.env, partner_name='X', total_overdue=100,
currency_code='USD', longest_overdue_days=10, tone='gentle',
)
for key in ('subject', 'body', 'tone_used', 'key_points'):
self.assertIn(key, result)
@tagged('post_install', '-at_install')
class TestFollowupTextPrompt(TransactionCase):
def test_system_prompt_requires_json(self):
self.assertIn('JSON', SYSTEM_PROMPT)
self.assertIn('"subject"', SYSTEM_PROMPT)
self.assertIn('"body"', SYSTEM_PROMPT)
def test_build_prompt_returns_tuple(self):
result = build_prompt(
partner_name='X', total_overdue=100, currency_code='USD',
longest_overdue_days=10, tone='gentle',
)
self.assertEqual(len(result), 2)
self.assertIn('100.00', result[1])
def test_build_prompt_includes_risk_drivers(self):
_, user = build_prompt(
partner_name='X', total_overdue=100, currency_code='USD',
longest_overdue_days=10, tone='firm',
risk_drivers=['Chronic late payer', '5/10 paid late'],
)
self.assertIn('RISK FACTORS', user)
self.assertIn('Chronic late payer', user)

View File

@@ -0,0 +1,61 @@
"""AI tool dispatch tests for fusion follow-up tools."""
from odoo.tests import tagged
from odoo.tests.common import TransactionCase
from odoo.addons.fusion_accounting_ai.services.tools import customer_followup as tools
@tagged('post_install', '-at_install')
class TestFusionFollowupTools(TransactionCase):
def test_fusion_list_overdue(self):
result = tools.fusion_list_overdue(
self.env, {'company_id': self.env.company.id},
)
self.assertIn('partners', result)
def test_fusion_get_partner_detail(self):
partner = self.env['res.partner'].create({
'name': 'Tool Partner', 'email': 't@t.local',
})
result = tools.fusion_get_partner_followup_detail(
self.env, {'partner_id': partner.id},
)
self.assertEqual(result['partner_id'], partner.id)
def test_fusion_generate_text_uses_fallback(self):
self.env['ir.config_parameter'].sudo().search([
('key', 'in', [
'fusion_accounting.provider.followup_text',
'fusion_accounting.provider.default',
]),
]).unlink()
result = tools.fusion_generate_followup_text(self.env, {
'partner_name': 'Acme', 'total_overdue': 1000,
'currency_code': 'USD', 'longest_overdue_days': 15,
'tone': 'gentle',
})
self.assertIn('subject', result)
self.assertIn('body', result)
def test_fusion_get_risk_score(self):
partner = self.env['res.partner'].create({'name': 'Risk Test'})
result = tools.fusion_get_partner_risk_score(
self.env, {'partner_id': partner.id},
)
self.assertIn('risk', result)
def test_tools_registered_in_dispatch(self):
from odoo.addons.fusion_accounting_ai.services.tools import TOOL_DISPATCH
for tool_name in [
'fusion_list_overdue',
'fusion_get_partner_followup_detail',
'fusion_generate_followup_text',
'fusion_send_followup',
'fusion_get_partner_risk_score',
]:
self.assertIn(
tool_name, TOOL_DISPATCH,
f"{tool_name} not registered in TOOL_DISPATCH",
)

View File

@@ -0,0 +1,74 @@
"""Unit tests for the fusion.followup.engine 7-method API."""
from datetime import date, timedelta
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestFusionFollowupEngine(TransactionCase):
def setUp(self):
super().setUp()
self.engine = self.env['fusion.followup.engine']
self.partner = self.env['res.partner'].create({
'name': 'Engine Test Partner', 'email': 'engine@test.local',
})
for seq, name, days, tone in [(901, 'Reminder', 7, 'gentle'),
(902, 'Warning', 30, 'firm'),
(903, 'Legal', 60, 'legal')]:
self.env['fusion.followup.level'].create({
'name': name, 'sequence': seq,
'delay_days': days, 'tone': tone,
})
def test_engine_model_exists(self):
self.assertIn('fusion.followup.engine', self.env.registry)
def test_get_overdue_returns_dict(self):
result = self.engine.get_overdue_for_partner(self.partner)
self.assertIn('aging', result)
self.assertIn('risk', result)
self.assertEqual(result['partner_id'], self.partner.id)
def test_compute_followup_level_no_overdue_returns_empty(self):
result = self.engine.compute_followup_level(self.partner)
self.assertFalse(result)
def test_pause_sets_partner_state(self):
until = date.today() + timedelta(days=14)
self.engine.pause_followup(self.partner, until_date=until)
self.partner.invalidate_recordset(['fusion_followup_paused_until', 'fusion_followup_status'])
self.assertEqual(self.partner.fusion_followup_paused_until, until)
self.assertEqual(self.partner.fusion_followup_status, 'paused')
def test_reset_clears_state(self):
self.engine.pause_followup(self.partner)
self.engine.reset_followup(self.partner)
self.partner.invalidate_recordset([
'fusion_followup_status', 'fusion_followup_paused_until',
'fusion_followup_last_level_id',
])
self.assertEqual(self.partner.fusion_followup_status, 'no_action')
self.assertFalse(self.partner.fusion_followup_paused_until)
def test_snapshot_history_returns_runs(self):
Run = self.env['fusion.followup.run']
run = Run.create({
'partner_id': self.partner.id,
'state': 'sent',
'overdue_amount': 500,
})
result = self.engine.snapshot_followup_history(self.partner)
self.assertEqual(result['count'], 1)
self.assertEqual(result['runs'][0]['id'], run.id)
def test_send_no_overdue_returns_no_action(self):
Level = self.env['fusion.followup.level']
level = Level.search([('sequence', '=', 901)], limit=1)
result = self.engine.send_followup_email(self.partner, level=level, force=True)
self.assertEqual(result['status'], 'no_overdue')
def test_escalate_when_no_current_level(self):
result = self.engine.escalate_to_next_level(self.partner)
self.assertIn('partner_id', result)

View File

@@ -0,0 +1,43 @@
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestFusionFollowupLevel(TransactionCase):
def test_create_minimal(self):
# Note: sequences 1-3 are reserved for seeded default levels.
level = self.env['fusion.followup.level'].create({
'name': 'Reminder', 'sequence': 901, 'delay_days': 7, 'tone': 'gentle',
})
self.assertEqual(level.name, 'Reminder')
self.assertTrue(level.active)
def test_negative_delay_rejected(self):
with self.assertRaises(Exception):
self.env['fusion.followup.level'].create({
'name': 'Bad', 'sequence': 902, 'delay_days': -5, 'tone': 'gentle',
})
def test_duplicate_sequence_rejected(self):
self.env['fusion.followup.level'].create({
'name': 'A', 'sequence': 100, 'delay_days': 7, 'tone': 'gentle',
})
with self.assertRaises(Exception):
self.env['fusion.followup.level'].create({
'name': 'B', 'sequence': 100, 'delay_days': 30, 'tone': 'firm',
})
def test_three_levels_escalate(self):
for seq, name, days, tone in [(1, 'R', 7, 'gentle'),
(2, 'W', 30, 'firm'),
(3, 'L', 60, 'legal')]:
self.env['fusion.followup.level'].create({
'name': name, 'sequence': seq + 200,
'delay_days': days, 'tone': tone,
})
levels = self.env['fusion.followup.level'].search([
('sequence', '>', 200),
], order='sequence')
self.assertEqual(len(levels), 3)
self.assertEqual(levels.mapped('tone'), ['gentle', 'firm', 'legal'])

View File

@@ -0,0 +1,44 @@
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestFusionFollowupRun(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.partner = cls.env['res.partner'].create({'name': 'Run Test Partner'})
cls.level = cls.env['fusion.followup.level'].create({
'name': 'Reminder', 'sequence': 301, 'delay_days': 7, 'tone': 'gentle',
})
def test_create_minimal(self):
run = self.env['fusion.followup.run'].create({
'partner_id': self.partner.id,
'level_id': self.level.id,
})
self.assertEqual(run.state, 'draft')
self.assertTrue(run.execution_date)
def test_action_mark_sent(self):
run = self.env['fusion.followup.run'].create({
'partner_id': self.partner.id,
'level_id': self.level.id,
})
run.action_mark_sent()
self.assertEqual(run.state, 'sent')
def test_action_mark_failed_records_error(self):
run = self.env['fusion.followup.run'].create({
'partner_id': self.partner.id,
})
run.action_mark_failed(error='SMTP unreachable')
self.assertEqual(run.state, 'failed')
self.assertEqual(run.error_message, 'SMTP unreachable')
def test_partner_required(self):
with self.assertRaises(Exception):
self.env['fusion.followup.run'].create({
'level_id': self.level.id,
})

View File

@@ -0,0 +1,60 @@
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestFusionFollowupTextCache(TransactionCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.partner = cls.env['res.partner'].create({'name': 'Cache Test Partner'})
cls.level = cls.env['fusion.followup.level'].create({
'name': 'Reminder', 'sequence': 401, 'delay_days': 7, 'tone': 'gentle',
})
cls.cache = cls.env['fusion.followup.text.cache']
def _kwargs(self, **overrides):
base = dict(
partner_id=self.partner.id, level_id=self.level.id,
overdue_amount=1234.56, longest_overdue_days=10,
invoice_count=3, tone='gentle',
)
base.update(overrides)
return base
def test_fingerprint_stable_and_unique(self):
fp1 = self.cache.compute_fingerprint(**self._kwargs())
fp2 = self.cache.compute_fingerprint(**self._kwargs())
fp3 = self.cache.compute_fingerprint(**self._kwargs(tone='firm'))
self.assertEqual(fp1, fp2)
self.assertNotEqual(fp1, fp3)
self.assertEqual(len(fp1), 64)
def test_lookup_returns_empty_when_missing(self):
result = self.cache.lookup(**self._kwargs())
self.assertFalse(result)
def test_lookup_finds_cached_entry(self):
kwargs = self._kwargs()
fp = self.cache.compute_fingerprint(**kwargs)
entry = self.cache.create({
'partner_id': self.partner.id,
'level_id': self.level.id,
'fingerprint': fp,
'subject': 'Hi',
'body': 'Please pay.',
'tone_used': 'gentle',
})
found = self.cache.lookup(**kwargs)
self.assertEqual(found.id, entry.id)
def test_action_increment_use(self):
entry = self.cache.create({
'partner_id': self.partner.id,
'fingerprint': 'abc123',
})
self.assertEqual(entry.use_count, 0)
entry.action_increment_use()
entry.action_increment_use()
self.assertEqual(entry.use_count, 2)

View File

@@ -0,0 +1,58 @@
from datetime import date, timedelta
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
from odoo.addons.fusion_accounting_followup.services.level_resolver import (
FollowupLevelSpec, resolve_level,
)
from odoo.addons.fusion_accounting_followup.services.overdue_aging import compute_aging
@tagged('post_install', '-at_install')
class TestLevelResolver(TransactionCase):
def setUp(self):
super().setUp()
self.levels = [
FollowupLevelSpec(sequence=1, name='Reminder', delay_days=7, tone='gentle'),
FollowupLevelSpec(sequence=2, name='Warning', delay_days=30, tone='firm'),
FollowupLevelSpec(sequence=3, name='Legal Notice', delay_days=60, tone='legal'),
]
def test_no_overdue_returns_none(self):
as_of = date(2026, 4, 19)
lines = [{'date_maturity': as_of + timedelta(days=10), 'amount_residual': 100}]
report = compute_aging(move_lines=lines, as_of=as_of)
result = resolve_level(aging_report=report, levels=self.levels)
self.assertIsNone(result)
def test_15_days_overdue_picks_reminder(self):
as_of = date(2026, 4, 19)
lines = [{'date_maturity': as_of - timedelta(days=15), 'amount_residual': 100}]
report = compute_aging(move_lines=lines, as_of=as_of)
result = resolve_level(aging_report=report, levels=self.levels)
self.assertEqual(result.name, 'Reminder')
def test_45_days_overdue_picks_warning(self):
as_of = date(2026, 4, 19)
lines = [{'date_maturity': as_of - timedelta(days=45), 'amount_residual': 200}]
report = compute_aging(move_lines=lines, as_of=as_of)
result = resolve_level(aging_report=report, levels=self.levels)
self.assertEqual(result.name, 'Warning')
def test_75_days_overdue_picks_legal(self):
as_of = date(2026, 4, 19)
lines = [{'date_maturity': as_of - timedelta(days=75), 'amount_residual': 300}]
report = compute_aging(move_lines=lines, as_of=as_of)
result = resolve_level(aging_report=report, levels=self.levels)
self.assertEqual(result.name, 'Legal Notice')
def test_no_levels_returns_none(self):
as_of = date(2026, 4, 19)
lines = [{'date_maturity': as_of - timedelta(days=30), 'amount_residual': 100}]
report = compute_aging(move_lines=lines, as_of=as_of)
result = resolve_level(aging_report=report, levels=[])
self.assertIsNone(result)
def test_invalid_tone_raises(self):
with self.assertRaises(ValueError):
FollowupLevelSpec(sequence=1, name='X', delay_days=7, tone='invalid')

View File

@@ -0,0 +1,21 @@
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestFollowupMigrationRoundTrip(TransactionCase):
def test_bootstrap_step_runs(self):
wizard = self.env['fusion.migration.wizard'].create({})
result = wizard._followup_bootstrap_step()
self.assertEqual(result['step'], 'followup_bootstrap')
# Either Enterprise present or not — both OK
self.assertIn(result['enterprise_module_present'], [True, False])
def test_bootstrap_idempotent(self):
wizard = self.env['fusion.migration.wizard'].create({})
first = wizard._followup_bootstrap_step()
second = wizard._followup_bootstrap_step()
# Second run skips what first created (or both no-op)
if first['enterprise_module_present']:
self.assertGreaterEqual(second['skipped'], first['created'])

View File

@@ -0,0 +1,69 @@
from datetime import date, timedelta
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
from odoo.addons.fusion_accounting_followup.services.overdue_aging import (
compute_aging, BUCKETS,
)
@tagged('post_install', '-at_install')
class TestOverdueAging(TransactionCase):
def test_empty_lines_returns_zero_buckets(self):
report = compute_aging(move_lines=[], as_of=date(2026, 4, 19))
self.assertEqual(report.total_amount, 0)
self.assertEqual(len(report.buckets), len(BUCKETS))
for b in report.buckets:
self.assertEqual(b.amount, 0)
def test_current_bucket_for_future_maturity(self):
as_of = date(2026, 4, 19)
lines = [{'date_maturity': date(2026, 5, 19), 'amount_residual': 100}]
report = compute_aging(move_lines=lines, as_of=as_of)
current = next(b for b in report.buckets if b.name == 'current')
self.assertEqual(current.amount, 100)
self.assertEqual(report.total_overdue_amount, 0)
def test_30_day_bucket(self):
as_of = date(2026, 4, 19)
lines = [{'date_maturity': as_of - timedelta(days=15), 'amount_residual': 200}]
report = compute_aging(move_lines=lines, as_of=as_of)
b = next(b for b in report.buckets if b.name == '1_30')
self.assertEqual(b.amount, 200)
def test_60_day_bucket(self):
as_of = date(2026, 4, 19)
lines = [{'date_maturity': as_of - timedelta(days=45), 'amount_residual': 300}]
report = compute_aging(move_lines=lines, as_of=as_of)
b = next(b for b in report.buckets if b.name == '31_60')
self.assertEqual(b.amount, 300)
def test_120_plus_bucket(self):
as_of = date(2026, 4, 19)
lines = [{'date_maturity': as_of - timedelta(days=200), 'amount_residual': 500}]
report = compute_aging(move_lines=lines, as_of=as_of)
b = next(b for b in report.buckets if b.name == '120_plus')
self.assertEqual(b.amount, 500)
def test_total_overdue_excludes_current(self):
as_of = date(2026, 4, 19)
lines = [
{'date_maturity': as_of + timedelta(days=10), 'amount_residual': 100},
{'date_maturity': as_of - timedelta(days=10), 'amount_residual': 200},
{'date_maturity': as_of - timedelta(days=50), 'amount_residual': 300},
]
report = compute_aging(move_lines=lines, as_of=as_of)
self.assertEqual(report.total_amount, 600)
self.assertEqual(report.total_overdue_amount, 500)
def test_buckets_sum_equals_total(self):
as_of = date(2026, 4, 19)
lines = [
{'date_maturity': as_of + timedelta(days=10), 'amount_residual': 100},
{'date_maturity': as_of - timedelta(days=15), 'amount_residual': 200},
{'date_maturity': as_of - timedelta(days=75), 'amount_residual': 300},
{'date_maturity': as_of - timedelta(days=200), 'amount_residual': 500},
]
report = compute_aging(move_lines=lines, as_of=as_of)
bucket_sum = sum(b.amount for b in report.buckets)
self.assertAlmostEqual(bucket_sum, report.total_amount, places=2)

View File

@@ -0,0 +1,100 @@
"""Performance benchmarks tagged 'benchmark'."""
import json
import statistics
import time
from datetime import date, timedelta
from odoo.tests.common import HttpCase, TransactionCase, new_test_user
from odoo.tests import tagged
def _percentile(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)]
@tagged('post_install', '-at_install', 'benchmark')
class TestEngineBenchmarks(TransactionCase):
def setUp(self):
super().setUp()
self.engine = self.env['fusion.followup.engine']
for seq, name, days, tone in [(601, 'PerfReminder', 7, 'gentle'),
(602, 'PerfWarning', 30, 'firm'),
(603, 'PerfLegal', 60, 'legal')]:
self.env['fusion.followup.level'].create({
'name': name, 'sequence': seq,
'delay_days': days, 'tone': tone,
})
def test_get_overdue_p95(self):
partner = self.env['res.partner'].create({'name': 'PerfPartner'})
timings = []
for _ in range(10):
start = time.perf_counter()
self.engine.get_overdue_for_partner(partner)
timings.append((time.perf_counter() - start) * 1000)
p95 = _percentile(timings, 95)
median = statistics.median(timings)
msg = f"get_overdue_for_partner: median={median:.0f}ms p95={p95:.0f}ms"
print(f"\n PERF: {msg} (target <100ms)")
self.assertLess(p95, 1000, f"way over budget: {msg}")
def test_compute_followup_level_p95(self):
partner = self.env['res.partner'].create({'name': 'CompLevelPerf'})
timings = []
for _ in range(10):
start = time.perf_counter()
self.engine.compute_followup_level(partner)
timings.append((time.perf_counter() - start) * 1000)
p95 = _percentile(timings, 95)
median = statistics.median(timings)
msg = f"compute_followup_level: median={median:.0f}ms p95={p95:.0f}ms"
print(f"\n PERF: {msg} (target <50ms)")
self.assertLess(p95, 500)
def test_send_followup_p95(self):
partner = self.env['res.partner'].create({
'name': 'SendPerf', 'email': 'sp@test.local',
})
timings = []
for _ in range(5):
start = time.perf_counter()
self.engine.send_followup_email(partner, force=True)
timings.append((time.perf_counter() - start) * 1000)
p95 = _percentile(timings, 95)
median = statistics.median(timings)
msg = f"send_followup_email (no overdue): median={median:.0f}ms p95={p95:.0f}ms"
print(f"\n PERF: {msg} (target <200ms)")
self.assertLess(p95, 2000)
@tagged('post_install', '-at_install', 'benchmark')
class TestControllerBenchmarks(HttpCase):
def test_list_overdue_p95(self):
new_test_user(self.env, login='fu_perf',
groups='base.group_user,account.group_account_invoice,base.group_partner_manager')
for i in range(20):
self.env['res.partner'].create({'name': f'PerfP{i}'})
self.authenticate('fu_perf', 'fu_perf')
timings = []
for _ in range(5):
start = time.perf_counter()
response = self.url_open(
'/fusion/followup/list_overdue',
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)
p95 = _percentile(timings, 95)
median = statistics.median(timings)
msg = f"controller.list_overdue: median={median:.0f}ms p95={p95:.0f}ms"
print(f"\n PERF: {msg} (target <500ms)")
self.assertLess(p95, 5000)

View File

@@ -0,0 +1,27 @@
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
@tagged('post_install', '-at_install')
class TestResPartnerFollowup(TransactionCase):
def test_default_status_no_action(self):
partner = self.env['res.partner'].create({'name': 'Default Status'})
self.assertEqual(partner.fusion_followup_status, 'no_action')
self.assertEqual(partner.fusion_followup_risk_band, 'low')
self.assertEqual(partner.fusion_followup_risk_score, 0)
def test_run_count_reflects_history(self):
partner = self.env['res.partner'].create({'name': 'History Partner'})
self.assertEqual(partner.fusion_followup_run_count, 0)
for _ in range(3):
self.env['fusion.followup.run'].create({'partner_id': partner.id})
partner.invalidate_recordset(['fusion_followup_run_count', 'fusion_followup_run_ids'])
self.assertEqual(partner.fusion_followup_run_count, 3)
def test_action_view_followup_history_returns_action(self):
partner = self.env['res.partner'].create({'name': 'Action Partner'})
action = partner.action_view_followup_history()
self.assertEqual(action['res_model'], 'fusion.followup.run')
self.assertEqual(action['domain'], [('partner_id', '=', partner.id)])
self.assertEqual(action['context']['default_partner_id'], partner.id)

View File

@@ -0,0 +1,48 @@
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
from odoo.addons.fusion_accounting_followup.services.risk_scorer import (
score_partner, PartnerRiskScore,
)
@tagged('post_install', '-at_install')
class TestRiskScorer(TransactionCase):
def test_no_history_returns_low(self):
result = score_partner()
self.assertEqual(result.band, 'low')
self.assertLess(result.score, 30)
def test_chronic_late_pays_returns_high(self):
result = score_partner(
total_invoices=20, paid_late_count=18,
avg_days_late=45, longest_overdue_days=90,
open_overdue_amount=15000, average_invoice_amount=2000,
)
self.assertIn(result.band, ('high', 'critical'))
self.assertGreater(len(result.drivers), 0)
def test_one_off_overdue_returns_medium(self):
result = score_partner(
total_invoices=10, paid_late_count=1,
avg_days_late=20, longest_overdue_days=45,
open_overdue_amount=2000, average_invoice_amount=2000,
)
self.assertIn(result.band, ('low', 'medium'))
def test_score_capped_at_100(self):
result = score_partner(
total_invoices=10, paid_late_count=10,
avg_days_late=180, longest_overdue_days=300,
open_overdue_amount=999999, average_invoice_amount=1000,
)
self.assertLessEqual(result.score, 100)
def test_score_floored_at_0(self):
result = score_partner()
self.assertGreaterEqual(result.score, 0)
def test_band_thresholds(self):
for s, expected_band in [(10, 'low'), (40, 'medium'), (70, 'high'), (90, 'critical')]:
r = PartnerRiskScore(score=s, band=expected_band, drivers=[])
self.assertEqual(r.band, expected_band)

View File

@@ -0,0 +1,25 @@
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
from odoo.addons.fusion_accounting_followup.services.tone_selector import select_tone
@tagged('post_install', '-at_install')
class TestToneSelector(TransactionCase):
def test_level_1_default_gentle(self):
self.assertEqual(select_tone(level_sequence=1), 'gentle')
def test_level_2_default_firm(self):
self.assertEqual(select_tone(level_sequence=2), 'firm')
def test_level_3_default_legal(self):
self.assertEqual(select_tone(level_sequence=3), 'legal')
def test_critical_risk_escalates_gentle_to_firm(self):
self.assertEqual(select_tone(level_sequence=1, risk_score=85), 'firm')
def test_extreme_risk_escalates_firm_to_legal(self):
self.assertEqual(select_tone(level_sequence=2, risk_score=95), 'legal')
def test_unknown_level_defaults_gentle(self):
self.assertEqual(select_tone(level_sequence=99), 'gentle')

View File

@@ -0,0 +1,69 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Top-level menu (visible only when account_followup Enterprise NOT installed) -->
<menuitem id="menu_fusion_followup_root"
name="Customer Follow-ups"
sequence="70"
web_icon="fusion_accounting_followup,static/description/icon.png"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<!-- Partners list (gated to overdue) -->
<record id="action_fusion_followup_partners" model="ir.actions.act_window">
<field name="name">Overdue Customers</field>
<field name="res_model">res.partner</field>
<field name="view_mode">list,form</field>
<field name="domain">[('fusion_followup_status', 'in', ('action_due', 'paused', 'blocked', 'with_credit_team'))]</field>
<field name="context">{}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Customer follow-ups
</p>
<p>
AI-augmented dunning sequences for unpaid invoices.
</p>
</field>
</record>
<menuitem id="menu_fusion_followup_partners"
name="Overdue Customers"
parent="menu_fusion_followup_root"
action="action_fusion_followup_partners"
sequence="10"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<!-- Levels config -->
<record id="action_fusion_followup_levels" model="ir.actions.act_window">
<field name="name">Follow-up Levels</field>
<field name="res_model">fusion.followup.level</field>
<field name="view_mode">list,form</field>
</record>
<menuitem id="menu_fusion_followup_levels"
name="Levels"
parent="menu_fusion_followup_root"
action="action_fusion_followup_levels"
sequence="20"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<!-- Run history -->
<record id="action_fusion_followup_runs" model="ir.actions.act_window">
<field name="name">Follow-up History</field>
<field name="res_model">fusion.followup.run</field>
<field name="view_mode">list,form</field>
</record>
<menuitem id="menu_fusion_followup_runs"
name="History"
parent="menu_fusion_followup_root"
action="action_fusion_followup_runs"
sequence="30"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
<!-- Batch wizard -->
<menuitem id="menu_fusion_followup_batch"
name="Batch Send..."
parent="menu_fusion_followup_root"
action="action_fusion_batch_followup_wizard"
sequence="40"
groups="fusion_accounting_core.group_fusion_show_when_enterprise_absent"/>
</odoo>

View File

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

View File

@@ -0,0 +1,91 @@
"""Batch send follow-ups to selected partners (or all overdue)."""
from datetime import date
from odoo import _, api, fields, models
from odoo.exceptions import UserError
class FusionBatchFollowupWizard(models.TransientModel):
_name = "fusion.batch.followup.wizard"
_description = "Batch Send Follow-ups Wizard"
scope = fields.Selection([
('selected', 'Selected partners only'),
('all_overdue', 'All overdue partners'),
], required=True, default='selected')
partner_ids = fields.Many2many('res.partner',
default=lambda self: self._default_partner_ids())
force = fields.Boolean(string='Force (override pause + manual review)',
default=False)
auto_resolve_level = fields.Boolean(
string='Auto-resolve level',
default=True,
help="If True, engine picks the appropriate level per partner. "
"If False, use the chosen override level for all.")
override_level_id = fields.Many2one('fusion.followup.level')
# Results
state = fields.Selection([('draft', 'Draft'), ('done', 'Done')], default='draft')
sent_count = fields.Integer(readonly=True)
skipped_count = fields.Integer(readonly=True)
error_count = fields.Integer(readonly=True)
summary = fields.Text(readonly=True)
@api.model
def _default_partner_ids(self):
ctx = self.env.context
if ctx.get('active_model') == 'res.partner':
return ctx.get('active_ids', [])
return []
def action_run(self):
self.ensure_one()
if self.scope == 'selected' and not self.partner_ids:
raise UserError(_("No partners selected."))
partners = self.partner_ids
if self.scope == 'all_overdue':
Line = self.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', '=', self.env.company.id),
]).mapped('partner_id').ids
partners = self.env['res.partner'].sudo().browse(overdue_partner_ids)
engine = self.env['fusion.followup.engine']
sent = 0
skipped = 0
errors = []
for partner in partners:
try:
with self.env.cr.savepoint():
level = self.override_level_id if not self.auto_resolve_level else None
result = engine.send_followup_email(
partner, level=level, force=self.force)
status = result.get('status', '')
if status == 'sent':
sent += 1
else:
skipped += 1
except Exception as e:
errors.append(f"{partner.name}: {e}")
self.write({
'state': 'done',
'sent_count': sent,
'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,44 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_fusion_batch_followup_wizard_form" model="ir.ui.view">
<field name="name">fusion.batch.followup.wizard.form</field>
<field name="model">fusion.batch.followup.wizard</field>
<field name="arch" type="xml">
<form string="Batch Follow-ups">
<group invisible="state == 'done'">
<field name="scope" widget="radio"/>
<field name="partner_ids" widget="many2many_tags"
invisible="scope != 'selected'"
required="scope == 'selected'"/>
<field name="auto_resolve_level"/>
<field name="override_level_id"
options="{'no_create': True}"
invisible="auto_resolve_level"
required="not auto_resolve_level"/>
<field name="force"/>
</group>
<group invisible="state != 'done'" string="Results">
<field name="sent_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_batch_followup_wizard" model="ir.actions.act_window">
<field name="name">Batch Send Follow-ups</field>
<field name="res_model">fusion.batch.followup.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="binding_model_id" ref="base.model_res_partner"/>
<field name="binding_view_types">list</field>
</record>
</odoo>

View File

@@ -19,6 +19,8 @@ access_fp_quote_configurator_estimator,fp.quote.configurator.estimator,model_fp_
access_fp_quote_configurator_manager,fp.quote.configurator.manager,model_fp_quote_configurator,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_direct_order_wizard_estimator,fp.direct.order.wizard.estimator,model_fp_direct_order_wizard,fusion_plating_configurator.group_fp_estimator,1,1,1,1
access_fp_direct_order_wizard_manager,fp.direct.order.wizard.manager,model_fp_direct_order_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_direct_order_line_estimator,fp.direct.order.line.estimator,model_fp_direct_order_line,fusion_plating_configurator.group_fp_estimator,1,1,1,1
access_fp_direct_order_line_manager,fp.direct.order.line.manager,model_fp_direct_order_line,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_part_import_wizard_estimator,fp.part.catalog.import.wizard.estimator,model_fp_part_catalog_import_wizard,fusion_plating_configurator.group_fp_estimator,1,1,1,1
access_fp_part_import_wizard_manager,fp.part.catalog.import.wizard.manager,model_fp_part_catalog_import_wizard,fusion_plating.group_fusion_plating_manager,1,1,1,1
access_fp_customer_price_list_operator,fp.customer.price.list.operator,model_fp_customer_price_list,fusion_plating.group_fusion_plating_operator,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
19 access_fp_quote_configurator_manager fp.quote.configurator.manager model_fp_quote_configurator fusion_plating.group_fusion_plating_manager 1 1 1 1
20 access_fp_direct_order_wizard_estimator fp.direct.order.wizard.estimator model_fp_direct_order_wizard fusion_plating_configurator.group_fp_estimator 1 1 1 1
21 access_fp_direct_order_wizard_manager fp.direct.order.wizard.manager model_fp_direct_order_wizard fusion_plating.group_fusion_plating_manager 1 1 1 1
22 access_fp_direct_order_line_estimator fp.direct.order.line.estimator model_fp_direct_order_line fusion_plating_configurator.group_fp_estimator 1 1 1 1
23 access_fp_direct_order_line_manager fp.direct.order.line.manager model_fp_direct_order_line fusion_plating.group_fusion_plating_manager 1 1 1 1
24 access_fp_part_import_wizard_estimator fp.part.catalog.import.wizard.estimator model_fp_part_catalog_import_wizard fusion_plating_configurator.group_fp_estimator 1 1 1 1
25 access_fp_part_import_wizard_manager fp.part.catalog.import.wizard.manager model_fp_part_catalog_import_wizard fusion_plating.group_fusion_plating_manager 1 1 1 1
26 access_fp_customer_price_list_operator fp.customer.price.list.operator model_fp_customer_price_list fusion_plating.group_fusion_plating_operator 1 0 0 0

View File

@@ -3,4 +3,5 @@
# License OPL-1 (Odoo Proprietary License v1.0)
from . import fp_direct_order_wizard
from . import fp_direct_order_line
from . import fp_part_catalog_import_wizard

View File

@@ -0,0 +1,46 @@
# -*- coding: utf-8 -*-
# Copyright 2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Plating product family.
from odoo import api, fields, models
class FpDirectOrderLine(models.TransientModel):
_name = 'fp.direct.order.line'
_description = 'Fusion Plating - Direct Order Line'
_order = 'sequence, id'
wizard_id = fields.Many2one(
'fp.direct.order.wizard',
required=True,
ondelete='cascade',
)
sequence = fields.Integer(default=10)
part_catalog_id = fields.Many2one(
'fp.part.catalog',
string='Part',
required=True,
)
coating_config_id = fields.Many2one(
'fp.coating.config',
string='Primary Treatment',
required=True,
)
quantity = fields.Integer(string='Qty', default=1, required=True)
currency_id = fields.Many2one(related='wizard_id.currency_id')
unit_price = fields.Monetary(
string='Unit Price',
currency_field='currency_id',
)
line_subtotal = fields.Monetary(
string='Subtotal',
currency_field='currency_id',
compute='_compute_line_subtotal',
)
@api.depends('quantity', 'unit_price')
def _compute_line_subtotal(self):
for rec in self:
rec.line_subtotal = (rec.quantity or 0) * (rec.unit_price or 0.0)