From 6802d60e4485a2b180c590ed07b0d9f5e2047f62 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 19 Apr 2026 20:52:27 -0400 Subject: [PATCH] feat(fusion_accounting_followup): fusion.followup.engine 7-method API The orchestrator AbstractModel for follow-up lifecycle. get_overdue_for_partner, compute_followup_level, send_followup_email, escalate_to_next_level, pause_followup, reset_followup, snapshot_followup_history. All controllers, AI tools, wizards, cron must route through these methods; no direct ORM writes to fusion.followup.run from anywhere else. Made-with: Cursor --- fusion_accounting_followup/__manifest__.py | 2 +- fusion_accounting_followup/models/__init__.py | 1 + .../models/fusion_followup_engine.py | 379 ++++++++++++++++++ fusion_accounting_followup/tests/__init__.py | 1 + .../tests/test_fusion_followup_engine.py | 74 ++++ 5 files changed, 456 insertions(+), 1 deletion(-) create mode 100644 fusion_accounting_followup/models/fusion_followup_engine.py create mode 100644 fusion_accounting_followup/tests/test_fusion_followup_engine.py diff --git a/fusion_accounting_followup/__manifest__.py b/fusion_accounting_followup/__manifest__.py index 16f9c8e7..bb201b0a 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.10', + 'version': '19.0.1.0.13', 'category': 'Accounting/Accounting', 'summary': 'AI-augmented customer follow-ups (dunning) for unpaid invoices.', 'description': """ diff --git a/fusion_accounting_followup/models/__init__.py b/fusion_accounting_followup/models/__init__.py index 4a181971..ec9d216e 100644 --- a/fusion_accounting_followup/models/__init__.py +++ b/fusion_accounting_followup/models/__init__.py @@ -3,3 +3,4 @@ from . import fusion_followup_run from . import fusion_followup_text_cache from . import res_partner from . import account_move_line +from . import fusion_followup_engine diff --git a/fusion_accounting_followup/models/fusion_followup_engine.py b/fusion_accounting_followup/models/fusion_followup_engine.py new file mode 100644 index 00000000..21523427 --- /dev/null +++ b/fusion_accounting_followup/models/fusion_followup_engine.py @@ -0,0 +1,379 @@ +"""The follow-up engine — orchestrator for customer follow-ups. + +7-method public API. All controllers, AI tools, wizards, cron must +go through this engine; no direct ORM writes to fusion.followup.run +from elsewhere.""" + +import logging +from datetime import date, timedelta + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError, UserError + +from ..services.overdue_aging import compute_aging +from ..services.level_resolver import resolve_level, FollowupLevelSpec +from ..services.risk_scorer import score_partner +from ..services.tone_selector import select_tone +from ..services.followup_text_generator import generate_followup_text + +_logger = logging.getLogger(__name__) + + +class FusionFollowupEngine(models.AbstractModel): + _name = "fusion.followup.engine" + _description = "Fusion Follow-up Engine" + + # ============================================================ + # PUBLIC API (7 methods) + # ============================================================ + + @api.model + def get_overdue_for_partner(self, partner) -> dict: + """Return aging report + risk score for a partner.""" + partner.ensure_one() + as_of = fields.Date.today() + move_lines = self._fetch_overdue_lines(partner) + aging = compute_aging( + move_lines=[{ + 'date_maturity': l.date_maturity, + 'amount_residual': l.amount_residual, + } for l in move_lines], + as_of=as_of, + ) + risk = self._compute_risk(partner, move_lines) + return { + 'partner_id': partner.id, + 'as_of': str(as_of), + 'aging': aging.to_dict(), + 'risk': { + 'score': risk.score, + 'band': risk.band, + 'drivers': risk.drivers, + }, + 'overdue_line_count': len(move_lines), + } + + @api.model + def compute_followup_level(self, partner): + """Return the fusion.followup.level recordset that should fire now, + or empty recordset if no action needed.""" + partner.ensure_one() + Level = self.env['fusion.followup.level'] + if partner.fusion_followup_paused_until and \ + partner.fusion_followup_paused_until > fields.Date.today(): + return Level + + as_of = fields.Date.today() + move_lines = self._fetch_overdue_lines(partner) + if not move_lines: + return Level + aging = compute_aging( + move_lines=[{ + 'date_maturity': l.date_maturity, + 'amount_residual': l.amount_residual, + } for l in move_lines], + as_of=as_of, + ) + + company_id = partner.company_id.id if partner.company_id else self.env.company.id + levels = Level.search([ + ('active', '=', True), + '|', ('company_id', '=', company_id), ('company_id', '=', False), + ], order='sequence') + if not levels: + return Level + + specs = [FollowupLevelSpec( + sequence=l.sequence, name=l.name, + delay_days=l.delay_days, tone=l.tone, + ) for l in levels] + + chosen_spec = resolve_level(aging_report=aging, levels=specs) + if chosen_spec is None: + return Level + + return levels.filtered(lambda l: l.sequence == chosen_spec.sequence)[:1] + + @api.model + def send_followup_email(self, partner, *, level=None, force=False) -> dict: + """Send a follow-up email at the given level (or auto-resolve if None). + + Creates a fusion.followup.run record. Uses cached text if available.""" + partner.ensure_one() + + if not level: + level = self.compute_followup_level(partner) + if not level: + return {'status': 'no_action', 'partner_id': partner.id} + + if not force and partner.fusion_followup_paused_until and \ + partner.fusion_followup_paused_until > fields.Date.today(): + return { + 'status': 'paused_until_' + str(partner.fusion_followup_paused_until), + 'partner_id': partner.id, + } + + if level.requires_manual_review and not force: + run = self._create_run(partner, level, state='manual_review') + return { + 'status': 'manual_review', + 'partner_id': partner.id, + 'run_id': run.id, + } + + overdue_data = self.get_overdue_for_partner(partner) + if overdue_data['overdue_line_count'] == 0: + return {'status': 'no_overdue', 'partner_id': partner.id} + + tone = select_tone( + level_sequence=level.sequence, + risk_score=overdue_data['risk']['score'], + ) + + text_data = self._get_or_generate_text( + partner=partner, level=level, + overdue_amount=overdue_data['aging']['total_overdue_amount'], + longest_overdue_days=self._max_overdue_days_from_aging(overdue_data['aging']), + invoice_count=overdue_data['overdue_line_count'], + tone=tone, risk_drivers=overdue_data['risk']['drivers'], + ) + + run = self._create_run( + partner, level, state='draft', + overdue_amount=overdue_data['aging']['total_overdue_amount'], + longest_overdue_days=self._max_overdue_days_from_aging(overdue_data['aging']), + risk_score=overdue_data['risk']['score'], + risk_band=overdue_data['risk']['band'], + subject=text_data['subject'], + body=text_data['body'], + tone_used=text_data['tone_used'], + text_was_ai_generated=text_data.get('_was_ai', False), + ) + + try: + self._send_email(partner, run) + run.write({'state': 'sent'}) + partner.write({ + 'fusion_followup_status': 'no_action', + 'fusion_followup_last_level_id': level.id, + 'fusion_followup_last_run_date': fields.Datetime.now(), + }) + except Exception as e: + _logger.warning("Email send failed for partner %s: %s", partner.id, e) + run.write({'state': 'failed', 'error_message': str(e)}) + + return { + 'status': 'sent', 'partner_id': partner.id, + 'run_id': run.id, 'level_id': level.id, 'tone': tone, + } + + @api.model + def escalate_to_next_level(self, partner) -> dict: + """Force the next-higher level than the partner's current last_level.""" + partner.ensure_one() + Level = self.env['fusion.followup.level'] + current = partner.fusion_followup_last_level_id + next_seq = (current.sequence + 1) if current else 1 + company_id = partner.company_id.id if partner.company_id else self.env.company.id + next_level = Level.search([ + ('active', '=', True), + ('sequence', '>=', next_seq), + '|', ('company_id', '=', company_id), ('company_id', '=', False), + ], order='sequence', limit=1) + if not next_level: + return {'status': 'at_max_level', 'partner_id': partner.id} + return self.send_followup_email(partner, level=next_level, force=True) + + @api.model + def pause_followup(self, partner, until_date: date = None) -> dict: + """Pause follow-ups for a partner until a date (default 30 days).""" + partner.ensure_one() + until = until_date or (fields.Date.today() + timedelta(days=30)) + partner.write({ + 'fusion_followup_paused_until': until, + 'fusion_followup_status': 'paused', + }) + return {'partner_id': partner.id, 'paused_until': str(until)} + + @api.model + def reset_followup(self, partner) -> dict: + """Reset partner's follow-up state to no_action.""" + partner.ensure_one() + partner.write({ + 'fusion_followup_status': 'no_action', + 'fusion_followup_paused_until': False, + 'fusion_followup_last_level_id': False, + }) + return {'partner_id': partner.id, 'status': 'reset'} + + @api.model + def snapshot_followup_history(self, partner, *, limit: int = 50) -> dict: + """Return audit history for a partner.""" + partner.ensure_one() + Run = self.env['fusion.followup.run'] + runs = Run.search([ + ('partner_id', '=', partner.id), + ], order='execution_date desc', limit=int(limit)) + return { + 'partner_id': partner.id, + 'count': len(runs), + 'runs': [{ + 'id': r.id, 'date': str(r.execution_date), + 'level_id': r.level_id.id if r.level_id else None, + 'level_name': r.level_id.name if r.level_id else '', + 'state': r.state, + 'overdue_amount': r.overdue_amount, + 'longest_overdue_days': r.longest_overdue_days, + 'tone_used': r.tone_used, + 'risk_score': r.risk_score, + 'subject': r.subject or '', + 'text_was_ai_generated': r.text_was_ai_generated, + } for r in runs], + } + + # ============================================================ + # PRIVATE HELPERS + # ============================================================ + + def _fetch_overdue_lines(self, partner): + """Fetch posted, unreconciled receivable lines for a partner.""" + Line = self.env['account.move.line'].sudo() + return Line.search([ + ('partner_id', '=', partner.id), + ('parent_state', '=', 'posted'), + ('account_id.account_type', '=', 'asset_receivable'), + ('reconciled', '=', False), + ('amount_residual', '>', 0), + ]) + + def _compute_risk(self, partner, overdue_lines): + """Compute risk score from partner's payment history.""" + Line = self.env['account.move.line'].sudo() + all_lines = Line.search([ + ('partner_id', '=', partner.id), + ('parent_state', '=', 'posted'), + ('account_id.account_type', '=', 'asset_receivable'), + ]) + total_invoices = len(all_lines) + # Heavy paid-late computation deferred to Phase 4.5 + paid_late_count = 0 + avg_days_late = 0.0 + + as_of = fields.Date.today() + longest_overdue_days = 0 + for line in overdue_lines: + if line.date_maturity: + days = (as_of - line.date_maturity).days + if days > longest_overdue_days: + longest_overdue_days = days + + open_overdue = sum(line.amount_residual for line in overdue_lines) + avg_invoice_amount = 1000.0 + if total_invoices > 0: + total_amount = sum(all_lines.mapped('balance')) + if total_amount: + avg_invoice_amount = abs(total_amount) / total_invoices + + return score_partner( + total_invoices=total_invoices, + paid_late_count=paid_late_count, + avg_days_late=avg_days_late, + longest_overdue_days=longest_overdue_days, + open_overdue_amount=open_overdue, + average_invoice_amount=avg_invoice_amount, + ) + + def _max_overdue_days_from_aging(self, aging_dict): + """Extract longest overdue days from aging dict.""" + tracked = aging_dict.get('max_days_overdue', 0) or 0 + if tracked: + return tracked + max_days = 0 + for b in aging_dict.get('buckets', []): + if b['name'] == 'current' or b['amount'] <= 0: + continue + if b['days_max'] is None: + max_days = max(max_days, b['days_min']) + else: + max_days = max(max_days, b['days_max']) + return max_days + + def _get_or_generate_text(self, *, partner, level, overdue_amount, + longest_overdue_days, invoice_count, tone, + risk_drivers=None) -> dict: + """Cache lookup + LLM fallback.""" + Cache = self.env['fusion.followup.text.cache'] + cached = Cache.lookup( + partner_id=partner.id, level_id=level.id, + overdue_amount=overdue_amount, + longest_overdue_days=longest_overdue_days, + invoice_count=invoice_count, tone=tone, + ) + if cached: + cached.action_increment_use() + return { + 'subject': cached.subject, 'body': cached.body, + 'tone_used': cached.tone_used, + 'key_points': cached.key_points or [], + '_was_ai': bool(cached.provider), + } + + company = partner.company_id or self.env.company + currency = company.currency_id + text = generate_followup_text( + self.env, + partner_name=partner.name, + total_overdue=overdue_amount, + currency_code=currency.name or 'USD', + longest_overdue_days=longest_overdue_days, + tone=tone, invoice_count=invoice_count, + risk_drivers=risk_drivers, + ) + try: + Cache.sudo().create({ + 'partner_id': partner.id, 'level_id': level.id, + 'company_id': company.id, + 'fingerprint': Cache.compute_fingerprint( + partner_id=partner.id, level_id=level.id, + overdue_amount=overdue_amount, + longest_overdue_days=longest_overdue_days, + invoice_count=invoice_count, tone=tone, + ), + 'subject': text['subject'], 'body': text['body'], + 'tone_used': text.get('tone_used', tone), + 'key_points': text.get('key_points', []), + }) + except Exception as e: + _logger.debug("Cache create failed (non-fatal): %s", e) + + text['_was_ai'] = False + return text + + def _create_run(self, partner, level, *, state='draft', **vals): + Run = self.env['fusion.followup.run'].sudo() + company = partner.company_id or self.env.company + defaults = { + 'partner_id': partner.id, + 'company_id': company.id, + 'level_id': level.id if level else False, + 'state': state, + } + defaults.update(vals) + return Run.create(defaults) + + def _send_email(self, partner, run): + """Best-effort email send. Uses level's mail_template if set, else + creates a simple message.""" + if not partner.email: + raise UserError(_("Partner %s has no email address.") % partner.name) + if run.level_id and run.level_id.mail_template_id: + run.level_id.mail_template_id.send_mail(partner.id, force_send=True) + else: + body_text = (run.body or '').replace('<', '<').replace('>', '>') + self.env['mail.mail'].sudo().create({ + 'subject': run.subject or 'Follow-up', + 'body_html': '
{}
'.format(body_text), + 'email_to': partner.email, + 'recipient_ids': [(4, partner.id)], + }).send() + run.write({'sent_to_email': partner.email}) diff --git a/fusion_accounting_followup/tests/__init__.py b/fusion_accounting_followup/tests/__init__.py index 4222b80d..49561b1d 100644 --- a/fusion_accounting_followup/tests/__init__.py +++ b/fusion_accounting_followup/tests/__init__.py @@ -8,3 +8,4 @@ from . import test_fusion_followup_run from . import test_fusion_followup_text_cache from . import test_res_partner_inherit from . import test_account_move_line_inherit +from . import test_fusion_followup_engine diff --git a/fusion_accounting_followup/tests/test_fusion_followup_engine.py b/fusion_accounting_followup/tests/test_fusion_followup_engine.py new file mode 100644 index 00000000..41ef9a6d --- /dev/null +++ b/fusion_accounting_followup/tests/test_fusion_followup_engine.py @@ -0,0 +1,74 @@ +"""Unit tests for the fusion.followup.engine 7-method API.""" + +from datetime import date, timedelta +from odoo.tests.common import TransactionCase +from odoo.tests import tagged + + +@tagged('post_install', '-at_install') +class TestFusionFollowupEngine(TransactionCase): + + def setUp(self): + super().setUp() + self.engine = self.env['fusion.followup.engine'] + self.partner = self.env['res.partner'].create({ + 'name': 'Engine Test Partner', 'email': 'engine@test.local', + }) + for seq, name, days, tone in [(901, 'Reminder', 7, 'gentle'), + (902, 'Warning', 30, 'firm'), + (903, 'Legal', 60, 'legal')]: + self.env['fusion.followup.level'].create({ + 'name': name, 'sequence': seq, + 'delay_days': days, 'tone': tone, + }) + + def test_engine_model_exists(self): + self.assertIn('fusion.followup.engine', self.env.registry) + + def test_get_overdue_returns_dict(self): + result = self.engine.get_overdue_for_partner(self.partner) + self.assertIn('aging', result) + self.assertIn('risk', result) + self.assertEqual(result['partner_id'], self.partner.id) + + def test_compute_followup_level_no_overdue_returns_empty(self): + result = self.engine.compute_followup_level(self.partner) + self.assertFalse(result) + + def test_pause_sets_partner_state(self): + until = date.today() + timedelta(days=14) + self.engine.pause_followup(self.partner, until_date=until) + self.partner.invalidate_recordset(['fusion_followup_paused_until', 'fusion_followup_status']) + self.assertEqual(self.partner.fusion_followup_paused_until, until) + self.assertEqual(self.partner.fusion_followup_status, 'paused') + + def test_reset_clears_state(self): + self.engine.pause_followup(self.partner) + self.engine.reset_followup(self.partner) + self.partner.invalidate_recordset([ + 'fusion_followup_status', 'fusion_followup_paused_until', + 'fusion_followup_last_level_id', + ]) + self.assertEqual(self.partner.fusion_followup_status, 'no_action') + self.assertFalse(self.partner.fusion_followup_paused_until) + + def test_snapshot_history_returns_runs(self): + Run = self.env['fusion.followup.run'] + run = Run.create({ + 'partner_id': self.partner.id, + 'state': 'sent', + 'overdue_amount': 500, + }) + result = self.engine.snapshot_followup_history(self.partner) + self.assertEqual(result['count'], 1) + self.assertEqual(result['runs'][0]['id'], run.id) + + def test_send_no_overdue_returns_no_action(self): + Level = self.env['fusion.followup.level'] + level = Level.search([('sequence', '=', 901)], limit=1) + result = self.engine.send_followup_email(self.partner, level=level, force=True) + self.assertEqual(result['status'], 'no_overdue') + + def test_escalate_when_no_current_level(self): + result = self.engine.escalate_to_next_level(self.partner) + self.assertIn('partner_id', result)