From d455016c275e75e1596a497c8e0fb2b620e8e4cd Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 21:00:07 -0400 Subject: [PATCH] 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 --- fusion_accounting_followup/__manifest__.py | 2 +- .../controllers/__init__.py | 1 + .../controllers/followup_controller.py | 173 ++++++++++++++++++ fusion_accounting_followup/tests/__init__.py | 1 + .../tests/test_followup_controller.py | 80 ++++++++ 5 files changed, 256 insertions(+), 1 deletion(-) create mode 100644 fusion_accounting_followup/controllers/followup_controller.py create mode 100644 fusion_accounting_followup/tests/test_followup_controller.py diff --git a/fusion_accounting_followup/__manifest__.py b/fusion_accounting_followup/__manifest__.py index f1c3473c..b3cb24c6 100644 --- a/fusion_accounting_followup/__manifest__.py +++ b/fusion_accounting_followup/__manifest__.py @@ -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': """ diff --git a/fusion_accounting_followup/controllers/__init__.py b/fusion_accounting_followup/controllers/__init__.py index e69de29b..3f63b75a 100644 --- a/fusion_accounting_followup/controllers/__init__.py +++ b/fusion_accounting_followup/controllers/__init__.py @@ -0,0 +1 @@ +from . import followup_controller diff --git a/fusion_accounting_followup/controllers/followup_controller.py b/fusion_accounting_followup/controllers/followup_controller.py new file mode 100644 index 00000000..6f349efc --- /dev/null +++ b/fusion_accounting_followup/controllers/followup_controller.py @@ -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) diff --git a/fusion_accounting_followup/tests/__init__.py b/fusion_accounting_followup/tests/__init__.py index 2ef1dce5..40bf9f9a 100644 --- a/fusion_accounting_followup/tests/__init__.py +++ b/fusion_accounting_followup/tests/__init__.py @@ -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 diff --git a/fusion_accounting_followup/tests/test_followup_controller.py b/fusion_accounting_followup/tests/test_followup_controller.py new file mode 100644 index 00000000..df538ec2 --- /dev/null +++ b/fusion_accounting_followup/tests/test_followup_controller.py @@ -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'))