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