"""HTTP controller: 6 JSON-RPC endpoints for the OWL follow-up dashboard. All endpoints route through fusion.followup.engine. V19 type='jsonrpc'. """ import logging from datetime import date, datetime from odoo import _, http from odoo.exceptions import ValidationError from odoo.http import request _logger = logging.getLogger(__name__) def _parse_date(value): if isinstance(value, date): return value if not value: return None return datetime.strptime(value, '%Y-%m-%d').date() class FusionFollowupController(http.Controller): @http.route('/fusion/followup/list_overdue', type='jsonrpc', auth='user') def list_overdue(self, limit=50, offset=0, status=None, company_id=None): company_id = int(company_id) if company_id else request.env.company.id Partner = request.env['res.partner'].sudo() Line = request.env['account.move.line'].sudo() overdue_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 domain = [('id', 'in', overdue_partner_ids)] if status: domain.append(('fusion_followup_status', '=', status)) total = Partner.search_count(domain) partners = Partner.search(domain, limit=int(limit), offset=int(offset)) engine = request.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, 'email': p.email or '', 'status': p.fusion_followup_status, 'paused_until': str(p.fusion_followup_paused_until) if p.fusion_followup_paused_until else None, 'last_level_id': p.fusion_followup_last_level_id.id if p.fusion_followup_last_level_id else None, 'last_level_name': p.fusion_followup_last_level_id.name if p.fusion_followup_last_level_id else None, 'last_run_date': str(p.fusion_followup_last_run_date) if p.fusion_followup_last_run_date else None, 'overdue_amount': overdue['aging']['total_overdue_amount'], 'overdue_line_count': overdue['overdue_line_count'], 'risk_score': overdue['risk']['score'], 'risk_band': overdue['risk']['band'], }) except Exception as e: _logger.warning("Skipping partner %s in list: %s", p.id, e) return {'count': len(rows), 'total': total, 'partners': rows} @http.route('/fusion/followup/get_partner_detail', type='jsonrpc', auth='user') def get_partner_detail(self, partner_id): partner = request.env['res.partner'].browse(int(partner_id)) if not partner.exists(): raise ValidationError(_("Partner %s not found") % partner_id) engine = request.env['fusion.followup.engine'] overdue = engine.get_overdue_for_partner(partner) history = engine.snapshot_followup_history(partner, limit=20) level = engine.compute_followup_level(partner) return { 'partner': { 'id': partner.id, 'name': partner.name, 'email': partner.email or '', 'status': partner.fusion_followup_status, 'paused_until': str(partner.fusion_followup_paused_until) if partner.fusion_followup_paused_until else None, 'last_level_id': partner.fusion_followup_last_level_id.id if partner.fusion_followup_last_level_id else None, 'last_level_name': partner.fusion_followup_last_level_id.name if partner.fusion_followup_last_level_id else None, 'last_run_date': str(partner.fusion_followup_last_run_date) if partner.fusion_followup_last_run_date else None, 'risk_score': partner.fusion_followup_risk_score, 'risk_band': partner.fusion_followup_risk_band, }, 'overdue': overdue, 'suggested_level': { 'id': level.id, 'name': level.name, 'tone': level.tone, 'sequence': level.sequence, } if level else None, 'history': history, } @http.route('/fusion/followup/generate_text', type='jsonrpc', auth='user') def generate_text(self, partner_id, level_id=None, force_regenerate=False): from odoo.addons.fusion_accounting_followup.services.followup_text_generator import ( generate_followup_text, ) from odoo.addons.fusion_accounting_followup.services.tone_selector import select_tone partner = request.env['res.partner'].browse(int(partner_id)) engine = request.env['fusion.followup.engine'] if level_id: level = request.env['fusion.followup.level'].browse(int(level_id)) else: level = engine.compute_followup_level(partner) if not level: return {'status': 'no_level', 'partner_id': partner.id} overdue = engine.get_overdue_for_partner(partner) tone = select_tone( level_sequence=level.sequence, risk_score=overdue['risk']['score'], ) currency_code = 'USD' if partner.company_id and partner.company_id.currency_id: currency_code = partner.company_id.currency_id.name or 'USD' text = generate_followup_text( request.env, partner_name=partner.name, total_overdue=overdue['aging']['total_overdue_amount'], currency_code=currency_code, longest_overdue_days=engine._max_overdue_days_from_aging(overdue['aging']), tone=tone, invoice_count=overdue['overdue_line_count'], risk_drivers=overdue['risk']['drivers'], ) return { 'status': 'ok', 'partner_id': partner.id, 'level_id': level.id, 'tone': tone, 'subject': text.get('subject', ''), 'body': text.get('body', ''), 'tone_used': text.get('tone_used', tone), 'key_points': text.get('key_points', []), } @http.route('/fusion/followup/send', type='jsonrpc', auth='user') def send_followup(self, partner_id, level_id=None, force=False): partner = request.env['res.partner'].browse(int(partner_id)) engine = request.env['fusion.followup.engine'] level = None if level_id: level = request.env['fusion.followup.level'].browse(int(level_id)) return engine.send_followup_email(partner, level=level, force=bool(force)) @http.route('/fusion/followup/pause', type='jsonrpc', auth='user') def pause(self, partner_id, until_date=None): partner = request.env['res.partner'].browse(int(partner_id)) engine = request.env['fusion.followup.engine'] return engine.pause_followup(partner, until_date=_parse_date(until_date)) @http.route('/fusion/followup/reset', type='jsonrpc', auth='user') def reset(self, partner_id): partner = request.env['res.partner'].browse(int(partner_id)) engine = request.env['fusion.followup.engine'] return engine.reset_followup(partner)