Compare commits

...

19 Commits

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

Made-with: Cursor
2026-04-19 21:33:26 -04:00
gsinghpal
d0a912b1da test(fusion_accounting_followup): coexistence behavior
Made-with: Cursor
2026-04-19 21:30:26 -04:00
gsinghpal
8ef88da94a feat(fusion_accounting_followup): menu + window actions with coexistence group filter
Made-with: Cursor
2026-04-19 21:30:06 -04:00
gsinghpal
38a2684782 feat(fusion_accounting_followup): migration wizard backfill from account_followup
Made-with: Cursor
2026-04-19 21:29:38 -04:00
gsinghpal
2ec90a50b0 feat(fusion_accounting_followup): batch send follow-ups wizard
Made-with: Cursor
2026-04-19 21:28:58 -04:00
gsinghpal
4ee261e189 feat(fusion_accounting_followup): default mail templates for 3 escalation levels
Made-with: Cursor
2026-04-19 21:27:59 -04:00
gsinghpal
ab3fcc56db feat(fusion_accounting_followup): seed 3 default follow-up levels
Made-with: Cursor
2026-04-19 21:27:33 -04:00
gsinghpal
474485f963 feat(fusion_accounting_followup): ai_text_panel + followup_history_table components
Made-with: Cursor
2026-04-19 21:20:51 -04:00
gsinghpal
da746698c5 feat(fusion_accounting_followup): partner_card + aging_bucket_strip + risk_badge components
Made-with: Cursor
2026-04-19 21:19:52 -04:00
gsinghpal
21f6171162 feat(fusion_accounting_followup): top-level followup_dashboard component
Made-with: Cursor
2026-04-19 21:18:59 -04:00
gsinghpal
86bead48e1 feat(fusion_accounting_followup): followup_service.js reactive frontend service
Made-with: Cursor
2026-04-19 21:17:57 -04:00
gsinghpal
99e4f8e17f feat(fusion_accounting_followup): SCSS foundation for OWL widget
Made-with: Cursor
2026-04-19 21:17:18 -04:00
gsinghpal
f45d66c465 test(fusion_accounting_followup): performance benchmarks with P95 targets
Made-with: Cursor
2026-04-19 21:10:02 -04:00
gsinghpal
f64b8f373c test(fusion_accounting_followup): full follow-up flow integration test
Made-with: Cursor
2026-04-19 21:09:17 -04:00
gsinghpal
d51a2b104e test(fusion_accounting_followup): Hypothesis property-based invariants
Made-with: Cursor
2026-04-19 21:08:35 -04:00
42 changed files with 1986 additions and 6 deletions

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{ {
'name': 'Fusion Accounting Follow-up', 'name': 'Fusion Accounting Follow-up',
'version': '19.0.1.0.18', 'version': '19.0.1.0.30',
'category': 'Accounting/Accounting', 'category': 'Accounting/Accounting',
'summary': 'AI-augmented customer follow-ups (dunning) for unpaid invoices.', 'summary': 'AI-augmented customer follow-ups (dunning) for unpaid invoices.',
'description': """ 'description': """
@@ -28,15 +28,40 @@ menu hides; the engine + AI tools remain available for the chat.
'depends': [ 'depends': [
'fusion_accounting_core', 'fusion_accounting_core',
'fusion_accounting_ai', 'fusion_accounting_ai',
'fusion_accounting_migration',
'account', 'account',
'mail', 'mail',
], ],
'data': [ 'data': [
'security/ir.model.access.csv', 'security/ir.model.access.csv',
'data/cron.xml', 'data/cron.xml',
'data/followup_levels_data.xml',
'data/mail_templates_data.xml',
'wizards/batch_followup_wizard_views.xml',
'views/menu_views.xml',
], ],
'assets': { 'assets': {
'web.assets_backend': [ 'web.assets_backend': [
'fusion_accounting_followup/static/src/scss/_variables.scss',
'fusion_accounting_followup/static/src/scss/followup.scss',
'fusion_accounting_followup/static/src/scss/dark_mode.scss',
'fusion_accounting_followup/static/src/services/followup_service.js',
'fusion_accounting_followup/static/src/views/followup_dashboard/followup_dashboard.js',
'fusion_accounting_followup/static/src/views/followup_dashboard/followup_dashboard.xml',
'fusion_accounting_followup/static/src/views/followup_dashboard/followup_dashboard_view.js',
'fusion_accounting_followup/static/src/components/risk_badge/risk_badge.js',
'fusion_accounting_followup/static/src/components/risk_badge/risk_badge.xml',
'fusion_accounting_followup/static/src/components/partner_card/partner_card.js',
'fusion_accounting_followup/static/src/components/partner_card/partner_card.xml',
'fusion_accounting_followup/static/src/components/aging_bucket_strip/aging_bucket_strip.js',
'fusion_accounting_followup/static/src/components/aging_bucket_strip/aging_bucket_strip.xml',
'fusion_accounting_followup/static/src/components/ai_text_panel/ai_text_panel.js',
'fusion_accounting_followup/static/src/components/ai_text_panel/ai_text_panel.xml',
'fusion_accounting_followup/static/src/components/followup_history_table/followup_history_table.js',
'fusion_accounting_followup/static/src/components/followup_history_table/followup_history_table.xml',
],
'web.assets_tests': [
'fusion_accounting_followup/static/src/tours/followup_tours.js',
], ],
}, },
'installable': True, 'installable': True,

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

@@ -5,3 +5,4 @@ from . import res_partner
from . import account_move_line from . import account_move_line
from . import fusion_followup_engine from . import fusion_followup_engine
from . import fusion_followup_cron from . import fusion_followup_cron
from . import fusion_migration_wizard

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

@@ -5,3 +5,4 @@ access_fusion_followup_run_user,fusion.followup.run.user,model_fusion_followup_r
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_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_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_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
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,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,50 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
// Tour 1: smoke
registry.category("web_tour.tours").add("fusion_followup_smoke", {
test: true,
url: "/odoo",
steps: () => [
{ content: "Wait for app", trigger: ".o_navbar" },
],
});
// Tour 2: open partners list
registry.category("web_tour.tours").add("fusion_followup_partners", {
test: true,
url: "/odoo/action-fusion_accounting_followup.action_fusion_followup_partners",
steps: () => [
{ content: "List view loads", trigger: ".o_list_view, .o_view_nocontent" },
],
});
// Tour 3: open levels
registry.category("web_tour.tours").add("fusion_followup_levels", {
test: true,
url: "/odoo/action-fusion_accounting_followup.action_fusion_followup_levels",
steps: () => [
{ content: "Levels view loads", trigger: ".o_list_view, .o_view_nocontent" },
],
});
// Tour 4: history
registry.category("web_tour.tours").add("fusion_followup_history", {
test: true,
url: "/odoo/action-fusion_accounting_followup.action_fusion_followup_runs",
steps: () => [
{ content: "History view loads", trigger: ".o_list_view, .o_view_nocontent" },
],
});
// Tour 5: batch wizard
registry.category("web_tour.tours").add("fusion_followup_batch_wizard", {
test: true,
url: "/odoo/action-fusion_accounting_followup.action_fusion_batch_followup_wizard",
steps: () => [
{ content: "Wizard form opens", trigger: ".modal-dialog .o_form_view" },
{ content: "Scope field exists", trigger: ".modal-dialog [name='scope']" },
{ content: "Close wizard", trigger: ".modal-dialog .btn-secondary", run: "click" },
],
});

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

@@ -14,3 +14,11 @@ from . import test_followup_controller
from . import test_followup_adapter from . import test_followup_adapter
from . import test_followup_tools from . import test_followup_tools
from . import test_followup_cron 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
from . import test_followup_tours
from . import test_local_llm_compat

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,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,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,23 @@
"""Python wrappers for OWL tours via HttpCase.start_tour."""
from odoo.tests.common import HttpCase
from odoo.tests import tagged
@tagged('post_install', '-at_install', 'tour')
class TestFollowupTours(HttpCase):
def test_smoke_tour(self):
self.start_tour("/odoo", "fusion_followup_smoke", login="admin")
def test_partners_tour(self):
self.start_tour("/odoo", "fusion_followup_partners", login="admin")
def test_levels_tour(self):
self.start_tour("/odoo", "fusion_followup_levels", login="admin")
def test_history_tour(self):
self.start_tour("/odoo", "fusion_followup_history", login="admin")
def test_batch_wizard_tour(self):
self.start_tour("/odoo", "fusion_followup_batch_wizard", login="admin")

View File

@@ -6,8 +6,9 @@ from odoo.tests import tagged
class TestFusionFollowupLevel(TransactionCase): class TestFusionFollowupLevel(TransactionCase):
def test_create_minimal(self): def test_create_minimal(self):
# Note: sequences 1-3 are reserved for seeded default levels.
level = self.env['fusion.followup.level'].create({ level = self.env['fusion.followup.level'].create({
'name': 'Reminder', 'sequence': 1, 'delay_days': 7, 'tone': 'gentle', 'name': 'Reminder', 'sequence': 901, 'delay_days': 7, 'tone': 'gentle',
}) })
self.assertEqual(level.name, 'Reminder') self.assertEqual(level.name, 'Reminder')
self.assertTrue(level.active) self.assertTrue(level.active)
@@ -15,7 +16,7 @@ class TestFusionFollowupLevel(TransactionCase):
def test_negative_delay_rejected(self): def test_negative_delay_rejected(self):
with self.assertRaises(Exception): with self.assertRaises(Exception):
self.env['fusion.followup.level'].create({ self.env['fusion.followup.level'].create({
'name': 'Bad', 'sequence': 1, 'delay_days': -5, 'tone': 'gentle', 'name': 'Bad', 'sequence': 902, 'delay_days': -5, 'tone': 'gentle',
}) })
def test_duplicate_sequence_rejected(self): def test_duplicate_sequence_rejected(self):

View File

@@ -0,0 +1,69 @@
"""Local LLM compat test for followup_text_generator.
Auto-detects LM Studio (:1234) or Ollama (:11434), skips when absent."""
import socket
from odoo.tests.common import TransactionCase
from odoo.tests import tagged
def _server_reachable(host, port, timeout=1.0):
try:
with socket.create_connection((host, port), timeout=timeout):
return True
except (OSError, socket.timeout):
return False
def _detect_local_llm():
for host, port, default_model in [
('host.docker.internal', 1234, 'local-model'),
('host.docker.internal', 11434, 'llama3.1:8b'),
('localhost', 1234, 'local-model'),
('localhost', 11434, 'llama3.1:8b'),
]:
if _server_reachable(host, port, timeout=0.5):
return (f'http://{host}:{port}/v1', default_model)
return (None, None)
@tagged('post_install', '-at_install', 'local_llm')
class TestLocalLLMFollowupText(TransactionCase):
def setUp(self):
super().setUp()
self.base_url, self.model = _detect_local_llm()
if not self.base_url:
self.skipTest("No local LLM server detected")
def test_followup_text_with_local_llm(self):
params = self.env['ir.config_parameter'].sudo()
prior = {k: params.get_param(k) for k in [
'fusion_accounting.openai_base_url',
'fusion_accounting.openai_model',
'fusion_accounting.provider.followup_text',
]}
params.set_param('fusion_accounting.openai_base_url', self.base_url)
params.set_param('fusion_accounting.openai_model', self.model)
params.set_param('fusion_accounting.openai_api_key', 'lm-studio')
params.set_param('fusion_accounting.provider.followup_text', 'openai')
try:
from odoo.addons.fusion_accounting_followup.services.followup_text_generator import (
generate_followup_text,
)
result = generate_followup_text(
self.env, partner_name='Acme Corp',
total_overdue=15000, currency_code='USD',
longest_overdue_days=45, tone='firm',
invoice_count=3,
risk_drivers=['8/12 invoices paid late', 'Avg 30 days late'],
)
self.assertIn('subject', result)
self.assertIn('body', result)
self.assertIn('tone_used', result)
finally:
for k, v in prior.items():
if v is not None:
params.set_param(k, v)

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,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,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>