From 52becd176a0d46dbf47e2e71bb91b387cb7f16da Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 21:03:30 -0400 Subject: [PATCH] feat(fusion_accounting_ai): 5 new customer follow-up AI tools Adds Task 17 tool layer: - fusion_list_overdue - fusion_get_partner_followup_detail - fusion_generate_followup_text - fusion_send_followup - fusion_get_partner_risk_score Tools register through TOOL_DISPATCH and degrade with a clear error message when fusion_accounting_followup is not installed. 5 TransactionCase tests added (78 total). Made-with: Cursor --- .../services/tools/__init__.py | 3 +- .../services/tools/customer_followup.py | 98 +++++++++++++++++++ fusion_accounting_followup/__manifest__.py | 2 +- fusion_accounting_followup/tests/__init__.py | 1 + .../tests/test_followup_tools.py | 61 ++++++++++++ 5 files changed, 163 insertions(+), 2 deletions(-) create mode 100644 fusion_accounting_ai/services/tools/customer_followup.py create mode 100644 fusion_accounting_followup/tests/test_followup_tools.py diff --git a/fusion_accounting_ai/services/tools/__init__.py b/fusion_accounting_ai/services/tools/__init__.py index b2331b03..85339c05 100644 --- a/fusion_accounting_ai/services/tools/__init__.py +++ b/fusion_accounting_ai/services/tools/__init__.py @@ -11,12 +11,13 @@ from .reporting import TOOLS as REPORTING_TOOLS from .audit import TOOLS as AUDIT_TOOLS from .financial_reports import TOOLS as FINANCIAL_REPORTS_TOOLS from .asset_management import TOOLS as ASSET_MANAGEMENT_TOOLS +from .customer_followup import TOOLS as CUSTOMER_FOLLOWUP_TOOLS TOOL_DISPATCH = {} for tools_dict in [ BANK_RECON_TOOLS, HST_TOOLS, AR_TOOLS, AP_TOOLS, JOURNAL_TOOLS, MONTH_END_TOOLS, PAYROLL_TOOLS, INVENTORY_TOOLS, ADP_TOOLS, REPORTING_TOOLS, AUDIT_TOOLS, FINANCIAL_REPORTS_TOOLS, - ASSET_MANAGEMENT_TOOLS, + ASSET_MANAGEMENT_TOOLS, CUSTOMER_FOLLOWUP_TOOLS, ]: TOOL_DISPATCH.update(tools_dict) diff --git a/fusion_accounting_ai/services/tools/customer_followup.py b/fusion_accounting_ai/services/tools/customer_followup.py new file mode 100644 index 00000000..1ef735d2 --- /dev/null +++ b/fusion_accounting_ai/services/tools/customer_followup.py @@ -0,0 +1,98 @@ +"""Fusion-engine-routed AI tools for customer follow-ups. + +These tools are exposed through TOOL_DISPATCH and let the assistant query +the customer follow-up engine via natural language. All tools degrade +gracefully when fusion_accounting_followup is not installed. +""" + +import logging + +_logger = logging.getLogger(__name__) + + +def fusion_list_overdue(env, params): + """List partners with overdue invoices, sorted by risk.""" + if 'fusion.followup.engine' not in env.registry: + return {'error': 'fusion_accounting_followup not installed'} + from ..data_adapters import get_adapter + adapter = get_adapter(env, 'followup') + return adapter.list_overdue( + status=params.get('status'), + limit=int(params.get('limit', 50)), + company_id=int(params['company_id']) + if params.get('company_id') else env.company.id, + ) + + +def fusion_get_partner_followup_detail(env, params): + """Detailed follow-up state for a single partner: aging, risk, history.""" + if 'fusion.followup.engine' not in env.registry: + return {'error': 'fusion_accounting_followup not installed'} + Partner = env['res.partner'] + partner = Partner.browse(int(params['partner_id'])) + if not partner.exists(): + return {'error': 'Partner not found'} + engine = env['fusion.followup.engine'] + overdue = engine.get_overdue_for_partner(partner) + history = engine.snapshot_followup_history(partner, limit=10) + return { + 'partner_id': partner.id, + 'partner_name': partner.name, + 'overdue': overdue, + 'history': history, + } + + +def fusion_generate_followup_text(env, params): + """Generate (or fall back to template) follow-up subject + body.""" + if 'fusion.followup.engine' not in env.registry: + return {'error': 'fusion_accounting_followup not installed'} + from odoo.addons.fusion_accounting_followup.services.followup_text_generator import ( + generate_followup_text, + ) + return generate_followup_text( + env, + partner_name=params.get('partner_name', ''), + total_overdue=float(params.get('total_overdue', 0)), + currency_code=params.get('currency_code', 'USD'), + longest_overdue_days=int(params.get('longest_overdue_days', 0)), + tone=params.get('tone', 'gentle'), + invoice_count=int(params.get('invoice_count', 0)), + ) + + +def fusion_send_followup(env, params): + """Send a follow-up email via the engine (creates a fusion.followup.run).""" + if 'fusion.followup.engine' not in env.registry: + return {'error': 'fusion_accounting_followup not installed'} + from ..data_adapters import get_adapter + adapter = get_adapter(env, 'followup') + return adapter.send_followup( + partner_id=int(params['partner_id']), + level_id=int(params['level_id']) if params.get('level_id') else None, + force=bool(params.get('force', False)), + ) + + +def fusion_get_partner_risk_score(env, params): + """Compute and return the payment-risk score + drivers for a partner.""" + if 'fusion.followup.engine' not in env.registry: + return {'error': 'fusion_accounting_followup not installed'} + partner = env['res.partner'].browse(int(params['partner_id'])) + if not partner.exists(): + return {'error': 'Partner not found'} + overdue = env['fusion.followup.engine'].get_overdue_for_partner(partner) + return { + 'partner_id': partner.id, + 'partner_name': partner.name, + 'risk': overdue['risk'], + } + + +TOOLS = { + 'fusion_list_overdue': fusion_list_overdue, + 'fusion_get_partner_followup_detail': fusion_get_partner_followup_detail, + 'fusion_generate_followup_text': fusion_generate_followup_text, + 'fusion_send_followup': fusion_send_followup, + 'fusion_get_partner_risk_score': fusion_get_partner_risk_score, +} diff --git a/fusion_accounting_followup/__manifest__.py b/fusion_accounting_followup/__manifest__.py index c7f90d9f..f95be0ad 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.16', + 'version': '19.0.1.0.17', 'category': 'Accounting/Accounting', 'summary': 'AI-augmented customer follow-ups (dunning) for unpaid invoices.', 'description': """ diff --git a/fusion_accounting_followup/tests/__init__.py b/fusion_accounting_followup/tests/__init__.py index 693ef383..94fa47b3 100644 --- a/fusion_accounting_followup/tests/__init__.py +++ b/fusion_accounting_followup/tests/__init__.py @@ -12,3 +12,4 @@ from . import test_fusion_followup_engine from . import test_engine_integration from . import test_followup_controller from . import test_followup_adapter +from . import test_followup_tools diff --git a/fusion_accounting_followup/tests/test_followup_tools.py b/fusion_accounting_followup/tests/test_followup_tools.py new file mode 100644 index 00000000..ad4200d2 --- /dev/null +++ b/fusion_accounting_followup/tests/test_followup_tools.py @@ -0,0 +1,61 @@ +"""AI tool dispatch tests for fusion follow-up tools.""" + +from odoo.tests import tagged +from odoo.tests.common import TransactionCase + +from odoo.addons.fusion_accounting_ai.services.tools import customer_followup as tools + + +@tagged('post_install', '-at_install') +class TestFusionFollowupTools(TransactionCase): + + def test_fusion_list_overdue(self): + result = tools.fusion_list_overdue( + self.env, {'company_id': self.env.company.id}, + ) + self.assertIn('partners', result) + + def test_fusion_get_partner_detail(self): + partner = self.env['res.partner'].create({ + 'name': 'Tool Partner', 'email': 't@t.local', + }) + result = tools.fusion_get_partner_followup_detail( + self.env, {'partner_id': partner.id}, + ) + self.assertEqual(result['partner_id'], partner.id) + + def test_fusion_generate_text_uses_fallback(self): + self.env['ir.config_parameter'].sudo().search([ + ('key', 'in', [ + 'fusion_accounting.provider.followup_text', + 'fusion_accounting.provider.default', + ]), + ]).unlink() + result = tools.fusion_generate_followup_text(self.env, { + 'partner_name': 'Acme', 'total_overdue': 1000, + 'currency_code': 'USD', 'longest_overdue_days': 15, + 'tone': 'gentle', + }) + self.assertIn('subject', result) + self.assertIn('body', result) + + def test_fusion_get_risk_score(self): + partner = self.env['res.partner'].create({'name': 'Risk Test'}) + result = tools.fusion_get_partner_risk_score( + self.env, {'partner_id': partner.id}, + ) + self.assertIn('risk', result) + + def test_tools_registered_in_dispatch(self): + from odoo.addons.fusion_accounting_ai.services.tools import TOOL_DISPATCH + for tool_name in [ + 'fusion_list_overdue', + 'fusion_get_partner_followup_detail', + 'fusion_generate_followup_text', + 'fusion_send_followup', + 'fusion_get_partner_risk_score', + ]: + self.assertIn( + tool_name, TOOL_DISPATCH, + f"{tool_name} not registered in TOOL_DISPATCH", + )