From 6791246def44a49d32005628ccc58196c2e41680 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sat, 18 Apr 2026 23:30:20 -0400 Subject: [PATCH] refactor(fusion_accounting_ai): route accounts_receivable tools through FollowupAdapter MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Task 13 Step 7 of phase-0 plan. Routes the AR tools through the FollowupAdapter so they work identically on fusion-native, Enterprise, and pure Community installs: - get_ar_aging → FollowupAdapter.aged_receivables() - get_overdue_invoices → FollowupAdapter.overdue_invoices() - send_followup → FollowupAdapter.send_followup() - get_followup_report → FollowupAdapter.followup_report_html() FollowupAdapter extended: - overdue_invoices() now includes partner_email, partner_phone and amount_total so the tool wrapper can render its richer response. - aged_receivables() and aged_payables() new shared-implementation method _aged_buckets() produces the 5-bucket aging shape the AR/AP tools emit. - followup_report_html() and send_followup() isolate the Enterprise account.followup.report / partner.execute_followup calls; Community mode returns a graceful error dict. Pure-Community tools in accounts_receivable.py (get_partner_balance, reconcile_payment_to_invoice, get_unmatched_payments) unchanged — they touch account.move / account.move.line directly which is tri-mode safe. 3 new data-adapter tests added (total: 9; all passing on westin-v19). Made-with: Cursor --- .../services/data_adapters/followup.py | 183 +++++++++++++++++- .../services/tools/accounts_receivable.py | 97 +++------- .../tests/test_data_adapters.py | 32 +++ 3 files changed, 236 insertions(+), 76 deletions(-) diff --git a/fusion_accounting_ai/services/data_adapters/followup.py b/fusion_accounting_ai/services/data_adapters/followup.py index c6d98a76..067011f2 100644 --- a/fusion_accounting_ai/services/data_adapters/followup.py +++ b/fusion_accounting_ai/services/data_adapters/followup.py @@ -1,24 +1,56 @@ -"""Follow-up data adapter.""" +"""Follow-up data adapter. + +Routes follow-up / aged-balance / collections data lookups across: +- FUSION: fusion.followup.line (added by future fusion_accounting_followup, Phase 2) +- ENTERPRISE: account_followup's account.followup.line + account.followup.report +- COMMUNITY: aggregations on account.move / account.move.line +""" from datetime import date, timedelta from .base import DataAdapter from ._registry import register_adapter +# Default aging bucket edges used for both AR and AP. +_AGING_BUCKETS = ('current', '1_30', '31_60', '61_90', '90_plus') + + +def _bucket_for_days(days): + if days <= 0: + return 'current' + if days <= 30: + return '1_30' + if days <= 60: + return '31_60' + if days <= 90: + return '61_90' + return '90_plus' + + class FollowupAdapter(DataAdapter): FUSION_MODEL = 'fusion.followup.line' ENTERPRISE_MODULE = 'account_followup' - def overdue_invoices(self, days_overdue=30, partner_id=None): - return self._dispatch('overdue_invoices', days_overdue=days_overdue, partner_id=partner_id) + # ------------------------------------------------------------------ + # overdue_invoices + # ------------------------------------------------------------------ + def overdue_invoices(self, days_overdue=30, partner_id=None, limit=200): + return self._dispatch( + 'overdue_invoices', + days_overdue=days_overdue, partner_id=partner_id, limit=limit, + ) - def overdue_invoices_via_fusion(self, days_overdue=30, partner_id=None): - return self.overdue_invoices_via_community(days_overdue=days_overdue, partner_id=partner_id) + def overdue_invoices_via_fusion(self, days_overdue=30, partner_id=None, limit=200): + return self.overdue_invoices_via_community( + days_overdue=days_overdue, partner_id=partner_id, limit=limit, + ) - def overdue_invoices_via_enterprise(self, days_overdue=30, partner_id=None): - return self.overdue_invoices_via_community(days_overdue=days_overdue, partner_id=partner_id) + def overdue_invoices_via_enterprise(self, days_overdue=30, partner_id=None, limit=200): + return self.overdue_invoices_via_community( + days_overdue=days_overdue, partner_id=partner_id, limit=limit, + ) - def overdue_invoices_via_community(self, days_overdue=30, partner_id=None): + def overdue_invoices_via_community(self, days_overdue=30, partner_id=None, limit=200): cutoff = date.today() - timedelta(days=days_overdue) domain = [ ('move_type', 'in', ('out_invoice', 'out_refund')), @@ -28,20 +60,151 @@ class FollowupAdapter(DataAdapter): ] if partner_id: domain.append(('partner_id', '=', partner_id)) - moves = self.env['account.move'].sudo().search(domain, limit=200, order='invoice_date_due asc') + moves = self.env['account.move'].sudo().search( + domain, limit=limit, order='invoice_date_due asc', + ) + today = date.today() return [ { 'id': m.id, 'name': m.name, 'partner_id': m.partner_id.id, 'partner_name': m.partner_id.name, + 'partner_email': m.partner_id.email or '', + 'partner_phone': m.partner_id.phone or '', 'invoice_date_due': m.invoice_date_due, + 'amount_total': m.amount_total, 'amount_residual': m.amount_residual, 'currency_id': m.currency_id.id, - 'days_overdue': (date.today() - m.invoice_date_due).days, + 'days_overdue': (today - m.invoice_date_due).days if m.invoice_date_due else 0, } for m in moves ] + # ------------------------------------------------------------------ + # aged_receivables + # ------------------------------------------------------------------ + def aged_receivables(self, company_id=None): + return self._dispatch('aged_receivables', company_id=company_id) + + def aged_receivables_via_fusion(self, company_id=None): + return self.aged_receivables_via_community(company_id=company_id) + + def aged_receivables_via_enterprise(self, company_id=None): + return self.aged_receivables_via_community(company_id=company_id) + + def aged_receivables_via_community(self, company_id=None): + return self._aged_buckets( + account_type='asset_receivable', + company_id=company_id, + sign=1, + ) + + # ------------------------------------------------------------------ + # aged_payables + # ------------------------------------------------------------------ + def aged_payables(self, company_id=None): + return self._dispatch('aged_payables', company_id=company_id) + + def aged_payables_via_fusion(self, company_id=None): + return self.aged_payables_via_community(company_id=company_id) + + def aged_payables_via_enterprise(self, company_id=None): + return self.aged_payables_via_community(company_id=company_id) + + def aged_payables_via_community(self, company_id=None): + return self._aged_buckets( + account_type='liability_payable', + company_id=company_id, + sign=-1, # AP residuals are negative; report as positive amounts + ) + + def _aged_buckets(self, account_type, company_id=None, sign=1): + """Shared aging-bucket implementation for receivable/payable accounts. + + Returns a dict: {'total': ..., 'buckets': {...}, 'line_count': N}. + `sign=-1` flips the sign so payables report as positive owed amounts. + """ + today = date.today() + domain = [ + ('account_id.account_type', '=', account_type), + ('parent_state', '=', 'posted'), + ('reconciled', '=', False), + ] + if company_id is not None: + domain.append(('company_id', '=', company_id)) + amls = self.env['account.move.line'].sudo().search(domain) + + buckets = {k: 0.0 for k in _AGING_BUCKETS} + for aml in amls: + amt = aml.amount_residual + if sign < 0: + amt = abs(amt) + if not aml.date_maturity or aml.date_maturity >= today: + buckets['current'] += amt + else: + days = (today - aml.date_maturity).days + buckets[_bucket_for_days(days)] += amt + + return { + 'total': sum(buckets.values()), + 'buckets': buckets, + 'line_count': len(amls), + } + + # ------------------------------------------------------------------ + # followup_report_html — Enterprise-only artifact + # ------------------------------------------------------------------ + def followup_report_html(self, partner_id): + return self._dispatch('followup_report_html', partner_id=partner_id) + + def followup_report_html_via_fusion(self, partner_id): + # Phase 2 will implement a native version. + return self.followup_report_html_via_community(partner_id=partner_id) + + def followup_report_html_via_enterprise(self, partner_id): + partner = self.env['res.partner'].browse(partner_id) + if not partner.exists(): + return {'error': 'Partner not found'} + report = self.env['account.followup.report'] + html = report._get_followup_report_html(partner) + return {'partner': partner.name, 'html': html} + + def followup_report_html_via_community(self, partner_id): + return { + 'error': ( + 'Follow-up report is only available when account_followup ' + '(Enterprise) or a fusion follow-up module is installed.' + ), + } + + # ------------------------------------------------------------------ + # send_followup — Enterprise-only action + # ------------------------------------------------------------------ + def send_followup(self, partner_id, options=None): + return self._dispatch('send_followup', partner_id=partner_id, options=options) + + def send_followup_via_fusion(self, partner_id, options=None): + return self.send_followup_via_community(partner_id=partner_id, options=options) + + def send_followup_via_enterprise(self, partner_id, options=None): + partner = self.env['res.partner'].browse(partner_id) + if not partner.exists(): + return {'error': 'Partner not found'} + result = partner.execute_followup(options or {'partner_id': partner_id}) + return { + 'status': 'sent', + 'partner': partner.name, + 'result': str(result) if result else 'done', + } + + def send_followup_via_community(self, partner_id, options=None): + return { + 'error': ( + 'Sending follow-ups is only available when account_followup ' + '(Enterprise) or a fusion follow-up module is installed.' + ), + } + register_adapter('followup', FollowupAdapter) diff --git a/fusion_accounting_ai/services/tools/accounts_receivable.py b/fusion_accounting_ai/services/tools/accounts_receivable.py index 1f7dc518..0e1f2c49 100644 --- a/fusion_accounting_ai/services/tools/accounts_receivable.py +++ b/fusion_accounting_ai/services/tools/accounts_receivable.py @@ -1,66 +1,36 @@ import logging -from odoo import fields _logger = logging.getLogger(__name__) def get_ar_aging(env, params): - today = fields.Date.today() - domain = [ - ('account_id.account_type', '=', 'asset_receivable'), - ('parent_state', '=', 'posted'), - ('reconciled', '=', False), - ('company_id', '=', env.company.id), - ] - amls = env['account.move.line'].search(domain) - - buckets = {'current': 0, '1_30': 0, '31_60': 0, '61_90': 0, '90_plus': 0} - for aml in amls: - if not aml.date_maturity or aml.date_maturity >= today: - buckets['current'] += aml.amount_residual - else: - days = (today - aml.date_maturity).days - if days <= 30: - buckets['1_30'] += aml.amount_residual - elif days <= 60: - buckets['31_60'] += aml.amount_residual - elif days <= 90: - buckets['61_90'] += aml.amount_residual - else: - buckets['90_plus'] += aml.amount_residual - - return { - 'total': sum(buckets.values()), - 'buckets': buckets, - 'line_count': len(amls), - } + """Return AR aging buckets. Routed through FollowupAdapter for tri-mode consistency.""" + from ..data_adapters import get_adapter + adapter = get_adapter(env, 'followup') + return adapter.aged_receivables(company_id=env.company.id) def get_overdue_invoices(env, params): - today = fields.Date.today() - days_overdue = int(params.get('min_days_overdue', 1)) - from datetime import timedelta - cutoff = today - timedelta(days=days_overdue) - invoices = env['account.move'].search([ - ('move_type', '=', 'out_invoice'), - ('state', '=', 'posted'), - ('payment_state', 'in', ('not_paid', 'partial')), - ('invoice_date_due', '<', cutoff), - ('company_id', '=', env.company.id), - ], order='invoice_date_due asc', limit=int(params.get('limit', 50))) + """Return overdue customer invoices. Routed through FollowupAdapter.""" + from ..data_adapters import get_adapter + adapter = get_adapter(env, 'followup') + rows = adapter.overdue_invoices( + days_overdue=int(params.get('min_days_overdue', 1)), + limit=int(params.get('limit', 50)), + ) return { - 'count': len(invoices), + 'count': len(rows), 'invoices': [{ - 'id': inv.id, - 'name': inv.name, - 'partner': inv.partner_id.name if inv.partner_id else '', - 'email': inv.partner_id.email or '' if inv.partner_id else '', - 'phone': inv.partner_id.phone or '' if inv.partner_id else '', - 'amount_total': inv.amount_total, - 'amount_residual': inv.amount_residual, - 'date_due': str(inv.invoice_date_due), - 'days_overdue': (today - inv.invoice_date_due).days, - } for inv in invoices], + 'id': r['id'], + 'name': r['name'], + 'partner': r['partner_name'] or '', + 'email': r['partner_email'], + 'phone': r['partner_phone'], + 'amount_total': r['amount_total'], + 'amount_residual': r['amount_residual'], + 'date_due': str(r['invoice_date_due']) if r['invoice_date_due'] else '', + 'days_overdue': r['days_overdue'], + } for r in rows], } @@ -119,10 +89,10 @@ def get_partner_balance(env, params): def send_followup(env, params): + """Send a follow-up to a partner. Routed through FollowupAdapter so the + Enterprise-only execute_followup path is isolated behind the adapter.""" + from ..data_adapters import get_adapter partner_id = int(params['partner_id']) - partner = env['res.partner'].browse(partner_id) - if not partner.exists(): - return {'error': 'Partner not found'} options = { 'partner_id': partner_id, 'email': params.get('send_email', False), @@ -133,21 +103,16 @@ def send_followup(env, params): options['email_subject'] = params['email_subject'] if params.get('body'): options['body'] = params['body'] - result = partner.execute_followup(options) - return {'status': 'sent', 'partner': partner.name, 'result': str(result) if result else 'done'} + adapter = get_adapter(env, 'followup') + return adapter.send_followup(partner_id=partner_id, options=options) def get_followup_report(env, params): + """Return the follow-up report HTML for a partner. Routed through FollowupAdapter.""" + from ..data_adapters import get_adapter partner_id = int(params['partner_id']) - partner = env['res.partner'].browse(partner_id) - if not partner.exists(): - return {'error': 'Partner not found'} - try: - report = env['account.followup.report'] - html = report._get_followup_report_html(partner) - return {'partner': partner.name, 'html': html} - except Exception as e: - return {'error': str(e)} + adapter = get_adapter(env, 'followup') + return adapter.followup_report_html(partner_id=partner_id) def reconcile_payment_to_invoice(env, params): diff --git a/fusion_accounting_ai/tests/test_data_adapters.py b/fusion_accounting_ai/tests/test_data_adapters.py index 0d6de1a8..c5f71b2f 100644 --- a/fusion_accounting_ai/tests/test_data_adapters.py +++ b/fusion_accounting_ai/tests/test_data_adapters.py @@ -83,6 +83,38 @@ class TestFollowupAdapter(TransactionCase): rows = adapter.overdue_invoices(days_overdue=30) self.assertIsInstance(rows, list) + def test_overdue_invoices_row_has_contact_fields(self): + """The enriched shape must include email, phone, and amount_total so + the accounts_receivable tool wrapper can render them.""" + adapter = get_adapter(self.env, 'followup') + rows = adapter.overdue_invoices(days_overdue=30, limit=5) + for row in rows: + for key in ( + 'id', 'name', 'partner_id', 'partner_name', + 'partner_email', 'partner_phone', + 'invoice_date_due', 'amount_total', 'amount_residual', + 'days_overdue', + ): + self.assertIn(key, row, f"Missing key {key!r} in overdue row") + + def test_aged_receivables_returns_bucket_shape(self): + adapter = get_adapter(self.env, 'followup') + result = adapter.aged_receivables(company_id=self.env.company.id) + self.assertIn('total', result) + self.assertIn('buckets', result) + self.assertIn('line_count', result) + for bucket in ('current', '1_30', '31_60', '61_90', '90_plus'): + self.assertIn(bucket, result['buckets']) + + def test_aged_payables_returns_bucket_shape(self): + adapter = get_adapter(self.env, 'followup') + result = adapter.aged_payables(company_id=self.env.company.id) + self.assertIn('total', result) + self.assertIn('buckets', result) + self.assertIn('line_count', result) + for bucket in ('current', '1_30', '31_60', '61_90', '90_plus'): + self.assertIn(bucket, result['buckets']) + @tagged('post_install', '-at_install') class TestAssetsAdapter(TransactionCase):