fix: enhance Fusion Claims Intelligence AI with client status and billing period tools

- Fix _read_group override crash (dict_values not subscriptable) in sale_order.py
- Migrate _fc_tool_claims_stats from deprecated read_group() to _read_group() API
- Enrich client details tool with funding history, invoice status, prev-funded devices
- Add Client Status Lookup tool (search by name, returns orders/invoices/next steps)
- Add ADP Billing Period tool (invoiced amounts, paid/unpaid, submission deadlines)
- Update AI agent system prompt with all 5 tools and usage examples
This commit is contained in:
2026-03-10 02:30:42 +00:00
parent 3342b57469
commit 7bd7b8f7c4
3 changed files with 386 additions and 50 deletions

View File

@@ -4,16 +4,60 @@
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()
@@ -46,20 +90,37 @@ class AIAgentFusionClaims(models.Model):
})
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."""
"""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'})
# Get orders
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):
], 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,
@@ -68,11 +129,19 @@ class AIAgentFusionClaims(models.Model):
'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,
})
# Get applications
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 '',
@@ -80,8 +149,17 @@ class AIAgentFusionClaims(models.Model):
'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,
@@ -106,12 +184,18 @@ class AIAgentFusionClaims(models.Model):
'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()
@@ -120,40 +204,46 @@ class AIAgentFusionClaims(models.Model):
total_profiles = Profile.search_count([])
total_orders = SO.search_count([('x_fc_sale_type', '!=', False)])
# By sale type
type_data = SO.read_group(
[('x_fc_sale_type', '!=', False)],
['x_fc_sale_type', 'amount_total:sum'],
['x_fc_sale_type'],
)
by_type = {}
for r in type_data:
by_type[r['x_fc_sale_type']] = {
'count': r['x_fc_sale_type_count'],
'total': float(r['amount_total'] or 0),
}
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
status_data = SO.read_group(
[('x_fc_sale_type', '!=', False), ('x_fc_adp_application_status', '!=', False)],
['x_fc_adp_application_status'],
['x_fc_adp_application_status'],
)
by_status = {}
for r in status_data:
by_status[r['x_fc_adp_application_status']] = r['x_fc_adp_application_status_count']
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 (top 10)
city_data = Profile.read_group(
[('city', '!=', False)],
['city'],
['city'],
limit=10,
orderby='city_count desc',
)
by_city = {}
for r in city_data:
by_city[r['city']] = r['city_count']
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,
@@ -162,3 +252,201 @@ class AIAgentFusionClaims(models.Model):
'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,
})