feat(fusion_accounting_followup): 6 JSON-RPC endpoints for OWL widget

Adds Task 15 controller layer:
- /fusion/followup/list_overdue
- /fusion/followup/get_partner_detail
- /fusion/followup/generate_text
- /fusion/followup/send
- /fusion/followup/pause
- /fusion/followup/reset

All endpoints use V19 type='jsonrpc' and route through
fusion.followup.engine. 6 HttpCase tests added (69 total).

Made-with: Cursor
This commit is contained in:
gsinghpal
2026-04-19 21:00:07 -04:00
parent 9b6d6b3895
commit d455016c27
5 changed files with 256 additions and 1 deletions

View File

@@ -1,6 +1,6 @@
{ {
'name': 'Fusion Accounting Follow-up', 'name': 'Fusion Accounting Follow-up',
'version': '19.0.1.0.14', 'version': '19.0.1.0.15',
'category': 'Accounting/Accounting', 'category': 'Accounting/Accounting',
'summary': 'AI-augmented customer follow-ups (dunning) for unpaid invoices.', 'summary': 'AI-augmented customer follow-ups (dunning) for unpaid invoices.',
'description': """ 'description': """

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

@@ -10,3 +10,4 @@ from . import test_res_partner_inherit
from . import test_account_move_line_inherit from . import test_account_move_line_inherit
from . import test_fusion_followup_engine from . import test_fusion_followup_engine
from . import test_engine_integration from . import test_engine_integration
from . import test_followup_controller

View File

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