411 lines
18 KiB
Python
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'}
|