diff --git a/fusion_accounting/PHASE_4_PLAN.md b/fusion_accounting/PHASE_4_PLAN.md new file mode 100644 index 00000000..17b66285 --- /dev/null +++ b/fusion_accounting/PHASE_4_PLAN.md @@ -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. diff --git a/fusion_accounting/__manifest__.py b/fusion_accounting/__manifest__.py index 7e388d1f..26078ee5 100644 --- a/fusion_accounting/__manifest__.py +++ b/fusion_accounting/__manifest__.py @@ -1,6 +1,6 @@ { 'name': 'Fusion Accounting', - 'version': '19.0.1.0.3', + 'version': '19.0.1.0.4', 'category': 'Accounting/Accounting', 'sequence': 25, 'summary': 'Meta-module that installs the full Fusion Accounting suite (core, AI, migration; bank rec, reports, etc. as later sub-modules ship).', @@ -16,10 +16,10 @@ Currently installs: - fusion_accounting_bank_rec AI-assisted bank reconciliation (Phase 1) - fusion_accounting_reports AI-augmented financial reports (Phase 2) - fusion_accounting_assets AI-augmented asset management (Phase 3) +- fusion_accounting_followup AI-augmented customer follow-ups (Phase 4) Future sub-modules (added per the roadmap as each Phase ships): -- fusion_accounting_dashboard (Phase 4) -- fusion_accounting_followup (Phase 5) +- fusion_accounting_dashboard (Phase 5) - fusion_accounting_budget (Phase 6) Built by Nexa Systems Inc. @@ -36,6 +36,7 @@ Built by Nexa Systems Inc. 'fusion_accounting_bank_rec', 'fusion_accounting_reports', 'fusion_accounting_assets', + 'fusion_accounting_followup', ], 'data': [], 'installable': True, diff --git a/fusion_accounting_ai/services/data_adapters/followup.py b/fusion_accounting_ai/services/data_adapters/followup.py index 067011f2..d165669e 100644 --- a/fusion_accounting_ai/services/data_adapters/followup.py +++ b/fusion_accounting_ai/services/data_adapters/followup.py @@ -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) diff --git a/fusion_accounting_ai/services/tools/__init__.py b/fusion_accounting_ai/services/tools/__init__.py index b2331b03..85339c05 100644 --- a/fusion_accounting_ai/services/tools/__init__.py +++ b/fusion_accounting_ai/services/tools/__init__.py @@ -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) diff --git a/fusion_accounting_ai/services/tools/customer_followup.py b/fusion_accounting_ai/services/tools/customer_followup.py new file mode 100644 index 00000000..1ef735d2 --- /dev/null +++ b/fusion_accounting_ai/services/tools/customer_followup.py @@ -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, +} diff --git a/fusion_accounting_followup/CLAUDE.md b/fusion_accounting_followup/CLAUDE.md new file mode 100644 index 00000000..f9ce5ce3 --- /dev/null +++ b/fusion_accounting_followup/CLAUDE.md @@ -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. diff --git a/fusion_accounting_followup/README.md b/fusion_accounting_followup/README.md new file mode 100644 index 00000000..02347676 --- /dev/null +++ b/fusion_accounting_followup/README.md @@ -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 diff --git a/fusion_accounting_followup/UPGRADE_NOTES.md b/fusion_accounting_followup/UPGRADE_NOTES.md new file mode 100644 index 00000000..a3193f49 --- /dev/null +++ b/fusion_accounting_followup/UPGRADE_NOTES.md @@ -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. diff --git a/fusion_accounting_followup/__init__.py b/fusion_accounting_followup/__init__.py new file mode 100644 index 00000000..9898e1c8 --- /dev/null +++ b/fusion_accounting_followup/__init__.py @@ -0,0 +1,5 @@ +from . import models +from . import services +from . import controllers +from . import wizards +from . import reports diff --git a/fusion_accounting_followup/__manifest__.py b/fusion_accounting_followup/__manifest__.py new file mode 100644 index 00000000..4c37bdaf --- /dev/null +++ b/fusion_accounting_followup/__manifest__.py @@ -0,0 +1,71 @@ +{ + 'name': 'Fusion Accounting Follow-up', + 'version': '19.0.1.0.30', + 'category': 'Accounting/Accounting', + 'summary': 'AI-augmented customer follow-ups (dunning) for unpaid invoices.', + 'description': """ +Fusion Accounting Follow-up +=========================== + +A Fusion-native replacement for Odoo Enterprise's account_followup module. + +CORE scope (Phase 4): +- Multi-level dunning sequences (gentle reminder, firm warning, legal) +- Per-partner follow-up state (current level, paused-until, history) +- Automated daily scan + escalation cron +- Mail templates per level + +AI augmentation: +- Contextually-appropriate follow-up text generation (LLM) +- Payment-risk scoring from invoice/payment history +- Tone selection (gentle/firm/legal) based on level + risk + +Coexists with Enterprise: when account_followup is installed, the Fusion +menu hides; the engine + AI tools remain available for the chat. +""", + 'author': 'Fusion Accounting', + 'license': 'LGPL-3', + 'depends': [ + 'fusion_accounting_core', + 'fusion_accounting_ai', + 'fusion_accounting_migration', + 'account', + 'mail', + ], + 'data': [ + 'security/ir.model.access.csv', + 'data/cron.xml', + 'data/followup_levels_data.xml', + 'data/mail_templates_data.xml', + 'wizards/batch_followup_wizard_views.xml', + 'views/menu_views.xml', + ], + 'assets': { + 'web.assets_backend': [ + 'fusion_accounting_followup/static/src/scss/_variables.scss', + 'fusion_accounting_followup/static/src/scss/followup.scss', + 'fusion_accounting_followup/static/src/scss/dark_mode.scss', + 'fusion_accounting_followup/static/src/services/followup_service.js', + 'fusion_accounting_followup/static/src/views/followup_dashboard/followup_dashboard.js', + 'fusion_accounting_followup/static/src/views/followup_dashboard/followup_dashboard.xml', + 'fusion_accounting_followup/static/src/views/followup_dashboard/followup_dashboard_view.js', + 'fusion_accounting_followup/static/src/components/risk_badge/risk_badge.js', + 'fusion_accounting_followup/static/src/components/risk_badge/risk_badge.xml', + 'fusion_accounting_followup/static/src/components/partner_card/partner_card.js', + 'fusion_accounting_followup/static/src/components/partner_card/partner_card.xml', + 'fusion_accounting_followup/static/src/components/aging_bucket_strip/aging_bucket_strip.js', + 'fusion_accounting_followup/static/src/components/aging_bucket_strip/aging_bucket_strip.xml', + 'fusion_accounting_followup/static/src/components/ai_text_panel/ai_text_panel.js', + 'fusion_accounting_followup/static/src/components/ai_text_panel/ai_text_panel.xml', + 'fusion_accounting_followup/static/src/components/followup_history_table/followup_history_table.js', + 'fusion_accounting_followup/static/src/components/followup_history_table/followup_history_table.xml', + ], + 'web.assets_tests': [ + 'fusion_accounting_followup/static/src/tours/followup_tours.js', + ], + }, + 'installable': True, + 'auto_install': False, + 'application': False, + 'icon': '/fusion_accounting_followup/static/description/icon.png', +} diff --git a/fusion_accounting_followup/controllers/__init__.py b/fusion_accounting_followup/controllers/__init__.py new file mode 100644 index 00000000..3f63b75a --- /dev/null +++ b/fusion_accounting_followup/controllers/__init__.py @@ -0,0 +1 @@ +from . import followup_controller diff --git a/fusion_accounting_followup/controllers/followup_controller.py b/fusion_accounting_followup/controllers/followup_controller.py new file mode 100644 index 00000000..6f349efc --- /dev/null +++ b/fusion_accounting_followup/controllers/followup_controller.py @@ -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) diff --git a/fusion_accounting_followup/data/cron.xml b/fusion_accounting_followup/data/cron.xml new file mode 100644 index 00000000..b28cee68 --- /dev/null +++ b/fusion_accounting_followup/data/cron.xml @@ -0,0 +1,24 @@ + + + + + Fusion Follow-up — Daily Scan + Send + + code + model._cron_daily_scan() + 1 + days + + + + + Fusion Follow-up — Weekly Risk Refresh + + code + model._cron_risk_refresh() + 7 + days + + + + diff --git a/fusion_accounting_followup/data/followup_levels_data.xml b/fusion_accounting_followup/data/followup_levels_data.xml new file mode 100644 index 00000000..10c1de73 --- /dev/null +++ b/fusion_accounting_followup/data/followup_levels_data.xml @@ -0,0 +1,32 @@ + + + + + Friendly Reminder + 1 + 7 + gentle + First contact - friendly reminder of overdue invoice. + + + + + Firm Warning + 2 + 30 + firm + Second contact - clear request for immediate action. + + + + + Legal Notice + 3 + 60 + legal + Final notice before referring to collections. + + + + + diff --git a/fusion_accounting_followup/data/mail_templates_data.xml b/fusion_accounting_followup/data/mail_templates_data.xml new file mode 100644 index 00000000..0552c850 --- /dev/null +++ b/fusion_accounting_followup/data/mail_templates_data.xml @@ -0,0 +1,85 @@ + + + + + Fusion Followup: Friendly Reminder + + Friendly reminder: invoice payment + {{ user.email_formatted }} + {{ object.email }} + +
+

Dear ,

+

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.

+

You can review your account statement at any time, or contact our + accounts receivable team with any questions.

+

Best regards,
+

+
+
+ {{ object.lang }} + +
+ + + Fusion Followup: Firm Warning + + Outstanding invoices — action required + {{ user.email_formatted }} + {{ object.email }} + +
+

Dear ,

+

Our records show outstanding invoices that require your immediate + attention. We request that you remit payment as soon as possible to + avoid further escalation.

+

If you have already remitted payment, please disregard this notice + and contact us with the payment details so we can update our records.

+

If there are any disputes or concerns regarding these invoices, + please contact our accounts receivable team immediately.

+

Regards,
+

+
+
+ {{ object.lang }} + +
+ + + Fusion Followup: Legal Notice + + FINAL NOTICE — outstanding balance + {{ user.email_formatted }} + {{ object.email }} + +
+

Dear ,

+

This is a FINAL NOTICE regarding outstanding invoices on your + account. Despite previous reminders, your balance remains unpaid.

+

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.

+

Please contact us immediately to resolve this matter.

+

Regards,
+

+
+
+ {{ object.lang }} + +
+ + + + + + + + + + + + +
diff --git a/fusion_accounting_followup/models/__init__.py b/fusion_accounting_followup/models/__init__.py new file mode 100644 index 00000000..df3570c7 --- /dev/null +++ b/fusion_accounting_followup/models/__init__.py @@ -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 diff --git a/fusion_accounting_followup/models/account_move_line.py b/fusion_accounting_followup/models/account_move_line.py new file mode 100644 index 00000000..369ce57a --- /dev/null +++ b/fusion_accounting_followup/models/account_move_line.py @@ -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.") diff --git a/fusion_accounting_followup/models/fusion_followup_cron.py b/fusion_accounting_followup/models/fusion_followup_cron.py new file mode 100644 index 00000000..f0c825b7 --- /dev/null +++ b/fusion_accounting_followup/models/fusion_followup_cron.py @@ -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) diff --git a/fusion_accounting_followup/models/fusion_followup_engine.py b/fusion_accounting_followup/models/fusion_followup_engine.py new file mode 100644 index 00000000..a674e3a3 --- /dev/null +++ b/fusion_accounting_followup/models/fusion_followup_engine.py @@ -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('<', '<').replace('>', '>') + self.env['mail.mail'].sudo().create({ + 'subject': run.subject or 'Follow-up', + 'body_html': '
{}
'.format(body_text), + 'email_to': partner.email, + 'recipient_ids': [(4, partner.id)], + }).send() + run.write({'sent_to_email': partner.email}) diff --git a/fusion_accounting_followup/models/fusion_followup_level.py b/fusion_accounting_followup/models/fusion_followup_level.py new file mode 100644 index 00000000..e2e5d9d2 --- /dev/null +++ b/fusion_accounting_followup/models/fusion_followup_level.py @@ -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.', + ) diff --git a/fusion_accounting_followup/models/fusion_followup_run.py b/fusion_accounting_followup/models/fusion_followup_run.py new file mode 100644 index 00000000..327039ea --- /dev/null +++ b/fusion_accounting_followup/models/fusion_followup_run.py @@ -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}) diff --git a/fusion_accounting_followup/models/fusion_followup_text_cache.py b/fusion_accounting_followup/models/fusion_followup_text_cache.py new file mode 100644 index 00000000..2c0eef40 --- /dev/null +++ b/fusion_accounting_followup/models/fusion_followup_text_cache.py @@ -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 diff --git a/fusion_accounting_followup/models/fusion_migration_wizard.py b/fusion_accounting_followup/models/fusion_migration_wizard.py new file mode 100644 index 00000000..f9ceec36 --- /dev/null +++ b/fusion_accounting_followup/models/fusion_migration_wizard.py @@ -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 diff --git a/fusion_accounting_followup/models/res_partner.py b/fusion_accounting_followup/models/res_partner.py new file mode 100644 index 00000000..2aaa5936 --- /dev/null +++ b/fusion_accounting_followup/models/res_partner.py @@ -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}, + } diff --git a/fusion_accounting_followup/reports/__init__.py b/fusion_accounting_followup/reports/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/fusion_accounting_followup/security/ir.model.access.csv b/fusion_accounting_followup/security/ir.model.access.csv new file mode 100644 index 00000000..fa38cd4d --- /dev/null +++ b/fusion_accounting_followup/security/ir.model.access.csv @@ -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 diff --git a/fusion_accounting_followup/services/__init__.py b/fusion_accounting_followup/services/__init__.py new file mode 100644 index 00000000..8a72cd93 --- /dev/null +++ b/fusion_accounting_followup/services/__init__.py @@ -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 diff --git a/fusion_accounting_followup/services/followup_text_generator.py b/fusion_accounting_followup/services/followup_text_generator.py new file mode 100644 index 00000000..66478f15 --- /dev/null +++ b/fusion_accounting_followup/services/followup_text_generator.py @@ -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 diff --git a/fusion_accounting_followup/services/followup_text_prompt.py b/fusion_accounting_followup/services/followup_text_prompt.py new file mode 100644 index 00000000..f3635c01 --- /dev/null +++ b/fusion_accounting_followup/services/followup_text_prompt.py @@ -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": "", + "body": " wrapper>", + "tone_used": "gentle" | "firm" | "legal", + "key_points": ["", "", ...] +} + +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)) diff --git a/fusion_accounting_followup/services/level_resolver.py b/fusion_accounting_followup/services/level_resolver.py new file mode 100644 index 00000000..0752816a --- /dev/null +++ b/fusion_accounting_followup/services/level_resolver.py @@ -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 diff --git a/fusion_accounting_followup/services/overdue_aging.py b/fusion_accounting_followup/services/overdue_aging.py new file mode 100644 index 00000000..6ce86cae --- /dev/null +++ b/fusion_accounting_followup/services/overdue_aging.py @@ -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 diff --git a/fusion_accounting_followup/services/risk_scorer.py b/fusion_accounting_followup/services/risk_scorer.py new file mode 100644 index 00000000..4db10909 --- /dev/null +++ b/fusion_accounting_followup/services/risk_scorer.py @@ -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) diff --git a/fusion_accounting_followup/services/tone_selector.py b/fusion_accounting_followup/services/tone_selector.py new file mode 100644 index 00000000..e77ecd3d --- /dev/null +++ b/fusion_accounting_followup/services/tone_selector.py @@ -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 diff --git a/fusion_accounting_followup/static/description/icon.png b/fusion_accounting_followup/static/description/icon.png new file mode 100644 index 00000000..6773c627 Binary files /dev/null and b/fusion_accounting_followup/static/description/icon.png differ diff --git a/fusion_accounting_followup/static/src/components/aging_bucket_strip/aging_bucket_strip.js b/fusion_accounting_followup/static/src/components/aging_bucket_strip/aging_bucket_strip.js new file mode 100644 index 00000000..c511a2fe --- /dev/null +++ b/fusion_accounting_followup/static/src/components/aging_bucket_strip/aging_bucket_strip.js @@ -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) + "%"; + } +} diff --git a/fusion_accounting_followup/static/src/components/aging_bucket_strip/aging_bucket_strip.xml b/fusion_accounting_followup/static/src/components/aging_bucket_strip/aging_bucket_strip.xml new file mode 100644 index 00000000..a48223c4 --- /dev/null +++ b/fusion_accounting_followup/static/src/components/aging_bucket_strip/aging_bucket_strip.xml @@ -0,0 +1,22 @@ + + + + +
+
+
+
+
+ Current + 30 + 60 + 90 + 120+ +
+
+ + + diff --git a/fusion_accounting_followup/static/src/components/ai_text_panel/ai_text_panel.js b/fusion_accounting_followup/static/src/components/ai_text_panel/ai_text_panel.js new file mode 100644 index 00000000..2c532d01 --- /dev/null +++ b/fusion_accounting_followup/static/src/components/ai_text_panel/ai_text_panel.js @@ -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 }, + }; +} diff --git a/fusion_accounting_followup/static/src/components/ai_text_panel/ai_text_panel.xml b/fusion_accounting_followup/static/src/components/ai_text_panel/ai_text_panel.xml new file mode 100644 index 00000000..6d64923b --- /dev/null +++ b/fusion_accounting_followup/static/src/components/ai_text_panel/ai_text_panel.xml @@ -0,0 +1,27 @@ + + + + +
+
AI-Generated Follow-up Text
+
+ Subject: +
+
+ +
+
+ Key points: +
    +
  • + +
  • +
+
+
+ Tone used: +
+
+
+ +
diff --git a/fusion_accounting_followup/static/src/components/followup_history_table/followup_history_table.js b/fusion_accounting_followup/static/src/components/followup_history_table/followup_history_table.js new file mode 100644 index 00000000..0c6a714d --- /dev/null +++ b/fusion_accounting_followup/static/src/components/followup_history_table/followup_history_table.js @@ -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); + } +} diff --git a/fusion_accounting_followup/static/src/components/followup_history_table/followup_history_table.xml b/fusion_accounting_followup/static/src/components/followup_history_table/followup_history_table.xml new file mode 100644 index 00000000..e4f83eba --- /dev/null +++ b/fusion_accounting_followup/static/src/components/followup_history_table/followup_history_table.xml @@ -0,0 +1,33 @@ + + + + +
+
Follow-up History ()
+ + + + + + + + + + + + + + + + + + + +
DateLevelToneStateOverdue
+ $ +
+
No history yet.
+
+
+ +
diff --git a/fusion_accounting_followup/static/src/components/partner_card/partner_card.js b/fusion_accounting_followup/static/src/components/partner_card/partner_card.js new file mode 100644 index 00000000..a8467048 --- /dev/null +++ b/fusion_accounting_followup/static/src/components/partner_card/partner_card.js @@ -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 }; +} diff --git a/fusion_accounting_followup/static/src/components/partner_card/partner_card.xml b/fusion_accounting_followup/static/src/components/partner_card/partner_card.xml new file mode 100644 index 00000000..a83d6c1a --- /dev/null +++ b/fusion_accounting_followup/static/src/components/partner_card/partner_card.xml @@ -0,0 +1,37 @@ + + + + +
+
+
+
+ + + +
+
+
+
+ Overdue: + $ +
+
+ Lines: + +
+
+ Risk: + +
+
+ Last: + +
+
+
+
+ +
diff --git a/fusion_accounting_followup/static/src/components/risk_badge/risk_badge.js b/fusion_accounting_followup/static/src/components/risk_badge/risk_badge.js new file mode 100644 index 00000000..03260b78 --- /dev/null +++ b/fusion_accounting_followup/static/src/components/risk_badge/risk_badge.js @@ -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 }, + }; +} diff --git a/fusion_accounting_followup/static/src/components/risk_badge/risk_badge.xml b/fusion_accounting_followup/static/src/components/risk_badge/risk_badge.xml new file mode 100644 index 00000000..3e6fe303 --- /dev/null +++ b/fusion_accounting_followup/static/src/components/risk_badge/risk_badge.xml @@ -0,0 +1,11 @@ + + + + + + + () + + + + diff --git a/fusion_accounting_followup/static/src/scss/_variables.scss b/fusion_accounting_followup/static/src/scss/_variables.scss new file mode 100644 index 00000000..cb3d8f85 --- /dev/null +++ b/fusion_accounting_followup/static/src/scss/_variables.scss @@ -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; diff --git a/fusion_accounting_followup/static/src/scss/dark_mode.scss b/fusion_accounting_followup/static/src/scss/dark_mode.scss new file mode 100644 index 00000000..25950570 --- /dev/null +++ b/fusion_accounting_followup/static/src/scss/dark_mode.scss @@ -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; } + } +} diff --git a/fusion_accounting_followup/static/src/scss/followup.scss b/fusion_accounting_followup/static/src/scss/followup.scss new file mode 100644 index 00000000..8962e3dd --- /dev/null +++ b/fusion_accounting_followup/static/src/scss/followup.scss @@ -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%); + } +} diff --git a/fusion_accounting_followup/static/src/services/followup_service.js b/fusion_accounting_followup/static/src/services/followup_service.js new file mode 100644 index 00000000..03d3c659 --- /dev/null +++ b/fusion_accounting_followup/static/src/services/followup_service.js @@ -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); diff --git a/fusion_accounting_followup/static/src/tours/followup_tours.js b/fusion_accounting_followup/static/src/tours/followup_tours.js new file mode 100644 index 00000000..35c7617f --- /dev/null +++ b/fusion_accounting_followup/static/src/tours/followup_tours.js @@ -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" }, + ], +}); diff --git a/fusion_accounting_followup/static/src/views/followup_dashboard/followup_dashboard.js b/fusion_accounting_followup/static/src/views/followup_dashboard/followup_dashboard.js new file mode 100644 index 00000000..274b16be --- /dev/null +++ b/fusion_accounting_followup/static/src/views/followup_dashboard/followup_dashboard.js @@ -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); + } +} diff --git a/fusion_accounting_followup/static/src/views/followup_dashboard/followup_dashboard.xml b/fusion_accounting_followup/static/src/views/followup_dashboard/followup_dashboard.xml new file mode 100644 index 00000000..1af84ada --- /dev/null +++ b/fusion_accounting_followup/static/src/views/followup_dashboard/followup_dashboard.xml @@ -0,0 +1,65 @@ + + + + +
+
+
+

Customer Follow-ups

+
of partners with overdue
+
+
+
Total overdue: $
+
+
+ +
+ + + + +
+ +
+
+
Loading...
+
No overdue partners.
+
+ +
+
+
+
+

+
+ +
+
+ +
+ +
+ + + + +
+ + +
+
Select a partner.
+
+
+
+
+ +
diff --git a/fusion_accounting_followup/static/src/views/followup_dashboard/followup_dashboard_view.js b/fusion_accounting_followup/static/src/views/followup_dashboard/followup_dashboard_view.js new file mode 100644 index 00000000..5e22b538 --- /dev/null +++ b/fusion_accounting_followup/static/src/views/followup_dashboard/followup_dashboard_view.js @@ -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); diff --git a/fusion_accounting_followup/tests/__init__.py b/fusion_accounting_followup/tests/__init__.py new file mode 100644 index 00000000..208a9ae0 --- /dev/null +++ b/fusion_accounting_followup/tests/__init__.py @@ -0,0 +1,24 @@ +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 +from . import test_followup_tours +from . import test_local_llm_compat diff --git a/fusion_accounting_followup/tests/test_account_move_line_inherit.py b/fusion_accounting_followup/tests/test_account_move_line_inherit.py new file mode 100644 index 00000000..9860dd27 --- /dev/null +++ b/fusion_accounting_followup/tests/test_account_move_line_inherit.py @@ -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) diff --git a/fusion_accounting_followup/tests/test_batch_followup_wizard.py b/fusion_accounting_followup/tests/test_batch_followup_wizard.py new file mode 100644 index 00000000..dc35f275 --- /dev/null +++ b/fusion_accounting_followup/tests/test_batch_followup_wizard.py @@ -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) diff --git a/fusion_accounting_followup/tests/test_coexistence.py b/fusion_accounting_followup/tests/test_coexistence.py new file mode 100644 index 00000000..443b05ca --- /dev/null +++ b/fusion_accounting_followup/tests/test_coexistence.py @@ -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) diff --git a/fusion_accounting_followup/tests/test_engine_integration.py b/fusion_accounting_followup/tests/test_engine_integration.py new file mode 100644 index 00000000..37bf69b5 --- /dev/null +++ b/fusion_accounting_followup/tests/test_engine_integration.py @@ -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") diff --git a/fusion_accounting_followup/tests/test_engine_property.py b/fusion_accounting_followup/tests/test_engine_property.py new file mode 100644 index 00000000..99b6679d --- /dev/null +++ b/fusion_accounting_followup/tests/test_engine_property.py @@ -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')) diff --git a/fusion_accounting_followup/tests/test_followup_adapter.py b/fusion_accounting_followup/tests/test_followup_adapter.py new file mode 100644 index 00000000..1b8bfaae --- /dev/null +++ b/fusion_accounting_followup/tests/test_followup_adapter.py @@ -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) diff --git a/fusion_accounting_followup/tests/test_followup_controller.py b/fusion_accounting_followup/tests/test_followup_controller.py new file mode 100644 index 00000000..df538ec2 --- /dev/null +++ b/fusion_accounting_followup/tests/test_followup_controller.py @@ -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')) diff --git a/fusion_accounting_followup/tests/test_followup_cron.py b/fusion_accounting_followup/tests/test_followup_cron.py new file mode 100644 index 00000000..13d815c9 --- /dev/null +++ b/fusion_accounting_followup/tests/test_followup_cron.py @@ -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() diff --git a/fusion_accounting_followup/tests/test_followup_full_flow.py b/fusion_accounting_followup/tests/test_followup_full_flow.py new file mode 100644 index 00000000..8a65e12f --- /dev/null +++ b/fusion_accounting_followup/tests/test_followup_full_flow.py @@ -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) diff --git a/fusion_accounting_followup/tests/test_followup_text_generator.py b/fusion_accounting_followup/tests/test_followup_text_generator.py new file mode 100644 index 00000000..a8e62819 --- /dev/null +++ b/fusion_accounting_followup/tests/test_followup_text_generator.py @@ -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) diff --git a/fusion_accounting_followup/tests/test_followup_tools.py b/fusion_accounting_followup/tests/test_followup_tools.py new file mode 100644 index 00000000..ad4200d2 --- /dev/null +++ b/fusion_accounting_followup/tests/test_followup_tools.py @@ -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", + ) diff --git a/fusion_accounting_followup/tests/test_followup_tours.py b/fusion_accounting_followup/tests/test_followup_tours.py new file mode 100644 index 00000000..f3eec133 --- /dev/null +++ b/fusion_accounting_followup/tests/test_followup_tours.py @@ -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") diff --git a/fusion_accounting_followup/tests/test_fusion_followup_engine.py b/fusion_accounting_followup/tests/test_fusion_followup_engine.py new file mode 100644 index 00000000..41ef9a6d --- /dev/null +++ b/fusion_accounting_followup/tests/test_fusion_followup_engine.py @@ -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) diff --git a/fusion_accounting_followup/tests/test_fusion_followup_level.py b/fusion_accounting_followup/tests/test_fusion_followup_level.py new file mode 100644 index 00000000..4cde95e3 --- /dev/null +++ b/fusion_accounting_followup/tests/test_fusion_followup_level.py @@ -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']) diff --git a/fusion_accounting_followup/tests/test_fusion_followup_run.py b/fusion_accounting_followup/tests/test_fusion_followup_run.py new file mode 100644 index 00000000..25e5c7c1 --- /dev/null +++ b/fusion_accounting_followup/tests/test_fusion_followup_run.py @@ -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, + }) diff --git a/fusion_accounting_followup/tests/test_fusion_followup_text_cache.py b/fusion_accounting_followup/tests/test_fusion_followup_text_cache.py new file mode 100644 index 00000000..a7e1b6fc --- /dev/null +++ b/fusion_accounting_followup/tests/test_fusion_followup_text_cache.py @@ -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) diff --git a/fusion_accounting_followup/tests/test_level_resolver.py b/fusion_accounting_followup/tests/test_level_resolver.py new file mode 100644 index 00000000..12a8f35c --- /dev/null +++ b/fusion_accounting_followup/tests/test_level_resolver.py @@ -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') diff --git a/fusion_accounting_followup/tests/test_local_llm_compat.py b/fusion_accounting_followup/tests/test_local_llm_compat.py new file mode 100644 index 00000000..1a46b01f --- /dev/null +++ b/fusion_accounting_followup/tests/test_local_llm_compat.py @@ -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) diff --git a/fusion_accounting_followup/tests/test_migration_round_trip.py b/fusion_accounting_followup/tests/test_migration_round_trip.py new file mode 100644 index 00000000..6dce5510 --- /dev/null +++ b/fusion_accounting_followup/tests/test_migration_round_trip.py @@ -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']) diff --git a/fusion_accounting_followup/tests/test_overdue_aging.py b/fusion_accounting_followup/tests/test_overdue_aging.py new file mode 100644 index 00000000..c1b620bd --- /dev/null +++ b/fusion_accounting_followup/tests/test_overdue_aging.py @@ -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) diff --git a/fusion_accounting_followup/tests/test_performance_benchmarks.py b/fusion_accounting_followup/tests/test_performance_benchmarks.py new file mode 100644 index 00000000..19dbf0e4 --- /dev/null +++ b/fusion_accounting_followup/tests/test_performance_benchmarks.py @@ -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) diff --git a/fusion_accounting_followup/tests/test_res_partner_inherit.py b/fusion_accounting_followup/tests/test_res_partner_inherit.py new file mode 100644 index 00000000..fa77651e --- /dev/null +++ b/fusion_accounting_followup/tests/test_res_partner_inherit.py @@ -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) diff --git a/fusion_accounting_followup/tests/test_risk_scorer.py b/fusion_accounting_followup/tests/test_risk_scorer.py new file mode 100644 index 00000000..93d00cdb --- /dev/null +++ b/fusion_accounting_followup/tests/test_risk_scorer.py @@ -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) diff --git a/fusion_accounting_followup/tests/test_tone_selector.py b/fusion_accounting_followup/tests/test_tone_selector.py new file mode 100644 index 00000000..f7df4f63 --- /dev/null +++ b/fusion_accounting_followup/tests/test_tone_selector.py @@ -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') diff --git a/fusion_accounting_followup/views/menu_views.xml b/fusion_accounting_followup/views/menu_views.xml new file mode 100644 index 00000000..5d65d199 --- /dev/null +++ b/fusion_accounting_followup/views/menu_views.xml @@ -0,0 +1,69 @@ + + + + + + + + Overdue Customers + res.partner + list,form + [('fusion_followup_status', 'in', ('action_due', 'paused', 'blocked', 'with_credit_team'))] + {} + +

+ Customer follow-ups +

+

+ AI-augmented dunning sequences for unpaid invoices. +

+
+
+ + + + + + Follow-up Levels + fusion.followup.level + list,form + + + + + + + Follow-up History + fusion.followup.run + list,form + + + + + + +
diff --git a/fusion_accounting_followup/wizards/__init__.py b/fusion_accounting_followup/wizards/__init__.py new file mode 100644 index 00000000..a388a168 --- /dev/null +++ b/fusion_accounting_followup/wizards/__init__.py @@ -0,0 +1 @@ +from . import batch_followup_wizard diff --git a/fusion_accounting_followup/wizards/batch_followup_wizard.py b/fusion_accounting_followup/wizards/batch_followup_wizard.py new file mode 100644 index 00000000..44a0ddb6 --- /dev/null +++ b/fusion_accounting_followup/wizards/batch_followup_wizard.py @@ -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', + } diff --git a/fusion_accounting_followup/wizards/batch_followup_wizard_views.xml b/fusion_accounting_followup/wizards/batch_followup_wizard_views.xml new file mode 100644 index 00000000..538a720b --- /dev/null +++ b/fusion_accounting_followup/wizards/batch_followup_wizard_views.xml @@ -0,0 +1,44 @@ + + + + fusion.batch.followup.wizard.form + fusion.batch.followup.wizard + +
+ + + + + + + + + + + + + + +
+
+ +
+
+ + + Batch Send Follow-ups + fusion.batch.followup.wizard + form + new + + list + +