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',
|
'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': """
|
||||||
|
|||||||
@@ -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_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
|
||||||
|
|||||||
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