# -*- coding: utf-8 -*- # Copyright 2024-2026 Nexa Systems Inc. # License OPL-1 (Odoo Proprietary License v1.0) import json import logging from datetime import date, timedelta from odoo import api, models _logger = logging.getLogger(__name__) PREV_FUNDED_FIELDS = { 'prev_funded_forearm': 'Forearm Crutches', 'prev_funded_wheeled': 'Wheeled Walker', 'prev_funded_manual': 'Manual Wheelchair', 'prev_funded_power': 'Power Wheelchair', 'prev_funded_addon': 'Power Add-On Device', 'prev_funded_scooter': 'Power Scooter', 'prev_funded_seating': 'Positioning Devices', 'prev_funded_tilt': 'Power Tilt System', 'prev_funded_recline': 'Power Recline System', 'prev_funded_legrests': 'Power Elevating Leg Rests', 'prev_funded_frame': 'Paediatric Standing Frame', 'prev_funded_stroller': 'Paediatric Specialty Stroller', } STATUS_NEXT_STEPS = { 'quotation': 'Schedule assessment with the client', 'assessment_scheduled': 'Complete the assessment', 'assessment_completed': 'Prepare and send ADP application to client', 'waiting_for_application': 'Follow up with client to return signed application', 'application_received': 'Review application and prepare for submission', 'ready_submission': 'Submit application to ADP', 'submitted': 'Wait for ADP acceptance (typically within 24 hours)', 'accepted': 'Wait for ADP approval decision', 'rejected': 'Review rejection reason and correct the application', 'resubmitted': 'Wait for ADP acceptance of resubmission', 'needs_correction': 'Review and correct the application per ADP feedback', 'approved': 'Prepare order for delivery', 'approved_deduction': 'Prepare order for delivery (note: approved with deduction)', 'ready_delivery': 'Schedule and complete delivery to client', 'ready_bill': 'Create and submit ADP invoice', 'billed': 'Monitor for ADP payment', 'case_closed': 'No further action required', 'on_hold': 'Check hold reason and follow up when ready to resume', 'denied': 'Review denial reason; consider appeal or alternative funding', 'withdrawn': 'No further action unless client wants to reinstate', 'cancelled': 'No further action required', 'expired': 'Contact client about reapplication if still needed', } class AIAgentFusionClaims(models.Model): """Extend ai.agent with Fusion Claims tool methods.""" _inherit = 'ai.agent' # ------------------------------------------------------------------ # Tool 1: Search Client Profiles # ------------------------------------------------------------------ def _fc_tool_search_clients(self, search_term=None, city_filter=None, condition_filter=None): """AI Tool: Search client profiles.""" Profile = self.env['fusion.client.profile'].sudo() domain = [] if search_term: domain = ['|', '|', '|', ('first_name', 'ilike', search_term), ('last_name', 'ilike', search_term), ('health_card_number', 'ilike', search_term), ('city', 'ilike', search_term), ] if city_filter: domain.append(('city', 'ilike', city_filter)) if condition_filter: domain.append(('medical_condition', 'ilike', condition_filter)) profiles = Profile.search(domain, limit=20) results = [] for p in profiles: results.append({ 'id': p.id, 'name': p.display_name, 'health_card': p.health_card_number or '', 'dob': str(p.date_of_birth) if p.date_of_birth else '', 'city': p.city or '', 'condition': (p.medical_condition or '')[:100], 'claims': p.claim_count, 'total_adp': float(p.total_adp_funded), 'total_client': float(p.total_client_portion), }) return json.dumps({'count': len(results), 'profiles': results}) # ------------------------------------------------------------------ # Tool 2: Get Client Details (enriched with funding history) # ------------------------------------------------------------------ def _fc_tool_client_details(self, profile_id): """AI Tool: Get detailed client information with funding history.""" Profile = self.env['fusion.client.profile'].sudo() profile = Profile.browse(int(profile_id)) if not profile.exists(): return json.dumps({'error': 'Profile not found'}) orders = [] if profile.partner_id: Invoice = self.env['account.move'].sudo() for o in self.env['sale.order'].sudo().search([ ('partner_id', '=', profile.partner_id.id), ('x_fc_sale_type', '!=', False), ], limit=20, order='date_order desc'): invoices = Invoice.search([ ('x_fc_source_sale_order_id', '=', o.id), ('move_type', '=', 'out_invoice'), ]) inv_summary = [] for inv in invoices: inv_summary.append({ 'number': inv.name or '', 'portion': inv.x_fc_adp_invoice_portion or 'full', 'amount': float(inv.amount_total), 'paid': inv.payment_state in ('paid', 'in_payment'), 'date': str(inv.invoice_date) if inv.invoice_date else '', }) orders.append({ 'name': o.name, 'sale_type': o.x_fc_sale_type, 'status': o.x_fc_adp_application_status or '', 'adp_total': float(o.x_fc_adp_portion_total), 'client_total': float(o.x_fc_client_portion_total), 'total': float(o.amount_total), 'date': str(o.date_order.date()) if o.date_order else '', 'billing_date': str(o.x_fc_billing_date) if o.x_fc_billing_date else '', 'previous_funding_date': str(o.x_fc_previous_funding_date) if o.x_fc_previous_funding_date else '', 'funding_warning': o.x_fc_funding_warning_message or '', 'funding_warning_level': o.x_fc_funding_warning_level or '', 'invoices': inv_summary, }) apps = [] for a in profile.application_data_ids[:10]: funded_devices = [ label for field, label in PREV_FUNDED_FIELDS.items() if getattr(a, field, False) ] apps.append({ 'date': str(a.application_date) if a.application_date else '', 'device': a.base_device or '', 'category': a.device_category or '', 'reason': a.reason_for_application or '', 'condition': (a.medical_condition or '')[:100], 'authorizer': f'{a.authorizer_first_name or ""} {a.authorizer_last_name or ""}'.strip(), 'previously_funded': funded_devices if funded_devices else ['None'], }) ai_summary = '' ai_risk = '' try: ai_summary = profile.ai_summary or '' ai_risk = profile.ai_risk_flags or '' except Exception: pass return json.dumps({ 'profile': { 'id': profile.id, 'name': profile.display_name, 'first_name': profile.first_name, 'last_name': profile.last_name, 'health_card': profile.health_card_number or '', 'dob': str(profile.date_of_birth) if profile.date_of_birth else '', 'city': profile.city or '', 'province': profile.province or '', 'postal_code': profile.postal_code or '', 'phone': profile.home_phone or '', 'condition': profile.medical_condition or '', 'mobility': profile.mobility_status or '', 'benefits': { 'social_assistance': profile.receives_social_assistance, 'type': profile.benefit_type or '', 'wsib': profile.wsib_eligible, 'vac': profile.vac_eligible, }, 'claims_count': profile.claim_count, 'total_adp': float(profile.total_adp_funded), 'total_client': float(profile.total_client_portion), 'total_amount': float(profile.total_amount), 'applications_count': profile.application_count, 'last_assessment': str(profile.last_assessment_date) if profile.last_assessment_date else '', 'ai_summary': ai_summary, 'ai_risk_flags': ai_risk, }, 'orders': orders, 'applications': apps, }) # ------------------------------------------------------------------ # Tool 3: Get Aggregated Stats (migrated from read_group) # ------------------------------------------------------------------ def _fc_tool_claims_stats(self): """AI Tool: Get aggregated claims statistics.""" SO = self.env['sale.order'].sudo() Profile = self.env['fusion.client.profile'].sudo() total_profiles = Profile.search_count([]) total_orders = SO.search_count([('x_fc_sale_type', '!=', False)]) by_type = {} try: type_results = SO._read_group( [('x_fc_sale_type', '!=', False)], groupby=['x_fc_sale_type'], aggregates=['__count', 'amount_total:sum'], ) for sale_type, count, total_amount in type_results: by_type[sale_type or 'unknown'] = { 'count': count, 'total': float(total_amount or 0), } except Exception as e: _logger.warning('Stats by_type failed: %s', e) by_status = {} try: status_results = SO._read_group( [('x_fc_sale_type', '!=', False), ('x_fc_adp_application_status', '!=', False)], groupby=['x_fc_adp_application_status'], aggregates=['__count'], ) for status, count in status_results: by_status[status or 'unknown'] = count except Exception as e: _logger.warning('Stats by_status failed: %s', e) by_city = {} try: city_results = Profile._read_group( [('city', '!=', False)], groupby=['city'], aggregates=['__count'], limit=10, order='__count desc', ) for city, count in city_results: by_city[city or 'unknown'] = count except Exception as e: _logger.warning('Stats by_city failed: %s', e) return json.dumps({ 'total_profiles': total_profiles, 'total_orders': total_orders, 'by_sale_type': by_type, 'by_status': by_status, 'top_cities': by_city, }) # ------------------------------------------------------------------ # Tool 4: Client Status Lookup (by name, not order number) # ------------------------------------------------------------------ def _fc_tool_client_status(self, client_name): """AI Tool: Look up a client's complete status by name.""" if not client_name or not client_name.strip(): return json.dumps({'error': 'Please provide a client name to search for'}) client_name = client_name.strip() Profile = self.env['fusion.client.profile'].sudo() SO = self.env['sale.order'].sudo() Invoice = self.env['account.move'].sudo() profiles = Profile.search([ '|', ('first_name', 'ilike', client_name), ('last_name', 'ilike', client_name), ], limit=5) if not profiles: partners = self.env['res.partner'].sudo().search([ ('name', 'ilike', client_name), ], limit=5) if not partners: return json.dumps({'error': f'No client found matching "{client_name}"'}) results = [] for partner in partners: orders = SO.search([ ('partner_id', '=', partner.id), ('x_fc_sale_type', '!=', False), ], order='date_order desc', limit=20) if not orders: continue results.append(self._build_client_status_result( partner_name=partner.name, partner_id=partner.id, profile=None, orders=orders, Invoice=Invoice, )) if not results: return json.dumps({'error': f'No orders found for "{client_name}"'}) return json.dumps({'clients': results}) results = [] for profile in profiles: orders = SO.search([ ('partner_id', '=', profile.partner_id.id), ('x_fc_sale_type', '!=', False), ], order='date_order desc', limit=20) if profile.partner_id else SO results.append(self._build_client_status_result( partner_name=profile.display_name, partner_id=profile.partner_id.id if profile.partner_id else None, profile=profile, orders=orders if profile.partner_id else SO.browse(), Invoice=Invoice, )) return json.dumps({'clients': results}) def _build_client_status_result(self, partner_name, partner_id, profile, orders, Invoice): """Build a complete status result for one client.""" order_data = [] for o in orders: invoices = Invoice.search([ ('x_fc_source_sale_order_id', '=', o.id), ('move_type', '=', 'out_invoice'), ]) adp_inv = invoices.filtered(lambda i: i.x_fc_adp_invoice_portion == 'adp') client_inv = invoices.filtered(lambda i: i.x_fc_adp_invoice_portion == 'client') docs = { 'original_application': bool(o.x_fc_original_application), 'signed_pages': bool(o.x_fc_signed_pages_11_12), 'xml_file': bool(o.x_fc_xml_file), 'proof_of_delivery': bool(o.x_fc_proof_of_delivery), } status = o.x_fc_adp_application_status or '' next_step = STATUS_NEXT_STEPS.get(status, '') order_data.append({ 'order': o.name, 'sale_type': o.x_fc_sale_type, 'status': status, 'date': str(o.date_order.date()) if o.date_order else '', 'total': float(o.amount_total), 'adp_total': float(o.x_fc_adp_portion_total), 'client_total': float(o.x_fc_client_portion_total), 'billing_date': str(o.x_fc_billing_date) if o.x_fc_billing_date else '', 'funding_warning': o.x_fc_funding_warning_message or '', 'adp_invoice': { 'number': adp_inv[0].name if adp_inv else '', 'amount': float(adp_inv[0].amount_total) if adp_inv else 0, 'paid': adp_inv[0].payment_state in ('paid', 'in_payment') if adp_inv else False, } if adp_inv else None, 'client_invoice': { 'number': client_inv[0].name if client_inv else '', 'amount': float(client_inv[0].amount_total) if client_inv else 0, 'paid': client_inv[0].payment_state in ('paid', 'in_payment') if client_inv else False, } if client_inv else None, 'documents': docs, 'next_step': next_step, }) result = { 'name': partner_name, 'partner_id': partner_id, 'orders': order_data, 'order_count': len(order_data), } if profile: result['profile_id'] = profile.id result['health_card'] = profile.health_card_number or '' result['city'] = profile.city or '' result['condition'] = (profile.medical_condition or '')[:100] result['total_adp_funded'] = float(profile.total_adp_funded) result['total_client_funded'] = float(profile.total_client_portion) return result # ------------------------------------------------------------------ # Tool 5: ADP Billing Period Summary # ------------------------------------------------------------------ def _fc_tool_adp_billing_period(self, period=None): """AI Tool: Get ADP billing summary for a posting period.""" Mixin = self.env['fusion_claims.adp.posting.schedule.mixin'] Invoice = self.env['account.move'].sudo() today = date.today() frequency = Mixin._get_adp_posting_frequency() if not period or period == 'current': posting_date = Mixin._get_current_posting_date(today) elif period == 'previous': current = Mixin._get_current_posting_date(today) posting_date = current - timedelta(days=frequency) elif period == 'next': posting_date = Mixin._get_next_posting_date(today) else: try: ref_date = date.fromisoformat(period) posting_date = Mixin._get_current_posting_date(ref_date) except (ValueError, TypeError): return json.dumps({'error': f'Invalid period: "{period}". Use "current", "previous", "next", or a date (YYYY-MM-DD).'}) period_start = posting_date period_end = posting_date + timedelta(days=frequency - 1) submission_deadline = Mixin._get_posting_week_wednesday(posting_date) expected_payment = Mixin._get_expected_payment_date(posting_date) adp_invoices = Invoice.search([ ('x_fc_adp_invoice_portion', '=', 'adp'), ('move_type', '=', 'out_invoice'), ('invoice_date', '>=', str(period_start)), ('invoice_date', '<=', str(period_end)), ]) total_invoiced = sum(adp_invoices.mapped('amount_total')) total_paid = sum(adp_invoices.filtered( lambda i: i.payment_state in ('paid', 'in_payment') ).mapped('amount_total')) total_unpaid = total_invoiced - total_paid source_orders = adp_invoices.mapped('x_fc_source_sale_order_id') invoice_details = [] for inv in adp_invoices[:25]: so = inv.x_fc_source_sale_order_id invoice_details.append({ 'invoice': inv.name or '', 'order': so.name if so else '', 'client': inv.partner_id.name or '', 'amount': float(inv.amount_total), 'paid': inv.payment_state in ('paid', 'in_payment'), 'date': str(inv.invoice_date) if inv.invoice_date else '', }) return json.dumps({ 'period': { 'posting_date': str(posting_date), 'start': str(period_start), 'end': str(period_end), 'submission_deadline': f'{submission_deadline.strftime("%A, %B %d, %Y")} 6:00 PM', 'expected_payment_date': str(expected_payment), }, 'summary': { 'total_invoices': len(adp_invoices), 'total_invoiced': float(total_invoiced), 'total_paid': float(total_paid), 'total_unpaid': float(total_unpaid), 'orders_billed': len(source_orders), }, 'invoices': invoice_details, })