changes
This commit is contained in:
BIN
fusion_accounting/fusion_accounting_followup/.DS_Store
vendored
Normal file
BIN
fusion_accounting/fusion_accounting_followup/.DS_Store
vendored
Normal file
Binary file not shown.
142
fusion_accounting/fusion_accounting_followup/CLAUDE.md
Normal file
142
fusion_accounting/fusion_accounting_followup/CLAUDE.md
Normal file
@@ -0,0 +1,142 @@
|
||||
# fusion_accounting_followup — Cursor / Claude Context
|
||||
|
||||
## Purpose
|
||||
|
||||
AI-augmented customer follow-ups (dunning) — a Fusion-native replacement
|
||||
for (and coexisting with) Odoo Enterprise's `account_followup` module.
|
||||
Ships in Phase 4 of the fusion_accounting roadmap.
|
||||
|
||||
## Architecture
|
||||
|
||||
Hybrid: the engine (`fusion.followup.engine`, AbstractModel) is the
|
||||
SINGLE write surface for the follow-up lifecycle. Everything else
|
||||
(controllers, OWL components, AI tools, wizards, cron) routes through
|
||||
the engine's 7-method public API:
|
||||
|
||||
- `get_overdue_for_partner(partner)`
|
||||
- `compute_followup_level(partner)`
|
||||
- `send_followup_email(partner, level=None, force=False)`
|
||||
- `escalate_to_next_level(partner)`
|
||||
- `pause_followup(partner, until_date=None)`
|
||||
- `reset_followup(partner)`
|
||||
- `snapshot_followup_history(partner, limit=50)`
|
||||
|
||||
Pure-Python services live in `services/`:
|
||||
|
||||
- `overdue_aging` — 6 buckets (current, 1-30, 31-60, 61-90, 91-120, 120+)
|
||||
- `level_resolver` — match aging to a `fusion.followup.level`
|
||||
- `risk_scorer` — 0-100 payment-risk score plus structured drivers
|
||||
- `tone_selector` — gentle / firm / legal based on level + risk
|
||||
- `followup_text_generator` + `followup_text_prompt` — LLM-generated
|
||||
follow-up text with a templated fallback that keeps the feature
|
||||
usable offline
|
||||
|
||||
Persisted models in `models/`:
|
||||
|
||||
- `fusion.followup.level` — level definition (delay_days, tone,
|
||||
mail_template_id, requires_manual_review, sequence)
|
||||
- `fusion.followup.run` — per-partner audit record (state, level,
|
||||
amount, ai-generated flag, error captured)
|
||||
- `fusion.followup.text.cache` — LLM cost-saving cache keyed on
|
||||
(partner, level, tone, prompt fingerprint)
|
||||
- `fusion.followup.engine` — AbstractModel (the API)
|
||||
- `fusion.followup.cron` — cron handlers (daily scan, weekly risk refresh)
|
||||
- `res.partner` (inherits) — adds `fusion_followup_status`,
|
||||
`fusion_followup_paused_until`, `fusion_followup_last_level_id`,
|
||||
`fusion_followup_risk_score`, `fusion_followup_risk_band`
|
||||
- `account.move.line` (inherits) — adds `fusion_followup_level_id` and
|
||||
`fusion_followup_last_run_date`
|
||||
|
||||
Wizards (TransientModel) in `wizards/`:
|
||||
|
||||
- `fusion.batch.followup.wizard` — bulk-send across all overdue
|
||||
customers, a manual selection, or a level-filtered subset; supports
|
||||
`auto_resolve_level`, `override_level_id`, and `force` flags
|
||||
|
||||
Controllers: `controllers/followup_controller.py` exposes 6 JSON-RPC
|
||||
endpoints under `/fusion/followup/*` (`list_overdue`, `get_partner`,
|
||||
`compute_level`, `send`, `escalate`, `pause`, `reset`, `history`,
|
||||
`generate_text`). All calls route through the engine.
|
||||
|
||||
OWL frontend: `static/src/`
|
||||
|
||||
- `services/followup_service.js` — central reactive state + RPC wrappers
|
||||
- `views/followup_dashboard/*` — top-level dashboard view
|
||||
- `components/risk_badge`, `partner_card`, `aging_bucket_strip`,
|
||||
`ai_text_panel`, `followup_history_table` — 5 components
|
||||
- `scss/_variables.scss` + `followup.scss` + `dark_mode.scss`
|
||||
- `tours/followup_tours.js` — 5 OWL tour smoke tests
|
||||
|
||||
Default data:
|
||||
|
||||
- `data/followup_levels_data.xml` — 3 default levels
|
||||
(Reminder @ 7d gentle, Warning @ 30d firm, Legal Notice @ 60d legal)
|
||||
- `data/mail_templates_data.xml` — 3 mail templates wired to the levels
|
||||
- `data/cron.xml` — daily scan + weekly risk refresh
|
||||
|
||||
## Coexistence
|
||||
|
||||
When `account_followup` is installed the Customer Follow-ups menu hides
|
||||
via `fusion_accounting_core.group_fusion_show_when_enterprise_absent`.
|
||||
The engine + AI tools always remain available for the chat / API. The
|
||||
migration step in `fusion.migration.wizard` backfills
|
||||
`fusion.followup.level` records from existing
|
||||
`account_followup.followup.line` rows (idempotent — skips rows already
|
||||
linked via the `legacy_followup_line_id` column).
|
||||
|
||||
## V19 Conventions Applied
|
||||
|
||||
- `_sql_constraints` → `models.Constraint` (every persisted model)
|
||||
- `@api.depends('id')` → not used (would raise `NotImplementedError`)
|
||||
- `@route(type='json')` → `type='jsonrpc'` (all 6 endpoints in
|
||||
`controllers/followup_controller.py`)
|
||||
- `numbercall` removed from `ir.cron` (data/cron.xml)
|
||||
- `res.groups.users` → `user_ids` and `ir.ui.menu.groups_id` →
|
||||
`group_ids` (security + menu_views.xml)
|
||||
- SCSS: `@import "variables"` is forbidden in V19; rely on manifest
|
||||
asset concatenation order (`_variables.scss` first)
|
||||
- OWL `t-on-click` arrow handlers must use an explicit `this.` reference
|
||||
|
||||
## Performance baseline (Task 21)
|
||||
|
||||
| Operation | P95 | Budget |
|
||||
|----------------------------------------|-------|----------|
|
||||
| `engine.compute_followup_level` | 0ms | 50ms |
|
||||
| `engine.get_overdue_for_partner` | 1ms | 100ms |
|
||||
| `engine.send_followup_email` (no due) | 0ms | 200ms |
|
||||
| `controller.list_overdue` (20 ptrs) | 100ms | 500ms |
|
||||
|
||||
(Engine ops measured against partners with no overdue lines — these are
|
||||
floor measurements; load-driven scaling is verified in
|
||||
`test_performance_benchmarks.py`.) All Phase 4 perf metrics are within
|
||||
1x of budget; no optimization needed at ship.
|
||||
|
||||
## Test counts (Phase 4 ship)
|
||||
|
||||
- 106 logical tests in `fusion_accounting_followup`
|
||||
- 0 failures, 0 errors
|
||||
- Coverage includes: 4 engine + 1 controller benchmark (tagged
|
||||
`benchmark`), 1 local LLM smoke (tagged `local_llm`, skips when no
|
||||
LLM), 5 OWL tour tests (tagged `tour`, skip without
|
||||
websocket-client), Hypothesis property tests on the engine,
|
||||
integration tests on the public API, controller round-trip tests,
|
||||
cron tests, batch wizard tests, coexistence tests, migration
|
||||
round-trip test.
|
||||
|
||||
## Known concerns / Phase 4.5 backlog
|
||||
|
||||
- `risk_scorer._compute_risk` `paid_late_count` and `avg_days_late` are
|
||||
placeholders; full reconciliation traversal deferred for performance.
|
||||
- Migration tone heuristic could misclassify Enterprise levels with
|
||||
non-standard sequence numbers (numeric sequence outside 1/10/100
|
||||
buckets).
|
||||
- `pause_followup` / `reset_followup` do not `sudo()` the partner
|
||||
write — could fail for non-admin users without partner-write rights.
|
||||
- Email send is best-effort — failure is captured on the
|
||||
`fusion.followup.run` record but does not raise.
|
||||
- `followup_text_generator` always returns a usable dict (templated
|
||||
fallback when LLM absent), so callers can't distinguish "AI said so"
|
||||
from "fallback fired"; the `tone_used` and absence of `key_points`
|
||||
are the only signals.
|
||||
- Sub-second SLA on `controller.list_overdue` for partner counts > 200
|
||||
is not yet stress-tested.
|
||||
66
fusion_accounting/fusion_accounting_followup/README.md
Normal file
66
fusion_accounting/fusion_accounting_followup/README.md
Normal file
@@ -0,0 +1,66 @@
|
||||
# fusion_accounting_followup
|
||||
|
||||
AI-augmented customer follow-ups (dunning) for Odoo 19 Community — a
|
||||
Fusion-native replacement for Enterprise's `account_followup` module.
|
||||
|
||||
## What it does
|
||||
|
||||
- Multi-level dunning sequences (gentle reminder, firm warning, legal
|
||||
notice) with delay-day cadence per level
|
||||
- 6-bucket aging analysis (current, 1-30, 31-60, 61-90, 91-120, 120+)
|
||||
per customer
|
||||
- Per-partner follow-up state machine (`current`, `action_due`,
|
||||
`paused`, `blocked`, `with_credit_team`)
|
||||
- Daily cron that scans overdue customers and queues / sends follow-ups
|
||||
- Weekly cron that refreshes the AI risk score on every overdue customer
|
||||
- Mail templates per level, with per-partner context interpolation
|
||||
- Batch wizard for bulk-send across all overdue customers, an
|
||||
arbitrary selection, or a level-filtered subset
|
||||
- Per-partner follow-up history with state, level, and amount audit
|
||||
- AI augmentation:
|
||||
- **Payment-risk scoring** — 0-100 score plus structured drivers
|
||||
(paid-late ratio, longest-overdue band, recent dispute, etc.)
|
||||
- **Tone selection** — gentle / firm / legal based on level + risk
|
||||
- **Follow-up text generation** — LLM-driven subject + body keyed
|
||||
on tone, with a templated keyword fallback so the feature still
|
||||
works offline
|
||||
- Coexists with Enterprise `account_followup` (Enterprise wins by
|
||||
default; the Fusion menu only appears when Enterprise is uninstalled)
|
||||
- Migration-aware: bootstrap step backfills `fusion.followup.level`
|
||||
records from existing `account_followup.followup.line` rows so the AI
|
||||
has memory from day 1
|
||||
|
||||
## Quick start
|
||||
|
||||
```bash
|
||||
# Install (sub-module)
|
||||
odoo --addons-path=... -i fusion_accounting_followup
|
||||
|
||||
# Or install the whole suite via the meta-module
|
||||
odoo --addons-path=... -i fusion_accounting
|
||||
|
||||
# Open the dashboard (when Enterprise's account_followup is NOT installed)
|
||||
# Apps -> Customer Follow-ups -> Overdue Customers
|
||||
|
||||
# When Enterprise IS installed: use Enterprise's UI; the engine + AI tools
|
||||
# are still available via the AI chat.
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
- Local LLM (LM Studio, Ollama):
|
||||
- `fusion_accounting.openai_base_url` =
|
||||
`http://host.docker.internal:1234/v1`
|
||||
- `fusion_accounting.openai_model` = your local model name
|
||||
- `fusion_accounting.openai_api_key` = `lm-studio` (anything non-empty)
|
||||
- `fusion_accounting.provider.followup_text` = `openai`
|
||||
|
||||
## Public API (engine)
|
||||
|
||||
`fusion.followup.engine` is the single write surface. See `CLAUDE.md`
|
||||
for the full 7-method signature list.
|
||||
|
||||
## See also
|
||||
|
||||
- `CLAUDE.md` — agent context
|
||||
- `UPGRADE_NOTES.md` — Odoo version anchoring
|
||||
@@ -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.
|
||||
5
fusion_accounting/fusion_accounting_followup/__init__.py
Normal file
5
fusion_accounting/fusion_accounting_followup/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
from . import models
|
||||
from . import services
|
||||
from . import controllers
|
||||
from . import wizards
|
||||
from . import reports
|
||||
70
fusion_accounting/fusion_accounting_followup/__manifest__.py
Normal file
70
fusion_accounting/fusion_accounting_followup/__manifest__.py
Normal file
@@ -0,0 +1,70 @@
|
||||
{
|
||||
'name': 'Fusion Accounting Follow-up',
|
||||
'version': '19.0.1.1.1',
|
||||
'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/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',
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
from . import followup_controller
|
||||
@@ -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)
|
||||
24
fusion_accounting/fusion_accounting_followup/data/cron.xml
Normal file
24
fusion_accounting/fusion_accounting_followup/data/cron.xml
Normal file
@@ -0,0 +1,24 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
|
||||
<record id="cron_fusion_followup_daily_scan" model="ir.cron">
|
||||
<field name="name">Fusion Follow-up — Daily Scan + Send</field>
|
||||
<field name="model_id" ref="model_fusion_followup_cron"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_daily_scan()</field>
|
||||
<field name="interval_number">1</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="cron_fusion_followup_risk_refresh" model="ir.cron">
|
||||
<field name="name">Fusion Follow-up — Weekly Risk Refresh</field>
|
||||
<field name="model_id" ref="model_fusion_followup_cron"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_risk_refresh()</field>
|
||||
<field name="interval_number">7</field>
|
||||
<field name="interval_type">days</field>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,32 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
|
||||
<record id="level_reminder" model="fusion.followup.level">
|
||||
<field name="name">Friendly Reminder</field>
|
||||
<field name="sequence">1</field>
|
||||
<field name="delay_days">7</field>
|
||||
<field name="tone">gentle</field>
|
||||
<field name="description">First contact - friendly reminder of overdue invoice.</field>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="level_warning" model="fusion.followup.level">
|
||||
<field name="name">Firm Warning</field>
|
||||
<field name="sequence">2</field>
|
||||
<field name="delay_days">30</field>
|
||||
<field name="tone">firm</field>
|
||||
<field name="description">Second contact - clear request for immediate action.</field>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="level_legal_notice" model="fusion.followup.level">
|
||||
<field name="name">Legal Notice</field>
|
||||
<field name="sequence">3</field>
|
||||
<field name="delay_days">60</field>
|
||||
<field name="tone">legal</field>
|
||||
<field name="description">Final notice before referring to collections.</field>
|
||||
<field name="requires_manual_review" eval="True"/>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,85 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo noupdate="1">
|
||||
|
||||
<record id="email_template_followup_gentle" model="mail.template">
|
||||
<field name="name">Fusion Followup: Friendly Reminder</field>
|
||||
<field name="model_id" ref="base.model_res_partner"/>
|
||||
<field name="subject">Friendly reminder: invoice payment</field>
|
||||
<field name="email_from">{{ user.email_formatted }}</field>
|
||||
<field name="email_to">{{ object.email }}</field>
|
||||
<field name="body_html" type="html">
|
||||
<div>
|
||||
<p>Dear <t t-out="object.name"/>,</p>
|
||||
<p>This is a friendly reminder that you have outstanding invoices on
|
||||
your account. We understand that things happen — please let us know
|
||||
if there is anything we can do to help resolve this.</p>
|
||||
<p>You can review your account statement at any time, or contact our
|
||||
accounts receivable team with any questions.</p>
|
||||
<p>Best regards,<br/>
|
||||
<t t-out="user.company_id.name"/></p>
|
||||
</div>
|
||||
</field>
|
||||
<field name="lang">{{ object.lang }}</field>
|
||||
<field name="auto_delete" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="email_template_followup_firm" model="mail.template">
|
||||
<field name="name">Fusion Followup: Firm Warning</field>
|
||||
<field name="model_id" ref="base.model_res_partner"/>
|
||||
<field name="subject">Outstanding invoices — action required</field>
|
||||
<field name="email_from">{{ user.email_formatted }}</field>
|
||||
<field name="email_to">{{ object.email }}</field>
|
||||
<field name="body_html" type="html">
|
||||
<div>
|
||||
<p>Dear <t t-out="object.name"/>,</p>
|
||||
<p>Our records show outstanding invoices that require your immediate
|
||||
attention. We request that you remit payment as soon as possible to
|
||||
avoid further escalation.</p>
|
||||
<p>If you have already remitted payment, please disregard this notice
|
||||
and contact us with the payment details so we can update our records.</p>
|
||||
<p>If there are any disputes or concerns regarding these invoices,
|
||||
please contact our accounts receivable team immediately.</p>
|
||||
<p>Regards,<br/>
|
||||
<t t-out="user.company_id.name"/></p>
|
||||
</div>
|
||||
</field>
|
||||
<field name="lang">{{ object.lang }}</field>
|
||||
<field name="auto_delete" eval="True"/>
|
||||
</record>
|
||||
|
||||
<record id="email_template_followup_legal" model="mail.template">
|
||||
<field name="name">Fusion Followup: Legal Notice</field>
|
||||
<field name="model_id" ref="base.model_res_partner"/>
|
||||
<field name="subject">FINAL NOTICE — outstanding balance</field>
|
||||
<field name="email_from">{{ user.email_formatted }}</field>
|
||||
<field name="email_to">{{ object.email }}</field>
|
||||
<field name="body_html" type="html">
|
||||
<div>
|
||||
<p>Dear <t t-out="object.name"/>,</p>
|
||||
<p>This is a FINAL NOTICE regarding outstanding invoices on your
|
||||
account. Despite previous reminders, your balance remains unpaid.</p>
|
||||
<p>If full payment is not received within 7 days from the date of this
|
||||
notice, we will be forced to refer this matter to our legal department
|
||||
for collection. This may include reporting the delinquency to credit
|
||||
bureaus and pursuing further legal action as permitted by law.</p>
|
||||
<p>Please contact us immediately to resolve this matter.</p>
|
||||
<p>Regards,<br/>
|
||||
<t t-out="user.company_id.name"/></p>
|
||||
</div>
|
||||
</field>
|
||||
<field name="lang">{{ object.lang }}</field>
|
||||
<field name="auto_delete" eval="True"/>
|
||||
</record>
|
||||
|
||||
<!-- Wire templates to default levels -->
|
||||
<record id="level_reminder" model="fusion.followup.level">
|
||||
<field name="mail_template_id" ref="email_template_followup_gentle"/>
|
||||
</record>
|
||||
<record id="level_warning" model="fusion.followup.level">
|
||||
<field name="mail_template_id" ref="email_template_followup_firm"/>
|
||||
</record>
|
||||
<record id="level_legal_notice" model="fusion.followup.level">
|
||||
<field name="mail_template_id" ref="email_template_followup_legal"/>
|
||||
</record>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,294 @@
|
||||
# Graph Report - /Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup (2026-04-22)
|
||||
|
||||
## Corpus Check
|
||||
- 57 files · ~13,245 words
|
||||
- Verdict: corpus is large enough that graph structure adds value.
|
||||
|
||||
## Summary
|
||||
- 354 nodes · 497 edges · 40 communities detected
|
||||
- Extraction: 73% EXTRACTED · 27% INFERRED · 0% AMBIGUOUS · INFERRED: 132 edges (avg confidence: 0.76)
|
||||
- Token cost: 0 input · 0 output
|
||||
|
||||
## Community Hubs (Navigation)
|
||||
- [[_COMMUNITY_Community 0|Community 0]]
|
||||
- [[_COMMUNITY_Community 1|Community 1]]
|
||||
- [[_COMMUNITY_Community 2|Community 2]]
|
||||
- [[_COMMUNITY_Community 3|Community 3]]
|
||||
- [[_COMMUNITY_Community 4|Community 4]]
|
||||
- [[_COMMUNITY_Community 5|Community 5]]
|
||||
- [[_COMMUNITY_Community 6|Community 6]]
|
||||
- [[_COMMUNITY_Community 7|Community 7]]
|
||||
- [[_COMMUNITY_Community 8|Community 8]]
|
||||
- [[_COMMUNITY_Community 9|Community 9]]
|
||||
- [[_COMMUNITY_Community 10|Community 10]]
|
||||
- [[_COMMUNITY_Community 11|Community 11]]
|
||||
- [[_COMMUNITY_Community 12|Community 12]]
|
||||
- [[_COMMUNITY_Community 13|Community 13]]
|
||||
- [[_COMMUNITY_Community 14|Community 14]]
|
||||
- [[_COMMUNITY_Community 15|Community 15]]
|
||||
- [[_COMMUNITY_Community 16|Community 16]]
|
||||
- [[_COMMUNITY_Community 17|Community 17]]
|
||||
- [[_COMMUNITY_Community 18|Community 18]]
|
||||
- [[_COMMUNITY_Community 19|Community 19]]
|
||||
- [[_COMMUNITY_Community 20|Community 20]]
|
||||
- [[_COMMUNITY_Community 21|Community 21]]
|
||||
- [[_COMMUNITY_Community 22|Community 22]]
|
||||
- [[_COMMUNITY_Community 23|Community 23]]
|
||||
- [[_COMMUNITY_Community 24|Community 24]]
|
||||
- [[_COMMUNITY_Community 25|Community 25]]
|
||||
- [[_COMMUNITY_Community 26|Community 26]]
|
||||
- [[_COMMUNITY_Community 27|Community 27]]
|
||||
- [[_COMMUNITY_Community 28|Community 28]]
|
||||
- [[_COMMUNITY_Community 29|Community 29]]
|
||||
- [[_COMMUNITY_Community 30|Community 30]]
|
||||
- [[_COMMUNITY_Community 31|Community 31]]
|
||||
- [[_COMMUNITY_Community 32|Community 32]]
|
||||
- [[_COMMUNITY_Community 33|Community 33]]
|
||||
- [[_COMMUNITY_Community 34|Community 34]]
|
||||
- [[_COMMUNITY_Community 35|Community 35]]
|
||||
- [[_COMMUNITY_Community 36|Community 36]]
|
||||
- [[_COMMUNITY_Community 37|Community 37]]
|
||||
- [[_COMMUNITY_Community 38|Community 38]]
|
||||
- [[_COMMUNITY_Community 39|Community 39]]
|
||||
|
||||
## God Nodes (most connected - your core abstractions)
|
||||
1. `send_followup_email()` - 22 edges
|
||||
2. `compute_aging()` - 21 edges
|
||||
3. `FollowupLevelSpec` - 20 edges
|
||||
4. `get_overdue_for_partner()` - 13 edges
|
||||
5. `compute_followup_level()` - 12 edges
|
||||
6. `generate_followup_text()` - 12 edges
|
||||
7. `TestFusionFollowupEngine` - 11 edges
|
||||
8. `select_tone()` - 11 edges
|
||||
9. `TestFollowupController` - 10 edges
|
||||
10. `TestLevelResolver` - 10 edges
|
||||
|
||||
## Surprising Connections (you probably didn't know these)
|
||||
- `test_buckets_sum_equals_total()` --calls--> `compute_aging()` [INFERRED]
|
||||
/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/tests/test_engine_property.py → /Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/services/overdue_aging.py
|
||||
- `test_overdue_amount_excludes_current()` --calls--> `compute_aging()` [INFERRED]
|
||||
/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/tests/test_engine_property.py → /Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/services/overdue_aging.py
|
||||
- `test_risk_score_in_range()` --calls--> `score_partner()` [INFERRED]
|
||||
/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/tests/test_engine_property.py → /Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/services/risk_scorer.py
|
||||
- `The follow-up engine — orchestrator for customer follow-ups. 7-method public AP` --uses--> `FollowupLevelSpec` [INFERRED]
|
||||
/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/models/fusion_followup_engine.py → /Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/services/level_resolver.py
|
||||
- `Cache lookup + LLM fallback.` --uses--> `FollowupLevelSpec` [INFERRED]
|
||||
/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/models/fusion_followup_engine.py → /Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/services/level_resolver.py
|
||||
|
||||
## Communities
|
||||
|
||||
### Community 0 - "Community 0"
|
||||
Cohesion: 0.07
|
||||
Nodes (22): FusionFollowupController, get_partner_detail(), list_overdue(), _parse_date(), pause(), HTTP controller: 6 JSON-RPC endpoints for the OWL follow-up dashboard. All endp, reset(), send_followup() (+14 more)
|
||||
|
||||
### Community 1 - "Community 1"
|
||||
Cohesion: 0.11
|
||||
Nodes (13): _max_days_overdue(), Level resolver: which follow-up level should fire for this partner? Pure-Python, Pick the highest-sequence level whose delay_days has been crossed by the mos, Return the actual max days-overdue tracked on the report, falling back to th, resolve_level(), AgingBucket, AgingReport, compute_aging() (+5 more)
|
||||
|
||||
### Community 2 - "Community 2"
|
||||
Cohesion: 0.07
|
||||
Nodes (14): Both new fields are declared on account.move.line., We can write the new fields onto an existing move line., Verify follow-up tracking fields are added to account.move.line., TestAccountMoveLineFollowup, Coexistence tests: fusion_accounting_followup menu only visible when Enterprise, TestFollowupCoexistence, Property-based invariants for follow-up services., test_buckets_sum_equals_total() (+6 more)
|
||||
|
||||
### Community 3 - "Community 3"
|
||||
Cohesion: 0.11
|
||||
Nodes (14): generate_text(), FusionFollowupEngine, Force the next-higher level than the partner's current last_level., Pause follow-ups for a partner until a date (default 30 days)., Reset partner's follow-up state to no_action., Return audit history for a partner., Fetch posted, unreconciled receivable lines for a partner., Compute risk score from partner's payment history. (+6 more)
|
||||
|
||||
### Community 4 - "Community 4"
|
||||
Cohesion: 0.15
|
||||
Nodes (2): FollowupDashboard, FollowupService
|
||||
|
||||
### Community 5 - "Community 5"
|
||||
Cohesion: 0.13
|
||||
Nodes (10): generate_followup_text(), _get_provider(), AI-generated follow-up text with templated fallback., Look up provider for 'followup_text' feature., Generate follow-up text via LLM, with templated fallback. Returns: {subject, _templated_fallback(), build_prompt(), LLM prompt for AI-generated follow-up text. Output contract: { "subject": str (+2 more)
|
||||
|
||||
### Community 6 - "Community 6"
|
||||
Cohesion: 0.13
|
||||
Nodes (7): HttpCase, Python wrappers for OWL tours via HttpCase.start_tour., TestFollowupTours, _percentile(), Performance benchmarks tagged 'benchmark'., TestControllerBenchmarks, TestEngineBenchmarks
|
||||
|
||||
### Community 7 - "Community 7"
|
||||
Cohesion: 0.21
|
||||
Nodes (6): Cache lookup + LLM fallback., compute_fingerprint(), FusionFollowupTextCache, lookup(), Cache of AI-generated follow-up text to avoid LLM cost on repeats., TestFusionFollowupTextCache
|
||||
|
||||
### Community 8 - "Community 8"
|
||||
Cohesion: 0.19
|
||||
Nodes (6): FusionMigrationWizard, Followup-specific migration step. Backfills fusion.followup.level from Enterpri, Backfill fusion.followup.level from account_followup.followup.line., Migration step: copy Enterprise account_followup per-partner state onto, Verify the partner-state migration step runs without error., TestFollowupMigrationRoundTrip
|
||||
|
||||
### Community 9 - "Community 9"
|
||||
Cohesion: 0.24
|
||||
Nodes (5): PartnerRiskScore, Payment-history risk scorer. Pure-Python: takes payment history (list of paymen, Compute a 0-100 risk score from payment-history primitives. Heuristic weigh, score_partner(), TestRiskScorer
|
||||
|
||||
### Community 10 - "Community 10"
|
||||
Cohesion: 0.22
|
||||
Nodes (5): test_tone_always_in_valid_set(), TestToneSelector, Tone selector: pick gentle/firm/legal based on follow-up level + risk score., Default tone follows level sequence; high risk can escalate., select_tone()
|
||||
|
||||
### Community 11 - "Community 11"
|
||||
Cohesion: 0.18
|
||||
Nodes (3): FusionFollowupRun, Audit record of one follow-up execution (per partner per level)., TestFusionFollowupRun
|
||||
|
||||
### Community 12 - "Community 12"
|
||||
Cohesion: 0.2
|
||||
Nodes (6): _cron_daily_scan(), _cron_risk_refresh(), FusionFollowupCron, Cron handlers for fusion_accounting_followup. Two scheduled jobs: - Daily scan:, Smoke tests for the fusion follow-up cron handlers., TestFollowupCron
|
||||
|
||||
### Community 13 - "Community 13"
|
||||
Cohesion: 0.29
|
||||
Nodes (2): HttpCase tests for the 6 follow-up JSON-RPC endpoints., TestFollowupController
|
||||
|
||||
### Community 14 - "Community 14"
|
||||
Cohesion: 0.2
|
||||
Nodes (3): Inherit res.partner: add follow-up state fields., ResPartner, TestResPartnerFollowup
|
||||
|
||||
### Community 15 - "Community 15"
|
||||
Cohesion: 0.22
|
||||
Nodes (3): FusionBatchFollowupWizard, Batch send follow-ups to selected partners (or all overdue)., TestBatchFollowupWizard
|
||||
|
||||
### Community 16 - "Community 16"
|
||||
Cohesion: 0.25
|
||||
Nodes (2): AI tool dispatch tests for fusion follow-up tools., TestFusionFollowupTools
|
||||
|
||||
### Community 17 - "Community 17"
|
||||
Cohesion: 0.25
|
||||
Nodes (2): FollowupAdapter wiring tests — engine paths., TestFollowupAdapter
|
||||
|
||||
### Community 18 - "Community 18"
|
||||
Cohesion: 0.38
|
||||
Nodes (4): _detect_local_llm(), Local LLM compat test for followup_text_generator. Auto-detects LM Studio (:123, _server_reachable(), TestLocalLLMFollowupText
|
||||
|
||||
### Community 19 - "Community 19"
|
||||
Cohesion: 0.67
|
||||
Nodes (2): AccountMoveLine, Inherit account.move.line: track last follow-up level.
|
||||
|
||||
### Community 20 - "Community 20"
|
||||
Cohesion: 0.67
|
||||
Nodes (2): FusionFollowupLevel, Follow-up level definition (e.g. Reminder at 7 days, Warning at 30, Legal at 60)
|
||||
|
||||
### Community 21 - "Community 21"
|
||||
Cohesion: 0.67
|
||||
Nodes (1): FollowupHistoryTable
|
||||
|
||||
### Community 22 - "Community 22"
|
||||
Cohesion: 0.67
|
||||
Nodes (1): AgingBucketStrip
|
||||
|
||||
### Community 23 - "Community 23"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): RiskBadge
|
||||
|
||||
### Community 24 - "Community 24"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): PartnerCard
|
||||
|
||||
### Community 25 - "Community 25"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): AiTextPanel
|
||||
|
||||
### Community 26 - "Community 26"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 27 - "Community 27"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 28 - "Community 28"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 29 - "Community 29"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 30 - "Community 30"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 31 - "Community 31"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 32 - "Community 32"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 33 - "Community 33"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Stable hash of the inputs that determine the generated text.
|
||||
|
||||
### Community 34 - "Community 34"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Find a cached entry matching these inputs, or empty recordset.
|
||||
|
||||
### Community 35 - "Community 35"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Scan every partner with overdue and send follow-ups when due.
|
||||
|
||||
### Community 36 - "Community 36"
|
||||
Cohesion: 1.0
|
||||
Nodes (1): Refresh fusion_followup_risk_score on every partner with overdue.
|
||||
|
||||
### Community 37 - "Community 37"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 38 - "Community 38"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
### Community 39 - "Community 39"
|
||||
Cohesion: 1.0
|
||||
Nodes (0):
|
||||
|
||||
## Knowledge Gaps
|
||||
- **58 isolated node(s):** `Property-based invariants for follow-up services.`, `AI tool dispatch tests for fusion follow-up tools.`, `Local LLM compat test for followup_text_generator. Auto-detects LM Studio (:123`, `Verify follow-up tracking fields are added to account.move.line.`, `Both new fields are declared on account.move.line.` (+53 more)
|
||||
These have ≤1 connection - possible missing edges or undocumented components.
|
||||
- **Thin community `Community 23`** (2 nodes): `RiskBadge`, `risk_badge.js`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 24`** (2 nodes): `PartnerCard`, `partner_card.js`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 25`** (2 nodes): `AiTextPanel`, `ai_text_panel.js`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 26`** (1 nodes): `__init__.py`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 27`** (1 nodes): `__init__.py`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 28`** (1 nodes): `__init__.py`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 29`** (1 nodes): `__init__.py`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 30`** (1 nodes): `__init__.py`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 31`** (1 nodes): `__init__.py`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 32`** (1 nodes): `__manifest__.py`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 33`** (1 nodes): `Stable hash of the inputs that determine the generated text.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 34`** (1 nodes): `Find a cached entry matching these inputs, or empty recordset.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 35`** (1 nodes): `Scan every partner with overdue and send follow-ups when due.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 36`** (1 nodes): `Refresh fusion_followup_risk_score on every partner with overdue.`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 37`** (1 nodes): `followup_tours.js`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 38`** (1 nodes): `followup_dashboard_view.js`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
- **Thin community `Community 39`** (1 nodes): `__init__.py`
|
||||
Too small to be a meaningful cluster - may be noise or needs more connections extracted.
|
||||
|
||||
## Suggested Questions
|
||||
_Questions this graph is uniquely positioned to answer:_
|
||||
|
||||
- **Why does `TestEngineBenchmarks` connect `Community 6` to `Community 2`?**
|
||||
_High betweenness centrality (0.099) - this node is a cross-community bridge._
|
||||
- **Why does `send_followup_email()` connect `Community 0` to `Community 3`, `Community 6`, `Community 7`, `Community 10`, `Community 12`, `Community 15`?**
|
||||
_High betweenness centrality (0.089) - this node is a cross-community bridge._
|
||||
- **Are the 14 inferred relationships involving `send_followup_email()` (e.g. with `.test_send_no_overdue_returns_no_action()` and `.test_send_followup_p95()`) actually correct?**
|
||||
_`send_followup_email()` has 14 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Are the 16 inferred relationships involving `compute_aging()` (e.g. with `test_buckets_sum_equals_total()` and `test_overdue_amount_excludes_current()`) actually correct?**
|
||||
_`compute_aging()` has 16 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Are the 18 inferred relationships involving `FollowupLevelSpec` (e.g. with `TestLevelResolver` and `FusionFollowupEngine`) actually correct?**
|
||||
_`FollowupLevelSpec` has 18 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **Are the 9 inferred relationships involving `get_overdue_for_partner()` (e.g. with `.test_get_overdue_returns_dict()` and `.test_get_overdue_p95()`) actually correct?**
|
||||
_`get_overdue_for_partner()` has 9 INFERRED edges - model-reasoned connections that need verification._
|
||||
- **What connects `Property-based invariants for follow-up services.`, `AI tool dispatch tests for fusion follow-up tools.`, `Local LLM compat test for followup_text_generator. Auto-detects LM Studio (:123` to the rest of the system?**
|
||||
_58 weakly-connected nodes found - possible documentation gaps or missing edges._
|
||||
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_models_fusion_followup_level_py", "label": "fusion_followup_level.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/models/fusion_followup_level.py", "source_location": "L1"}, {"id": "fusion_followup_level_fusionfollowuplevel", "label": "FusionFollowupLevel", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/models/fusion_followup_level.py", "source_location": "L13"}, {"id": "fusion_followup_level_rationale_1", "label": "Follow-up level definition (e.g. Reminder at 7 days, Warning at 30, Legal at 60)", "file_type": "rationale", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/models/fusion_followup_level.py", "source_location": "L1"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_models_fusion_followup_level_py", "target": "odoo", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/models/fusion_followup_level.py", "source_location": "L3", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_models_fusion_followup_level_py", "target": "fusion_followup_level_fusionfollowuplevel", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/models/fusion_followup_level.py", "source_location": "L13", "weight": 1.0}, {"source": "fusion_followup_level_rationale_1", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_models_fusion_followup_level_py", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/models/fusion_followup_level.py", "source_location": "L1", "weight": 1.0}], "raw_calls": []}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_static_src_tours_followup_tours_js", "label": "followup_tours.js", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/static/src/tours/followup_tours.js", "source_location": "L1"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_static_src_tours_followup_tours_js", "target": "registry", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/static/src/tours/followup_tours.js", "source_location": "L3", "weight": 1.0}], "raw_calls": []}
|
||||
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_static_src_components_risk_badge_risk_badge_js", "label": "risk_badge.js", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/static/src/components/risk_badge/risk_badge.js", "source_location": "L1"}, {"id": "risk_badge_riskbadge", "label": "RiskBadge", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/static/src/components/risk_badge/risk_badge.js", "source_location": "L5"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_static_src_components_risk_badge_risk_badge_js", "target": "owl", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/static/src/components/risk_badge/risk_badge.js", "source_location": "L3", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_static_src_components_risk_badge_risk_badge_js", "target": "risk_badge_riskbadge", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/static/src/components/risk_badge/risk_badge.js", "source_location": "L5", "weight": 1.0}], "raw_calls": []}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_controllers_init_py", "label": "__init__.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/controllers/__init__.py", "source_location": "L1"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_controllers_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_controllers_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/controllers/__init__.py", "source_location": "L1", "weight": 1.0}], "raw_calls": []}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_static_src_components_aging_bucket_strip_aging_bucket_strip_js", "label": "aging_bucket_strip.js", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/static/src/components/aging_bucket_strip/aging_bucket_strip.js", "source_location": "L1"}, {"id": "aging_bucket_strip_agingbucketstrip", "label": "AgingBucketStrip", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/static/src/components/aging_bucket_strip/aging_bucket_strip.js", "source_location": "L5"}, {"id": "aging_bucket_strip_agingbucketstrip_bucketwidth", "label": ".bucketWidth()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/static/src/components/aging_bucket_strip/aging_bucket_strip.js", "source_location": "L11"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_static_src_components_aging_bucket_strip_aging_bucket_strip_js", "target": "owl", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/static/src/components/aging_bucket_strip/aging_bucket_strip.js", "source_location": "L3", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_static_src_components_aging_bucket_strip_aging_bucket_strip_js", "target": "aging_bucket_strip_agingbucketstrip", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/static/src/components/aging_bucket_strip/aging_bucket_strip.js", "source_location": "L5", "weight": 1.0}, {"source": "aging_bucket_strip_agingbucketstrip", "target": "aging_bucket_strip_agingbucketstrip_bucketwidth", "relation": "method", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/static/src/components/aging_bucket_strip/aging_bucket_strip.js", "source_location": "L11", "weight": 1.0}], "raw_calls": [{"caller_nid": "aging_bucket_strip_agingbucketstrip_bucketwidth", "callee": "toFixed", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/static/src/components/aging_bucket_strip/aging_bucket_strip.js", "source_location": "L13"}]}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_static_src_components_followup_history_table_followup_history_table_js", "label": "followup_history_table.js", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/static/src/components/followup_history_table/followup_history_table.js", "source_location": "L1"}, {"id": "followup_history_table_followuphistorytable", "label": "FollowupHistoryTable", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/static/src/components/followup_history_table/followup_history_table.js", "source_location": "L5"}, {"id": "followup_history_table_followuphistorytable_formatdate", "label": ".formatDate()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/static/src/components/followup_history_table/followup_history_table.js", "source_location": "L11"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_static_src_components_followup_history_table_followup_history_table_js", "target": "owl", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/static/src/components/followup_history_table/followup_history_table.js", "source_location": "L3", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_static_src_components_followup_history_table_followup_history_table_js", "target": "followup_history_table_followuphistorytable", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/static/src/components/followup_history_table/followup_history_table.js", "source_location": "L5", "weight": 1.0}, {"source": "followup_history_table_followuphistorytable", "target": "followup_history_table_followuphistorytable_formatdate", "relation": "method", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/static/src/components/followup_history_table/followup_history_table.js", "source_location": "L11", "weight": 1.0}], "raw_calls": [{"caller_nid": "followup_history_table_followuphistorytable_formatdate", "callee": "slice", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/static/src/components/followup_history_table/followup_history_table.js", "source_location": "L13"}]}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_services_init_py", "label": "__init__.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/services/__init__.py", "source_location": "L1"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_services_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_services_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/services/__init__.py", "source_location": "L1", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_services_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_services_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/services/__init__.py", "source_location": "L2", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_services_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_services_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/services/__init__.py", "source_location": "L3", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_services_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_services_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/services/__init__.py", "source_location": "L4", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_services_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_services_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/services/__init__.py", "source_location": "L5", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_services_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_services_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/services/__init__.py", "source_location": "L6", "weight": 1.0}], "raw_calls": []}
|
||||
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_reports_init_py", "label": "__init__.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/reports/__init__.py", "source_location": "L1"}], "edges": [], "raw_calls": []}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_static_src_views_followup_dashboard_followup_dashboard_view_js", "label": "followup_dashboard_view.js", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/static/src/views/followup_dashboard/followup_dashboard_view.js", "source_location": "L1"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_static_src_views_followup_dashboard_followup_dashboard_view_js", "target": "registry", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/static/src/views/followup_dashboard/followup_dashboard_view.js", "source_location": "L3", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_static_src_views_followup_dashboard_followup_dashboard_view_js", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_static_src_views_followup_dashboard_followup_dashboard", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/static/src/views/followup_dashboard/followup_dashboard_view.js", "source_location": "L4", "weight": 1.0}], "raw_calls": []}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_static_src_components_partner_card_partner_card_js", "label": "partner_card.js", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/static/src/components/partner_card/partner_card.js", "source_location": "L1"}, {"id": "partner_card_partnercard", "label": "PartnerCard", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/static/src/components/partner_card/partner_card.js", "source_location": "L6"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_static_src_components_partner_card_partner_card_js", "target": "owl", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/static/src/components/partner_card/partner_card.js", "source_location": "L3", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_static_src_components_partner_card_partner_card_js", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_static_src_components_risk_badge_risk_badge", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/static/src/components/partner_card/partner_card.js", "source_location": "L4", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_static_src_components_partner_card_partner_card_js", "target": "partner_card_partnercard", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/static/src/components/partner_card/partner_card.js", "source_location": "L6", "weight": 1.0}], "raw_calls": []}
|
||||
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_init_py", "label": "__init__.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/__init__.py", "source_location": "L1"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/__init__.py", "source_location": "L1", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/__init__.py", "source_location": "L2", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/__init__.py", "source_location": "L3", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/__init__.py", "source_location": "L4", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/__init__.py", "source_location": "L5", "weight": 1.0}], "raw_calls": []}
|
||||
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_models_fusion_followup_run_py", "label": "fusion_followup_run.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/models/fusion_followup_run.py", "source_location": "L1"}, {"id": "fusion_followup_run_fusionfollowuprun", "label": "FusionFollowupRun", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/models/fusion_followup_run.py", "source_location": "L15"}, {"id": "fusion_followup_run_fusionfollowuprun_action_mark_sent", "label": ".action_mark_sent()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/models/fusion_followup_run.py", "source_location": "L50"}, {"id": "fusion_followup_run_fusionfollowuprun_action_mark_failed", "label": ".action_mark_failed()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/models/fusion_followup_run.py", "source_location": "L53"}, {"id": "fusion_followup_run_rationale_1", "label": "Audit record of one follow-up execution (per partner per level).", "file_type": "rationale", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/models/fusion_followup_run.py", "source_location": "L1"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_models_fusion_followup_run_py", "target": "odoo", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/models/fusion_followup_run.py", "source_location": "L3", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_models_fusion_followup_run_py", "target": "fusion_followup_run_fusionfollowuprun", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/models/fusion_followup_run.py", "source_location": "L15", "weight": 1.0}, {"source": "fusion_followup_run_fusionfollowuprun", "target": "fusion_followup_run_fusionfollowuprun_action_mark_sent", "relation": "method", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/models/fusion_followup_run.py", "source_location": "L50", "weight": 1.0}, {"source": "fusion_followup_run_fusionfollowuprun", "target": "fusion_followup_run_fusionfollowuprun_action_mark_failed", "relation": "method", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/models/fusion_followup_run.py", "source_location": "L53", "weight": 1.0}, {"source": "fusion_followup_run_rationale_1", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_models_fusion_followup_run_py", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/models/fusion_followup_run.py", "source_location": "L1", "weight": 1.0}], "raw_calls": [{"caller_nid": "fusion_followup_run_fusionfollowuprun_action_mark_sent", "callee": "write", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/models/fusion_followup_run.py", "source_location": "L51"}, {"caller_nid": "fusion_followup_run_fusionfollowuprun_action_mark_failed", "callee": "write", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/models/fusion_followup_run.py", "source_location": "L54"}]}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_services_followup_text_prompt_py", "label": "followup_text_prompt.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/services/followup_text_prompt.py", "source_location": "L1"}, {"id": "followup_text_prompt_build_prompt", "label": "build_prompt()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/services/followup_text_prompt.py", "source_location": "L37"}, {"id": "followup_text_prompt_rationale_1", "label": "LLM prompt for AI-generated follow-up text. Output contract: { \"subject\": str", "file_type": "rationale", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/services/followup_text_prompt.py", "source_location": "L1"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_services_followup_text_prompt_py", "target": "followup_text_prompt_build_prompt", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/services/followup_text_prompt.py", "source_location": "L37", "weight": 1.0}, {"source": "followup_text_prompt_rationale_1", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_services_followup_text_prompt_py", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/services/followup_text_prompt.py", "source_location": "L1", "weight": 1.0}], "raw_calls": [{"caller_nid": "followup_text_prompt_build_prompt", "callee": "append", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/services/followup_text_prompt.py", "source_location": "L49"}, {"caller_nid": "followup_text_prompt_build_prompt", "callee": "append", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/services/followup_text_prompt.py", "source_location": "L51"}, {"caller_nid": "followup_text_prompt_build_prompt", "callee": "append", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/services/followup_text_prompt.py", "source_location": "L53"}, {"caller_nid": "followup_text_prompt_build_prompt", "callee": "append", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/services/followup_text_prompt.py", "source_location": "L54"}, {"caller_nid": "followup_text_prompt_build_prompt", "callee": "append", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/services/followup_text_prompt.py", "source_location": "L55"}, {"caller_nid": "followup_text_prompt_build_prompt", "callee": "join", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/services/followup_text_prompt.py", "source_location": "L56"}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_models_account_move_line_py", "label": "account_move_line.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/models/account_move_line.py", "source_location": "L1"}, {"id": "account_move_line_accountmoveline", "label": "AccountMoveLine", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/models/account_move_line.py", "source_location": "L6"}, {"id": "account_move_line_rationale_1", "label": "Inherit account.move.line: track last follow-up level.", "file_type": "rationale", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/models/account_move_line.py", "source_location": "L1"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_models_account_move_line_py", "target": "odoo", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/models/account_move_line.py", "source_location": "L3", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_models_account_move_line_py", "target": "account_move_line_accountmoveline", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/models/account_move_line.py", "source_location": "L6", "weight": 1.0}, {"source": "account_move_line_rationale_1", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_models_account_move_line_py", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/models/account_move_line.py", "source_location": "L1", "weight": 1.0}], "raw_calls": []}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_manifest_py", "label": "__manifest__.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/__manifest__.py", "source_location": "L1"}], "edges": [], "raw_calls": []}
|
||||
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_services_tone_selector_py", "label": "tone_selector.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/services/tone_selector.py", "source_location": "L1"}, {"id": "tone_selector_select_tone", "label": "select_tone()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/services/tone_selector.py", "source_location": "L11"}, {"id": "tone_selector_rationale_1", "label": "Tone selector: pick gentle/firm/legal based on follow-up level + risk score.", "file_type": "rationale", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/services/tone_selector.py", "source_location": "L1"}, {"id": "tone_selector_rationale_12", "label": "Default tone follows level sequence; high risk can escalate.", "file_type": "rationale", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/services/tone_selector.py", "source_location": "L12"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_services_tone_selector_py", "target": "tone_selector_select_tone", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/services/tone_selector.py", "source_location": "L11", "weight": 1.0}, {"source": "tone_selector_rationale_1", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_services_tone_selector_py", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/services/tone_selector.py", "source_location": "L1", "weight": 1.0}, {"source": "tone_selector_rationale_12", "target": "tone_selector_select_tone", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/services/tone_selector.py", "source_location": "L12", "weight": 1.0}], "raw_calls": [{"caller_nid": "tone_selector_select_tone", "callee": "get", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/services/tone_selector.py", "source_location": "L13"}]}
|
||||
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_wizards_init_py", "label": "__init__.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/wizards/__init__.py", "source_location": "L1"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_wizards_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_wizards_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/wizards/__init__.py", "source_location": "L1", "weight": 1.0}], "raw_calls": []}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_models_init_py", "label": "__init__.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/models/__init__.py", "source_location": "L1"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_models_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/models/__init__.py", "source_location": "L1", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_models_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/models/__init__.py", "source_location": "L2", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_models_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/models/__init__.py", "source_location": "L3", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_models_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/models/__init__.py", "source_location": "L4", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_models_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/models/__init__.py", "source_location": "L5", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_models_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/models/__init__.py", "source_location": "L6", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_models_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/models/__init__.py", "source_location": "L7", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_models_init_py", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_models_init_py", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/models/__init__.py", "source_location": "L8", "weight": 1.0}], "raw_calls": []}
|
||||
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_static_src_components_ai_text_panel_ai_text_panel_js", "label": "ai_text_panel.js", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/static/src/components/ai_text_panel/ai_text_panel.js", "source_location": "L1"}, {"id": "ai_text_panel_aitextpanel", "label": "AiTextPanel", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/static/src/components/ai_text_panel/ai_text_panel.js", "source_location": "L5"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_static_src_components_ai_text_panel_ai_text_panel_js", "target": "owl", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/static/src/components/ai_text_panel/ai_text_panel.js", "source_location": "L3", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_static_src_components_ai_text_panel_ai_text_panel_js", "target": "ai_text_panel_aitextpanel", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/static/src/components/ai_text_panel/ai_text_panel.js", "source_location": "L5", "weight": 1.0}], "raw_calls": []}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -0,0 +1 @@
|
||||
{"nodes": [{"id": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_models_res_partner_py", "label": "res_partner.py", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/models/res_partner.py", "source_location": "L1"}, {"id": "res_partner_respartner", "label": "ResPartner", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/models/res_partner.py", "source_location": "L15"}, {"id": "res_partner_respartner_compute_fusion_followup_run_count", "label": "._compute_fusion_followup_run_count()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/models/res_partner.py", "source_location": "L40"}, {"id": "res_partner_respartner_action_view_followup_history", "label": ".action_view_followup_history()", "file_type": "code", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/models/res_partner.py", "source_location": "L44"}, {"id": "res_partner_rationale_1", "label": "Inherit res.partner: add follow-up state fields.", "file_type": "rationale", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/models/res_partner.py", "source_location": "L1"}], "edges": [{"source": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_models_res_partner_py", "target": "odoo", "relation": "imports_from", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/models/res_partner.py", "source_location": "L3", "weight": 1.0}, {"source": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_models_res_partner_py", "target": "res_partner_respartner", "relation": "contains", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/models/res_partner.py", "source_location": "L15", "weight": 1.0}, {"source": "res_partner_respartner", "target": "res_partner_respartner_compute_fusion_followup_run_count", "relation": "method", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/models/res_partner.py", "source_location": "L40", "weight": 1.0}, {"source": "res_partner_respartner", "target": "res_partner_respartner_action_view_followup_history", "relation": "method", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/models/res_partner.py", "source_location": "L44", "weight": 1.0}, {"source": "res_partner_rationale_1", "target": "users_gurpreet_github_odoo_modules_fusion_accounting_followup_models_res_partner_py", "relation": "rationale_for", "confidence": "EXTRACTED", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/models/res_partner.py", "source_location": "L1", "weight": 1.0}], "raw_calls": [{"caller_nid": "res_partner_respartner_compute_fusion_followup_run_count", "callee": "len", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/models/res_partner.py", "source_location": "L42"}, {"caller_nid": "res_partner_respartner_action_view_followup_history", "callee": "ensure_one", "source_file": "/Users/gurpreet/Github/Odoo-Modules/fusion_accounting_followup/models/res_partner.py", "source_location": "L45"}]}
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
9160
fusion_accounting/fusion_accounting_followup/graphify-out/graph.json
Normal file
9160
fusion_accounting/fusion_accounting_followup/graphify-out/graph.json
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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
|
||||
@@ -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.")
|
||||
@@ -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)
|
||||
@@ -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': '<pre>{}</pre>'.format(body_text),
|
||||
'email_to': partner.email,
|
||||
'recipient_ids': [(4, partner.id)],
|
||||
}).send()
|
||||
run.write({'sent_to_email': partner.email})
|
||||
@@ -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.',
|
||||
)
|
||||
@@ -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})
|
||||
@@ -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
|
||||
@@ -0,0 +1,197 @@
|
||||
"""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 _followup_partner_state_bootstrap_step(self):
|
||||
"""Migration step: copy Enterprise account_followup per-partner state
|
||||
onto Fusion's fields on res.partner.
|
||||
|
||||
Idempotent: only updates partners whose Fusion field is at default
|
||||
(no_action) and whose Enterprise field has a non-default value.
|
||||
"""
|
||||
self.ensure_one()
|
||||
_logger.info("fusion_accounting_followup partner-state migration starting")
|
||||
|
||||
Partner = self.env['res.partner'].sudo()
|
||||
has_status = 'followup_status' in Partner._fields
|
||||
has_next_date = 'payment_next_action_date' in Partner._fields
|
||||
has_line = 'followup_line_id' in Partner._fields
|
||||
if not (has_status or has_next_date or has_line):
|
||||
_logger.info(
|
||||
"Enterprise account_followup partner fields not present \u2014 skipping")
|
||||
return {
|
||||
'step': 'followup_partner_state',
|
||||
'enterprise_module_present': False,
|
||||
'updated': 0, 'skipped': 0, 'errors': [],
|
||||
}
|
||||
|
||||
result = {
|
||||
'step': 'followup_partner_state',
|
||||
'enterprise_module_present': True,
|
||||
'updated': 0, 'skipped': 0, 'errors': [],
|
||||
}
|
||||
|
||||
domain_terms = []
|
||||
if has_status:
|
||||
domain_terms.append(('followup_status', '!=', 'no_action_needed'))
|
||||
if has_next_date:
|
||||
domain_terms.append(('payment_next_action_date', '!=', False))
|
||||
if not domain_terms:
|
||||
_logger.info("No usable Enterprise follow-up fields \u2014 skipping")
|
||||
return result
|
||||
if len(domain_terms) > 1:
|
||||
domain = ['|'] * (len(domain_terms) - 1) + domain_terms
|
||||
else:
|
||||
domain = domain_terms
|
||||
candidates = Partner.search(domain)
|
||||
_logger.info(
|
||||
"Found %d partners with non-default Enterprise follow-up state",
|
||||
len(candidates))
|
||||
|
||||
Level = self.env['fusion.followup.level'].sudo()
|
||||
today = fields.Date.today()
|
||||
|
||||
status_map = {
|
||||
'in_need_of_action': 'action_due',
|
||||
'with_overdue_invoices': 'action_due',
|
||||
'no_action_needed': 'no_action',
|
||||
}
|
||||
|
||||
for partner in candidates:
|
||||
try:
|
||||
if partner.fusion_followup_status not in (False, 'no_action'):
|
||||
result['skipped'] += 1
|
||||
continue
|
||||
|
||||
vals = {}
|
||||
|
||||
ent_status = (
|
||||
getattr(partner, 'followup_status', None)
|
||||
if has_status else None)
|
||||
if ent_status and ent_status in status_map:
|
||||
vals['fusion_followup_status'] = status_map[ent_status]
|
||||
|
||||
next_date = (
|
||||
getattr(partner, 'payment_next_action_date', False)
|
||||
if has_next_date else False)
|
||||
if next_date and next_date > today:
|
||||
vals['fusion_followup_paused_until'] = next_date
|
||||
vals['fusion_followup_status'] = 'paused'
|
||||
|
||||
ent_line = (
|
||||
getattr(partner, 'followup_line_id', None)
|
||||
if has_line else None)
|
||||
if ent_line:
|
||||
fusion_level = Level.search([
|
||||
('name', '=', ent_line.name),
|
||||
], limit=1)
|
||||
if fusion_level:
|
||||
vals['fusion_followup_last_level_id'] = fusion_level.id
|
||||
|
||||
if vals:
|
||||
partner.write(vals)
|
||||
result['updated'] += 1
|
||||
_logger.debug(
|
||||
"Migrated partner %s: %s", partner.name, vals)
|
||||
else:
|
||||
result['skipped'] += 1
|
||||
|
||||
except Exception as e:
|
||||
result['errors'].append(
|
||||
f"Partner {partner.id} ({partner.name}): {e}")
|
||||
_logger.warning(
|
||||
"Migration failed for partner %s: %s", partner.id, e)
|
||||
|
||||
_logger.info(
|
||||
"fusion_accounting_followup partner-state migration: "
|
||||
"updated=%d skipped=%d errors=%d",
|
||||
result['updated'], 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)
|
||||
try:
|
||||
self._followup_partner_state_bootstrap_step()
|
||||
except Exception as e:
|
||||
_logger.warning("followup_partner_state_bootstrap_step failed: %s", e)
|
||||
return result
|
||||
@@ -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},
|
||||
}
|
||||
@@ -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
|
||||
|
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,56 @@
|
||||
"""LLM prompt for AI-generated follow-up text.
|
||||
|
||||
Output contract: {
|
||||
"subject": str,
|
||||
"body": str,
|
||||
"tone_used": str,
|
||||
"key_points": [str, ...]
|
||||
}"""
|
||||
|
||||
|
||||
SYSTEM_PROMPT = """You are an experienced credit collections specialist writing a
|
||||
follow-up email for an unpaid invoice. Output MUST be valid JSON of this
|
||||
exact shape:
|
||||
|
||||
{
|
||||
"subject": "<email subject line>",
|
||||
"body": "<plain-text or simple HTML body, no <html> wrapper>",
|
||||
"tone_used": "gentle" | "firm" | "legal",
|
||||
"key_points": ["<point 1>", "<point 2>", ...]
|
||||
}
|
||||
|
||||
Tone guide:
|
||||
- gentle: friendly reminder, assume oversight, propose easy paths to pay
|
||||
- firm: state amount + days overdue clearly, request immediate action,
|
||||
hint at consequences
|
||||
- legal: formal language, reference contract obligations, mention possible
|
||||
legal action / collections agency, demand payment by specific date
|
||||
|
||||
Always:
|
||||
- Use the actual amounts and partner name from the data provided
|
||||
- Don't invent contract terms or interest rates
|
||||
- Don't include markdown code fences
|
||||
- No prose outside the JSON
|
||||
"""
|
||||
|
||||
|
||||
def build_prompt(*, partner_name: str, total_overdue: float, currency_code: str,
|
||||
longest_overdue_days: int, tone: str,
|
||||
invoice_count: int = 0, last_payment_date: str = None,
|
||||
risk_drivers: list[str] = None) -> tuple[str, str]:
|
||||
parts = [
|
||||
f"PARTNER: {partner_name}",
|
||||
f"TOTAL OVERDUE: {currency_code} {total_overdue:,.2f}",
|
||||
f"LONGEST OVERDUE: {longest_overdue_days} days",
|
||||
f"OPEN INVOICE COUNT: {invoice_count}",
|
||||
f"REQUESTED TONE: {tone}",
|
||||
]
|
||||
if last_payment_date:
|
||||
parts.append(f"LAST PAYMENT: {last_payment_date}")
|
||||
if risk_drivers:
|
||||
parts.append("RISK FACTORS:")
|
||||
for d in risk_drivers[:5]:
|
||||
parts.append(f" - {d}")
|
||||
parts.append("")
|
||||
parts.append("Write the follow-up email per the system prompt.")
|
||||
return (SYSTEM_PROMPT, "\n".join(parts))
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
BIN
fusion_accounting/fusion_accounting_followup/static/.DS_Store
vendored
Normal file
BIN
fusion_accounting/fusion_accounting_followup/static/.DS_Store
vendored
Normal file
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 72 KiB |
@@ -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) + "%";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_accounting_followup.AgingBucketStrip">
|
||||
<div class="mt-2">
|
||||
<div class="fu-aging-strip">
|
||||
<div t-foreach="props.aging.buckets" t-as="b" t-key="b.name"
|
||||
class="bucket" t-att-data-name="b.name"
|
||||
t-att-style="'width: ' + bucketWidth(b)"
|
||||
t-att-title="b.name + ': $' + (b.amount or 0).toFixed(2)"/>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between text-muted" style="font-size: 0.7rem;">
|
||||
<span>Current</span>
|
||||
<span>30</span>
|
||||
<span>60</span>
|
||||
<span>90</span>
|
||||
<span>120+</span>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_accounting_followup.AiTextPanel">
|
||||
<div class="fu-ai-text-panel mt-3">
|
||||
<h5>AI-Generated Follow-up Text</h5>
|
||||
<div class="ai-subject">
|
||||
Subject: <t t-esc="props.text.subject"/>
|
||||
</div>
|
||||
<div class="ai-body">
|
||||
<t t-esc="props.text.body"/>
|
||||
</div>
|
||||
<div class="key-points" t-if="props.text.key_points and props.text.key_points.length">
|
||||
<strong>Key points:</strong>
|
||||
<ul>
|
||||
<li t-foreach="props.text.key_points" t-as="point" t-key="point_index">
|
||||
<t t-esc="point"/>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="text-muted mt-2" style="font-size: 0.75rem;">
|
||||
Tone used: <t t-esc="props.text.tone_used or props.text.tone or 'gentle'"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_accounting_followup.FollowupHistoryTable">
|
||||
<div class="mt-4">
|
||||
<h5>Follow-up History (<t t-esc="props.history.count or 0"/>)</h5>
|
||||
<table t-if="props.history.runs and props.history.runs.length" class="fu-history-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Date</th>
|
||||
<th>Level</th>
|
||||
<th>Tone</th>
|
||||
<th>State</th>
|
||||
<th class="text-end">Overdue</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr t-foreach="props.history.runs" t-as="run" t-key="run.id">
|
||||
<td><t t-esc="formatDate(run.date)"/></td>
|
||||
<td><t t-esc="run.level_name or '-'"/></td>
|
||||
<td><t t-esc="run.tone_used or '-'"/></td>
|
||||
<td><t t-esc="run.state"/></td>
|
||||
<td class="text-end">
|
||||
<t t-if="run.overdue_amount">$<t t-esc="run.overdue_amount.toFixed(2)"/></t>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
<div t-else="" class="text-muted">No history yet.</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="fusion_accounting_followup.PartnerCard">
|
||||
<div class="o_fusion_followup_card"
|
||||
t-att-class="props.selected ? 'selected' : ''"
|
||||
t-on-click="props.onSelect">
|
||||
<div class="o_fusion_followup_card_header">
|
||||
<div class="partner-name"><t t-esc="props.partner.partner_name"/></div>
|
||||
<div>
|
||||
<span class="fu-status-badge" t-att-data-status="props.partner.status">
|
||||
<t t-esc="props.partner.status"/>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="partner-numbers">
|
||||
<div>
|
||||
<span class="label">Overdue:</span>
|
||||
<span class="value">$<t t-esc="props.formatCurrency(props.partner.overdue_amount)"/></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Lines:</span>
|
||||
<span class="value"><t t-esc="props.partner.overdue_line_count or 0"/></span>
|
||||
</div>
|
||||
<div>
|
||||
<span class="label">Risk:</span>
|
||||
<RiskBadge band="props.partner.risk_band" score="props.partner.risk_score"/>
|
||||
</div>
|
||||
<div t-if="props.partner.last_level_name">
|
||||
<span class="label">Last:</span>
|
||||
<span class="value"><t t-esc="props.partner.last_level_name"/></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -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 },
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user