refactor(fusion_accounting_ai): route accounts_receivable tools through FollowupAdapter

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
This commit is contained in:
gsinghpal
2026-04-18 23:30:20 -04:00
parent 2a41f48123
commit 6791246def
3 changed files with 236 additions and 76 deletions

View File

@@ -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)

View File

@@ -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):

View File

@@ -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):