This commit is contained in:
gsinghpal
2026-05-16 13:18:52 -04:00
parent 191a9c82be
commit 9ebf89bde2
1080 changed files with 0 additions and 1197 deletions

Binary file not shown.

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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',
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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._

View File

@@ -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": []}

View File

@@ -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": []}

View File

@@ -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": []}

View File

@@ -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": []}

View File

@@ -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"}]}

View File

@@ -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"}]}

View File

@@ -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": []}

View File

@@ -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": []}

View File

@@ -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": []}

View File

@@ -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": []}

View File

@@ -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": []}

View File

@@ -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"}]}

View File

@@ -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"}]}

View File

@@ -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": []}

View File

@@ -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": []}

View File

@@ -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"}]}

View File

@@ -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": []}

View File

@@ -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": []}

View File

@@ -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": []}

View File

@@ -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 it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 72 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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