# -*- 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', ) # ========================================================================== # WITHDRAWAL INTENT (for 'withdrawn' status) # ========================================================================== withdrawal_intent = fields.Selection( selection=[ ('cancel', 'Cancel Application'), ('resubmit', 'Withdraw for Correction & Resubmission'), ], string='What would you like to do after withdrawal?', default='resubmit', ) 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')) # Build initial update vals update_vals = {'x_fc_adp_application_status': new_status} if new_status == 'withdrawn': update_vals['x_fc_previous_status_before_withdrawal'] = self.previous_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'

Details: {reason}

' message_body = f''' ''' # ================================================================= # 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'

Details: {reason}

' message_body = f''' ''' # ================================================================= # 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': # Handled entirely below based on withdrawal_intent message_body = None elif new_status == 'cancelled': # Cancelled has its own detailed message posted later message_body = None else: message_body = f''' ''' # 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: Branch based on withdrawal intent # ================================================================= if new_status == 'withdrawn': intent = self.withdrawal_intent if intent == 'cancel': # --------------------------------------------------------- # WITHDRAW & CANCEL: Cancel invoices + SO # --------------------------------------------------------- cancelled_invoices = [] cancelled_so = False # Cancel related invoices first invoices = order.invoice_ids.filtered(lambda inv: inv.state != 'cancel') for invoice in invoices: try: inv_msg = Markup(f''' ''') invoice.message_post( body=inv_msg, message_type='notification', subtype_xmlid='mail.mt_note', ) if invoice.state == 'posted': invoice.button_draft() invoice.button_cancel() cancelled_invoices.append(invoice.name) except Exception as e: warn_msg = Markup(f''' ''') 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''' ''') 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'
  • {inv}
  • ' for inv in cancelled_invoices]) invoice_list_html = f'
  • Invoices Cancelled:
  • ' so_status = 'Cancelled' if cancelled_so else 'Not applicable' summary_msg = Markup(f''' ''') order.message_post( body=summary_msg, message_type='notification', subtype_xmlid='mail.mt_note', ) order._send_withdrawal_email(reason=reason, intent='cancel') else: # --------------------------------------------------------- # WITHDRAW & RESUBMIT: Return to ready_submission # --------------------------------------------------------- order.with_context(skip_status_validation=True).write({ 'x_fc_adp_application_status': 'ready_submission', 'x_fc_previous_status_before_withdrawal': self.previous_status, }) resubmit_msg = Markup(f''' ''') order.message_post( body=resubmit_msg, message_type='notification', subtype_xmlid='mail.mt_note', ) order._send_withdrawal_email(reason=reason, intent='resubmit') # ================================================================= # 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''' ''') 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''' ''') 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''' ''') 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'
  • {inv}
  • ' for inv in cancelled_invoices]) invoice_list_html = f'
  • Invoices Cancelled:
  • ' # Post comprehensive summary to chatter so_status = 'Cancelled' if cancelled_so else 'Not applicable' summary_msg = Markup(f''' ''') order.message_post( body=summary_msg, message_type='notification', subtype_xmlid='mail.mt_note', ) return {'type': 'ir.actions.act_window_close'}