Files
Odoo-Modules/fusion_claims/wizard/status_change_reason_wizard.py
2026-02-22 01:22:18 -05:00

411 lines
18 KiB
Python

# -*- coding: utf-8 -*-
from markupsafe import Markup
from odoo import api, fields, models
class StatusChangeReasonWizard(models.TransientModel):
"""Wizard to capture reason when changing to specific statuses."""
_name = 'fusion.status.change.reason.wizard'
_description = 'Status Change Reason Wizard'
sale_order_id = fields.Many2one(
'sale.order',
string='Sale Order',
required=True,
ondelete='cascade',
)
new_status = fields.Selection(
selection=[
('rejected', 'Rejected by ADP'), # New: Initial rejection (within 24 hours)
('denied', 'Application Denied'), # Funding denied (after 2-3 weeks)
('withdrawn', 'Application Withdrawn'),
('on_hold', 'On Hold'),
('cancelled', 'Cancelled'),
('needs_correction', 'Application Needs Correction'),
],
string='New Status',
required=True,
)
# ==========================================================================
# REJECTION REASON FIELDS (for 'rejected' status - initial submission rejection)
# ==========================================================================
rejection_reason = fields.Selection(
selection=[
('name_correction', 'Name Correction Needed'),
('healthcard_correction', 'Health Card Correction Needed'),
('duplicate_claim', 'Duplicate Claim Exists'),
('xml_format_error', 'XML Format/Validation Error'),
('missing_info', 'Missing Required Information'),
('other', 'Other'),
],
string='Rejection Reason',
help='Select the reason ADP rejected the submission',
)
# ==========================================================================
# DENIAL REASON FIELDS (for 'denied' status - funding denial after review)
# ==========================================================================
denial_reason = fields.Selection(
selection=[
('eligibility', 'Client Eligibility Issues'),
('recent_funding', 'Previous Funding Within 5 Years'),
('medical_justification', 'Insufficient Medical Justification'),
('equipment_not_covered', 'Equipment Not Covered by ADP'),
('documentation_incomplete', 'Documentation Incomplete'),
('other', 'Other'),
],
string='Denial Reason',
help='Select the reason ADP denied the funding',
)
reason = fields.Text(
string='Reason / Additional Details',
help='Please provide additional details for this status change.',
)
# For on_hold: track the previous status
previous_status = fields.Char(
string='Previous Status',
readonly=True,
)
# Computed field to determine if reason is required
reason_required = fields.Boolean(
compute='_compute_reason_required',
)
@api.depends('new_status', 'rejection_reason', 'denial_reason')
def _compute_reason_required(self):
"""Reason text is required for 'other' selections or non-rejection/denial statuses."""
for wizard in self:
if wizard.new_status == 'rejected':
# Reason required if rejection_reason is 'other'
wizard.reason_required = wizard.rejection_reason == 'other'
elif wizard.new_status == 'denied':
# Reason required if denial_reason is 'other'
wizard.reason_required = wizard.denial_reason == 'other'
else:
# For other statuses (on_hold, cancelled, etc.), reason is always required
wizard.reason_required = True
@api.model
def default_get(self, fields_list):
"""Set defaults from context."""
res = super().default_get(fields_list)
if self.env.context.get('active_model') == 'sale.order':
order_id = self.env.context.get('active_id')
if order_id:
order = self.env['sale.order'].browse(order_id)
res['sale_order_id'] = order_id
res['previous_status'] = order.x_fc_adp_application_status
if self.env.context.get('default_new_status'):
res['new_status'] = self.env.context.get('default_new_status')
return res
def _get_status_label(self, status):
"""Get human-readable label for status."""
labels = {
'rejected': 'Rejected by ADP',
'denied': 'Application Denied',
'withdrawn': 'Application Withdrawn',
'on_hold': 'On Hold',
'cancelled': 'Cancelled',
'needs_correction': 'Application Needs Correction',
}
return labels.get(status, status)
def _get_status_icon(self, status):
"""Get FontAwesome icon for status."""
icons = {
'rejected': 'fa-times',
'denied': 'fa-times-circle',
'withdrawn': 'fa-undo',
'on_hold': 'fa-pause-circle',
'cancelled': 'fa-ban',
'needs_correction': 'fa-exclamation-triangle',
}
return icons.get(status, 'fa-info-circle')
def _get_rejection_reason_label(self, reason):
"""Get human-readable label for rejection reason."""
labels = {
'name_correction': 'Name Correction Needed',
'healthcard_correction': 'Health Card Correction Needed',
'duplicate_claim': 'Duplicate Claim Exists',
'xml_format_error': 'XML Format/Validation Error',
'missing_info': 'Missing Required Information',
'other': 'Other',
}
return labels.get(reason, reason)
def _get_denial_reason_label(self, reason):
"""Get human-readable label for denial reason."""
labels = {
'eligibility': 'Client Eligibility Issues',
'recent_funding': 'Previous Funding Within 5 Years',
'medical_justification': 'Insufficient Medical Justification',
'equipment_not_covered': 'Equipment Not Covered by ADP',
'documentation_incomplete': 'Documentation Incomplete',
'other': 'Other',
}
return labels.get(reason, reason)
def action_confirm(self):
"""Confirm status change and post reason to chatter."""
self.ensure_one()
order = self.sale_order_id
new_status = self.new_status
reason = self.reason or ''
# Build chatter message
status_label = self._get_status_label(new_status)
icon = self._get_status_icon(new_status)
user_name = self.env.user.name
change_date = fields.Date.today().strftime('%B %d, %Y')
# Color scheme for different status types
status_colors = {
'rejected': ('#e74c3c', '#fff5f5', '#f5c6cb'), # Red (lighter)
'denied': ('#dc3545', '#fff5f5', '#f5c6cb'), # Red
'withdrawn': ('#6c757d', '#f8f9fa', '#dee2e6'), # Gray
'on_hold': ('#fd7e14', '#fff8f0', '#ffecd0'), # Orange
'cancelled': ('#dc3545', '#fff5f5', '#f5c6cb'), # Red
'needs_correction': ('#ffc107', '#fffbf0', '#ffeeba'), # Yellow
}
header_color, bg_color, border_color = status_colors.get(new_status, ('#17a2b8', '#f0f9ff', '#bee5eb'))
# For on_hold, also store the previous status and hold date
update_vals = {'x_fc_adp_application_status': new_status}
# =================================================================
# REJECTED: ADP rejected submission (within 24 hours)
# =================================================================
if new_status == 'rejected':
rejection_reason = self.rejection_reason
rejection_label = self._get_rejection_reason_label(rejection_reason)
# Store rejection details in sale order
current_count = order.x_fc_rejection_count or 0
update_vals.update({
'x_fc_rejection_reason': rejection_reason,
'x_fc_rejection_reason_other': reason if rejection_reason == 'other' else False,
'x_fc_rejection_date': fields.Date.today(),
'x_fc_rejection_count': current_count + 1,
})
# Build rejection message
details_html = ''
if rejection_reason == 'other' and reason:
details_html = f'<p><strong>Details:</strong> {reason}</p>'
message_body = f'''
<div class="alert alert-danger" role="alert">
<h5 class="alert-heading"><i class="fa {icon}"></i> Submission Rejected by ADP</h5>
<ul>
<li><strong>Rejection #:</strong> {current_count + 1}</li>
<li><strong>Reason:</strong> {rejection_label}</li>
<li><strong>Date:</strong> {change_date}</li>
<li><strong>Recorded By:</strong> {user_name}</li>
</ul>
{details_html}
<hr>
<p class="mb-0"><i class="fa fa-info-circle"></i> <strong>Next Step:</strong> Correct the issue and resubmit the application.</p>
</div>
'''
# =================================================================
# DENIED: Funding denied by ADP (after 2-3 weeks review)
# =================================================================
elif new_status == 'denied':
denial_reason = self.denial_reason
denial_label = self._get_denial_reason_label(denial_reason)
# Store denial details in sale order
update_vals.update({
'x_fc_denial_reason': denial_reason,
'x_fc_denial_reason_other': reason if denial_reason == 'other' else False,
'x_fc_denial_date': fields.Date.today(),
})
# Build denial message
details_html = ''
if denial_reason == 'other' and reason:
details_html = f'<p><strong>Details:</strong> {reason}</p>'
message_body = f'''
<div class="alert alert-danger" role="alert">
<h5 class="alert-heading"><i class="fa {icon}"></i> Funding Denied by ADP</h5>
<ul>
<li><strong>Denial Reason:</strong> {denial_label}</li>
<li><strong>Date:</strong> {change_date}</li>
<li><strong>Recorded By:</strong> {user_name}</li>
</ul>
{details_html}
</div>
'''
# =================================================================
# ON HOLD: Application put on hold
# Message is posted by _send_on_hold_email() to avoid duplicates
# =================================================================
elif new_status == 'on_hold':
update_vals['x_fc_on_hold_date'] = fields.Date.today()
update_vals['x_fc_previous_status_before_hold'] = self.previous_status
# Don't post message here - _send_on_hold_email() will post the message
message_body = None
elif new_status == 'withdrawn':
# Don't post message here - _send_withdrawal_email() will post the message
message_body = None
elif new_status == 'cancelled':
# Cancelled has its own detailed message posted later
message_body = None
else:
message_body = f'''
<div class="alert alert-warning" role="alert">
<h5 class="alert-heading"><i class="fa {icon}"></i> Status Changed: {status_label}</h5>
<ul>
<li><strong>Changed By:</strong> {user_name}</li>
<li><strong>Date:</strong> {change_date}</li>
</ul>
<hr>
<p class="mb-0"><strong>Reason:</strong> {reason}</p>
</div>
'''
# Post to chatter (except for cancelled which has its own detailed message)
if message_body:
order.message_post(
body=Markup(message_body),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
# Update the status (this will trigger the write() method)
# Use context to skip the wizard trigger and skip auto-email for needs_correction
# (we send it below with the reason text)
write_ctx = {'skip_status_validation': True}
if new_status == 'needs_correction':
write_ctx['skip_correction_email'] = True
order.with_context(**write_ctx).write(update_vals)
# =================================================================
# NEEDS CORRECTION: Send email with the reason from this wizard
# =================================================================
if new_status == 'needs_correction':
order._send_correction_needed_email(reason=reason)
# =================================================================
# WITHDRAWN: Send email notification to all parties
# =================================================================
if new_status == 'withdrawn':
order._send_withdrawal_email(reason=reason)
# =================================================================
# ON HOLD: Send email notification to all parties
# =================================================================
if new_status == 'on_hold':
order._send_on_hold_email(reason=reason)
# =================================================================
# CANCELLED: Also cancel the sale order and all related invoices
# =================================================================
if new_status == 'cancelled':
cancelled_invoices = []
cancelled_so = False
user_name = self.env.user.name
cancel_date = fields.Date.today().strftime('%B %d, %Y')
# Cancel related invoices first
invoices = order.invoice_ids.filtered(lambda inv: inv.state != 'cancel')
for invoice in invoices:
try:
# Post cancellation reason to invoice chatter
inv_msg = Markup(f'''
<div class="alert alert-danger" role="alert">
<h5 class="alert-heading"><i class="fa fa-ban"></i> Invoice Cancelled</h5>
<ul>
<li><strong>Related Order:</strong> {order.name}</li>
<li><strong>Cancelled By:</strong> {user_name}</li>
<li><strong>Date:</strong> {cancel_date}</li>
</ul>
<hr>
<p class="mb-0"><strong>Reason:</strong> {reason}</p>
</div>
''')
invoice.message_post(
body=inv_msg,
message_type='notification',
subtype_xmlid='mail.mt_note',
)
# Cancel the invoice (button_cancel or button_draft then cancel)
if invoice.state == 'posted':
invoice.button_draft()
invoice.button_cancel()
cancelled_invoices.append(invoice.name)
except Exception as e:
warn_msg = Markup(f'''
<div class="alert alert-warning" role="alert">
<p class="mb-0"><i class="fa fa-exclamation-triangle"></i> <strong>Warning:</strong> Could not cancel invoice {invoice.name}</p>
<p class="mb-0 small">{str(e)}</p>
</div>
''')
order.message_post(
body=warn_msg,
message_type='notification',
subtype_xmlid='mail.mt_note',
)
# Cancel the sale order itself
if order.state not in ('cancel', 'done'):
try:
order._action_cancel()
cancelled_so = True
except Exception as e:
warn_msg = Markup(f'''
<div class="alert alert-warning" role="alert">
<p class="mb-0"><i class="fa fa-exclamation-triangle"></i> <strong>Warning:</strong> Could not cancel sale order</p>
<p class="mb-0 small">{str(e)}</p>
</div>
''')
order.message_post(
body=warn_msg,
message_type='notification',
subtype_xmlid='mail.mt_note',
)
# Build cancellation summary
invoice_list_html = ''
if cancelled_invoices:
invoice_items = ''.join([f'<li>{inv}</li>' for inv in cancelled_invoices])
invoice_list_html = f'<li><strong>Invoices Cancelled:</strong><ul>{invoice_items}</ul></li>'
# Post comprehensive summary to chatter
so_status = 'Cancelled' if cancelled_so else 'Not applicable'
summary_msg = Markup(f'''
<div class="alert alert-danger" role="alert">
<h5 class="alert-heading"><i class="fa fa-ban"></i> Application Cancelled</h5>
<ul>
<li><strong>Cancelled By:</strong> {user_name}</li>
<li><strong>Date:</strong> {cancel_date}</li>
<li><strong>Sale Order:</strong> {so_status}</li>
{invoice_list_html}
</ul>
<hr>
<p class="mb-0"><strong>Reason for Cancellation:</strong> {reason}</p>
</div>
''')
order.message_post(
body=summary_msg,
message_type='notification',
subtype_xmlid='mail.mt_note',
)
return {'type': 'ir.actions.act_window_close'}