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:
@@ -1,6 +1,6 @@
|
||||
{
|
||||
'name': 'Fusion Accounting Follow-up',
|
||||
'version': '19.0.1.0.14',
|
||||
'version': '19.0.1.0.15',
|
||||
'category': 'Accounting/Accounting',
|
||||
'summary': 'AI-augmented customer follow-ups (dunning) for unpaid invoices.',
|
||||
'description': """
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from . import followup_controller
|
||||
|
||||
173
fusion_accounting_followup/controllers/followup_controller.py
Normal file
173
fusion_accounting_followup/controllers/followup_controller.py
Normal 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)
|
||||
@@ -10,3 +10,4 @@ from . import test_res_partner_inherit
|
||||
from . import test_account_move_line_inherit
|
||||
from . import test_fusion_followup_engine
|
||||
from . import test_engine_integration
|
||||
from . import test_followup_controller
|
||||
|
||||
80
fusion_accounting_followup/tests/test_followup_controller.py
Normal file
80
fusion_accounting_followup/tests/test_followup_controller.py
Normal 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'))
|
||||
Reference in New Issue
Block a user