- Switch FUSION_MODEL to fusion.followup.engine so adapter mode selection matches the new module - Add list_overdue() with fusion/enterprise/community variants - Re-route send_followup_via_fusion to engine.send_followup_email - 4 new TransactionCase tests (73 total) Existing aging / overdue_invoices adapter methods continue to fall back to the community implementation. Made-with: Cursor
282 lines
11 KiB
Python
282 lines
11 KiB
Python
"""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.engine'
|
|
ENTERPRISE_MODULE = 'account_followup'
|
|
|
|
# ------------------------------------------------------------------
|
|
# 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, 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, 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, limit=200):
|
|
cutoff = date.today() - timedelta(days=days_overdue)
|
|
domain = [
|
|
('move_type', 'in', ('out_invoice', 'out_refund')),
|
|
('state', '=', 'posted'),
|
|
('payment_state', 'in', ('not_paid', 'partial')),
|
|
('invoice_date_due', '<=', cutoff),
|
|
]
|
|
if partner_id:
|
|
domain.append(('partner_id', '=', partner_id))
|
|
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': (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 — routes to fusion engine when available
|
|
# ------------------------------------------------------------------
|
|
def send_followup(self, partner_id, level_id=None, force=False, options=None):
|
|
return self._dispatch(
|
|
'send_followup',
|
|
partner_id=partner_id, level_id=level_id,
|
|
force=force, options=options,
|
|
)
|
|
|
|
def send_followup_via_fusion(self, partner_id, level_id=None,
|
|
force=False, options=None):
|
|
if 'fusion.followup.engine' not in self.env.registry:
|
|
return {'error': 'fusion_accounting_followup not installed'}
|
|
partner = self.env['res.partner'].browse(int(partner_id))
|
|
level = None
|
|
if level_id:
|
|
level = self.env['fusion.followup.level'].browse(int(level_id))
|
|
return self.env['fusion.followup.engine'].send_followup_email(
|
|
partner, level=level, force=bool(force),
|
|
)
|
|
|
|
def send_followup_via_enterprise(self, partner_id, level_id=None,
|
|
force=False, 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, level_id=None,
|
|
force=False, options=None):
|
|
return {
|
|
'error': (
|
|
'Sending follow-ups is only available when account_followup '
|
|
'(Enterprise) or a fusion follow-up module is installed.'
|
|
),
|
|
}
|
|
|
|
# ------------------------------------------------------------------
|
|
# list_overdue — partner-centric overdue rollup (fusion engine)
|
|
# ------------------------------------------------------------------
|
|
def list_overdue(self, status=None, limit=50, company_id=None):
|
|
return self._dispatch(
|
|
'list_overdue',
|
|
status=status, limit=limit, company_id=company_id,
|
|
)
|
|
|
|
def list_overdue_via_fusion(self, status=None, limit=50, company_id=None):
|
|
if 'fusion.followup.engine' not in self.env.registry:
|
|
return {'partners': [], 'count': 0, 'total': 0}
|
|
company_id = company_id or self.env.company.id
|
|
Line = self.env['account.move.line'].sudo()
|
|
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
|
|
Partner = self.env['res.partner'].sudo()
|
|
domain = [('id', 'in', partner_ids)]
|
|
if status:
|
|
domain.append(('fusion_followup_status', '=', status))
|
|
partners = Partner.search(domain, limit=int(limit))
|
|
engine = self.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,
|
|
'overdue_amount': overdue['aging']['total_overdue_amount'],
|
|
'risk_score': overdue['risk']['score'],
|
|
'risk_band': overdue['risk']['band'],
|
|
'status': p.fusion_followup_status,
|
|
})
|
|
except Exception:
|
|
pass
|
|
return {'count': len(rows), 'total': len(partner_ids), 'partners': rows}
|
|
|
|
def list_overdue_via_enterprise(self, status=None, limit=50, company_id=None):
|
|
return {
|
|
'partners': [], 'count': 0, 'total': 0,
|
|
'error': 'Enterprise account_followup must be used from its UI',
|
|
}
|
|
|
|
def list_overdue_via_community(self, status=None, limit=50, company_id=None):
|
|
return {
|
|
'partners': [], 'count': 0, 'total': 0,
|
|
'error': 'No follow-up engine in pure Community',
|
|
}
|
|
|
|
|
|
register_adapter('followup', FollowupAdapter)
|