fusion_claims: ADP workflow recovery actions + email gap fixes

Workflow (from the FigJam ADP board):
- 9 new ADP action methods to wire up the orphan states that the board
  showed had no entry or no exit path: put_on_hold, withdraw, mark_denied,
  mark_rejected, mark_needs_correction, cancel, reopen_cancelled,
  reopen_expired, resubmit_from_denied.
- 12-month auto-expire cron (_cron_adp_expire_approved) configurable via
  fusion_claims.adp_approval_expiry_months, runs daily at 03:00.
- 3 new recovery buttons in the ADP form view (Reopen cancelled, Reopen
  expired, Resubmit from denied) in both the primary status bar and the
  secondary details panel.

Email (from the 2026-04 email audit):
- 6 new ADP stage email methods via a shared _adp_send_stage_email helper:
  assessment_scheduled, assessment_completed, application_received, accepted,
  cancelled, expired. Each has a matching dispatch entry in write().
- _send_rejection_email now includes the client (was authorizer-only).
- _send_accepted_email excludes the authorizer per the new rule: "Accepted"
  is a passive intermediate state with no authorizer action required.
- _send_ready_for_delivery_email excludes the authorizer: operational
  scheduling, not delivery confirmation. Authorizers are notified at
  case_closed when the product is actually delivered.
- action_adp_put_on_hold and action_adp_withdraw now fire their matching
  email methods so direct action-method calls get the same notifications
  as the status_change_reason_wizard path.

Authorizer notification rule (locked in for this update):
  Send to authorizer ONLY for initial involvement (assessment/submit/
  resubmit), delivery confirmation (case_closed), and problem states
  (rejected, denied, needs_correction, withdrawn, on_hold, cancelled,
  expired). Skip for billing, payment, ready_delivery scheduling, and
  passive intermediates (accepted).

Scope: ADP + ADP/ODSP only. MOD workflow emails reverted and deferred
to a separate update.

Deployed to odoo-westin (westin-v19) and odoo-mobility (mobility).
Pre-existing stock_route_warehouse FK orphans on mobility worked around
by verifying fusion_claims transaction committed before container restart.
This commit is contained in:
gsinghpal
2026-04-09 06:06:33 -04:00
parent d60a75a391
commit 8b2cbd9085
5 changed files with 548 additions and 10 deletions

View File

@@ -5,7 +5,7 @@
{
'name': 'Fusion Claims',
'version': '19.0.8.0.1',
'version': '19.0.8.0.2',
'category': 'Sales',
'summary': 'Complete ADP Claims Management with Dashboard, Sales Integration, Billing Automation, and Two-Stage Verification.',
'description': """

View File

@@ -133,6 +133,14 @@
<field name="value">10</field>
</record>
<!-- ADP funding window: approved applications auto-expire after N months
if not progressed to ready_delivery. ADP doesn't actively notify of
expiry — we trust this window to reflect reality. -->
<record id="config_adp_approval_expiry_months" model="ir.config_parameter">
<field name="key">fusion_claims.adp_approval_expiry_months</field>
<field name="value">12</field>
</record>
<!-- ODSP Settings -->
<record id="config_sa_mobility_email" model="ir.config_parameter">
<field name="key">fusion_claims.sa_mobility_email</field>

View File

@@ -136,5 +136,21 @@
<field name="nextcall" eval="DateTime.now().replace(hour=2, minute=0, second=0)"/>
</record>
<!-- Cron Job: ADP 12-month auto-expire for approved applications.
Approved / approved_deduction orders past the configured funding
window (fusion_claims.adp_approval_expiry_months, default 12 months)
are auto-transitioned to 'expired'. Users can reopen via the
"Reopen Expired" button on the order form. -->
<record id="ir_cron_adp_expire_approved" model="ir.cron">
<field name="name">Fusion Claims: ADP Expire Approved Applications</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="state">code</field>
<field name="code">model._cron_adp_expire_approved()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active">True</field>
<field name="nextcall" eval="DateTime.now().replace(hour=3, minute=0, second=0)"/>
</record>
</data>
</odoo>

View File

@@ -3691,6 +3691,281 @@ class SaleOrder(models.Model):
return True
# ==========================================================================
# ADP WORKFLOW — EXCEPTION / RECOVERY ACTIONS
# Added 2026-04 to wire up previously-orphan states
# (on_hold, withdrawn, denied, rejected, needs_correction, cancelled, expired)
# See docs/workflow-explorer/ for the state machine diagram.
# ==========================================================================
_ADP_ACTIVE_STATES = (
'quotation', 'assessment_scheduled', 'assessment_completed',
'waiting_for_application', 'application_received', 'ready_submission',
'submitted', 'accepted', 'rejected', 'resubmitted', 'needs_correction',
'approved', 'approved_deduction', 'ready_delivery', 'ready_bill',
)
def _adp_chatter_transition(self, title, icon, colour_class, details=None):
"""Post a standardised chatter note for an ADP workflow transition."""
self.ensure_one()
user_name = self.env.user.name
when = fields.Date.today().strftime('%B %d, %Y')
detail_html = ''
if details:
rows = ''.join(f'<li><strong>{k}:</strong> {v}</li>' for k, v in details.items())
detail_html = f'<ul>{rows}</ul>'
body = (
f'<div class="alert alert-{colour_class}" role="alert">'
f'<h5 class="alert-heading"><i class="fa {icon}"></i> {title}</h5>'
f'<ul><li><strong>By:</strong> {user_name}</li>'
f'<li><strong>Date:</strong> {when}</li></ul>'
f'{detail_html}'
'</div>'
)
self.message_post(
body=Markup(body),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
def action_adp_put_on_hold(self):
"""Put the ADP application on hold, preserving the previous state for resume.
Callable from any active, non-terminal state. The current state is
saved to x_fc_previous_status_before_hold so action_resume_from_hold
can restore it. Terminal and already-held states are rejected.
Sends the standard on-hold notification email (client + authorizer +
sales rep) via the existing _send_on_hold_email helper.
"""
self.ensure_one()
current = self.x_fc_adp_application_status
blocked = ('on_hold', 'case_closed', 'cancelled', 'expired', 'withdrawn')
if current in blocked:
raise UserError(
_("This application cannot be put on hold from its current state (%s).") % current
)
self.with_context(skip_status_validation=True).write({
'x_fc_adp_application_status': 'on_hold',
'x_fc_on_hold_date': fields.Date.today(),
'x_fc_previous_status_before_hold': current,
})
labels = dict(self._fields['x_fc_adp_application_status'].selection)
self._adp_chatter_transition(
title='Application Put On Hold',
icon='fa-pause-circle',
colour_class='warning',
details={'Previous Status': labels.get(current, current)},
)
# Fire the on-hold notification email so client + authorizer know.
try:
self._send_on_hold_email()
except Exception as e:
_logger.error(f"action_adp_put_on_hold: failed to send email for {self.name}: {e}")
return True
def action_adp_withdraw(self):
"""Withdraw the ADP application. Records the previous state for later
resubmission and sends the standard withdrawal notification.
"""
self.ensure_one()
current = self.x_fc_adp_application_status
allowed = ('submitted', 'resubmitted', 'accepted', 'ready_submission', 'on_hold')
if current not in allowed:
raise UserError(
_("Applications can only be withdrawn from Ready for Submission, "
"Submitted, Resubmitted, Accepted, or On Hold. Current: %s") % current
)
self.with_context(skip_status_validation=True).write({
'x_fc_adp_application_status': 'withdrawn',
'x_fc_previous_status_before_withdrawal': current,
})
self._adp_chatter_transition(
title='Application Withdrawn',
icon='fa-hand-paper-o',
colour_class='secondary',
)
# Fire the withdrawal notification email (client + authorizer).
try:
self._send_withdrawal_email(intent='cancel')
except Exception as e:
_logger.error(f"action_adp_withdraw: failed to send email for {self.name}: {e}")
return True
def action_adp_mark_rejected(self):
"""Mark the application as rejected by ADP. Allows later correction + resubmission."""
self.ensure_one()
current = self.x_fc_adp_application_status
allowed = ('submitted', 'resubmitted', 'accepted')
if current not in allowed:
raise UserError(
_("Only Submitted, Resubmitted or Accepted applications can be marked as Rejected. Current: %s") % current
)
self.with_context(skip_status_validation=True).write({
'x_fc_adp_application_status': 'rejected',
})
self._adp_chatter_transition(
title='Rejected by ADP',
icon='fa-times-circle',
colour_class='danger',
)
return True
def action_adp_mark_denied(self):
"""Mark the application as denied by ADP after initial acceptance."""
self.ensure_one()
current = self.x_fc_adp_application_status
allowed = ('submitted', 'resubmitted', 'accepted', 'approved', 'approved_deduction')
if current not in allowed:
raise UserError(
_("Only Submitted, Resubmitted, Accepted or Approved applications can be marked as Denied. Current: %s") % current
)
self.with_context(skip_status_validation=True).write({
'x_fc_adp_application_status': 'denied',
})
self._adp_chatter_transition(
title='Application Denied by ADP',
icon='fa-ban',
colour_class='danger',
)
return True
def action_adp_mark_needs_correction(self):
"""ADP has requested corrections (from portal feedback). Sends the
application to Needs Correction from submitted/resubmitted/approved
/approved_deduction/rejected. The existing submission_verification_wizard
then handles the resubmission path back to 'resubmitted'.
"""
self.ensure_one()
current = self.x_fc_adp_application_status
allowed = ('submitted', 'resubmitted', 'accepted', 'approved', 'approved_deduction', 'rejected')
if current not in allowed:
raise UserError(
_("Only Submitted, Resubmitted, Accepted, Approved or Rejected applications "
"can be moved to Needs Correction. Current: %s") % current
)
self.with_context(skip_status_validation=True).write({
'x_fc_adp_application_status': 'needs_correction',
})
self._adp_chatter_transition(
title='ADP Requested Corrections',
icon='fa-exclamation-triangle',
colour_class='warning',
)
return True
def action_adp_cancel(self):
"""Cancel the ADP application. Can be called from most active states.
Does not reset dates or clear documents — use action_adp_reopen_cancelled
to bring a cancelled order back into the workflow if needed.
"""
self.ensure_one()
current = self.x_fc_adp_application_status
blocked = ('case_closed', 'cancelled', 'expired', 'billed')
if current in blocked:
raise UserError(
_("This application cannot be cancelled from its current state (%s).") % current
)
self.with_context(skip_status_validation=True).write({
'x_fc_adp_application_status': 'cancelled',
})
self._adp_chatter_transition(
title='Application Cancelled',
icon='fa-times',
colour_class='secondary',
)
return True
def action_adp_reopen_cancelled(self):
"""Bring a cancelled order back into the workflow at the Quotation stage."""
self.ensure_one()
if self.x_fc_adp_application_status != 'cancelled':
raise UserError(_("This action is only available for cancelled applications."))
self.with_context(skip_status_validation=True).write({
'x_fc_adp_application_status': 'quotation',
})
self._adp_chatter_transition(
title='Cancelled Application Reopened',
icon='fa-refresh',
colour_class='info',
)
return True
def action_adp_reopen_expired(self):
"""Bring an expired application back into the workflow at the Quotation stage."""
self.ensure_one()
if self.x_fc_adp_application_status != 'expired':
raise UserError(_("This action is only available for expired applications."))
self.with_context(skip_status_validation=True).write({
'x_fc_adp_application_status': 'quotation',
})
self._adp_chatter_transition(
title='Expired Application Reopened',
icon='fa-refresh',
colour_class='info',
)
return True
def action_adp_resubmit_from_denied(self):
"""Send a denied application back to Ready for Submission for a fresh attempt."""
self.ensure_one()
if self.x_fc_adp_application_status != 'denied':
raise UserError(_("This action is only available for denied applications."))
self.with_context(skip_status_validation=True).write({
'x_fc_adp_application_status': 'ready_submission',
})
self._adp_chatter_transition(
title='Denied Application Sent for Resubmission',
icon='fa-undo',
colour_class='info',
)
return True
# ==========================================================================
# ADP WORKFLOW — 12-MONTH AUTO EXPIRE CRON
# Approved applications expire automatically if not delivered within the
# configured window. Window is configurable via
# fusion_claims.adp_approval_expiry_months (default 12).
# ==========================================================================
@api.model
def _cron_adp_expire_approved(self):
"""Auto-expire approved/approved_deduction orders older than the configured window."""
from dateutil.relativedelta import relativedelta
ICP = self.env['ir.config_parameter'].sudo()
try:
months = int(ICP.get_param('fusion_claims.adp_approval_expiry_months', '12'))
except (TypeError, ValueError):
months = 12
cutoff = fields.Date.today() - relativedelta(months=months)
stale = self.search([
('x_fc_adp_application_status', 'in', ('approved', 'approved_deduction')),
('x_fc_claim_approval_date', '!=', False),
('x_fc_claim_approval_date', '<=', cutoff),
])
for order in stale:
try:
days_old = (fields.Date.today() - order.x_fc_claim_approval_date).days
order.with_context(skip_status_validation=True).write({
'x_fc_adp_application_status': 'expired',
})
order.message_post(
body=Markup(
'<div class="alert alert-warning" role="alert">'
'<h5 class="alert-heading"><i class="fa fa-hourglass-end"></i> Approval Expired Automatically</h5>'
f'<p class="mb-1">Approval was received {days_old} days ago '
f'({order.x_fc_claim_approval_date}) — past the {months}-month '
f'ADP funding window.</p>'
'<p class="mb-0">Use <strong>Reopen</strong> to reinstate the application.</p>'
'</div>'
),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
_logger.info(f"Auto-expired approved ADP order {order.name} (approved {days_old} days ago)")
except Exception as e:
_logger.error(f"Failed to auto-expire {order.name}: {e}")
def action_set_ready_to_bill(self):
"""Open the Ready to Bill wizard to collect POD and delivery date.
@@ -5407,11 +5682,15 @@ class SaleOrder(models.Model):
return False
def _send_rejection_email(self):
"""Send notification when ADP rejects the submission (data errors, not funding denial)."""
"""Send notification when ADP rejects the submission (data errors, not funding denial).
Client is now included (2026-04 email audit fix) so the family sees
progress and knows the team is actively handling the correction.
"""
self.ensure_one()
if not self._is_email_notifications_enabled():
return False
recipients = self._get_email_recipients(include_client=False, include_authorizer=True, include_sales_rep=True)
recipients = self._get_email_recipients(include_client=True, include_authorizer=True, include_sales_rep=True)
to_emails = recipients.get('to', [])
cc_emails = recipients.get('cc', []) + recipients.get('office_cc', [])
if not to_emails:
@@ -5542,6 +5821,168 @@ class SaleOrder(models.Model):
_logger.error(f"Failed to send case closed email for {self.name}: {e}")
return False
# ==========================================================================
# ADP STAGE EMAILS — added 2026-04 to close gaps in the workflow email flow
# (assessment_scheduled, assessment_completed, accepted, cancelled, expired)
# All send to client + authorizer + sales rep by default.
# ==========================================================================
def _adp_send_stage_email(self, title, subject, summary, note, note_color='#4f8cff',
email_type='info', include_authorizer=True):
"""Shared helper for the new stage emails below.
Defaults to client + authorizer + sales rep. Pass include_authorizer=False
for emails where the authorizer does not need to be in the loop — e.g.
'accepted' (passive intermediate state, no action required of the OT) or
'ready_for_delivery' (operational scheduling, not delivery confirmation).
See the 2026-04 email audit for the authorizer notification rule.
"""
self.ensure_one()
if not self._is_email_notifications_enabled():
return False
recipients = self._get_email_recipients(
include_client=True, include_authorizer=include_authorizer, include_sales_rep=True)
to_emails = recipients.get('to', [])
cc_emails = recipients.get('cc', []) + recipients.get('office_cc', [])
if not to_emails and not cc_emails:
return False
sales_rep_name = (recipients.get('sales_rep') or self.env.user).name
client_name = (recipients.get('client') or self.partner_id).name or 'Client'
body_html = self._email_build(
title=title,
summary=summary,
email_type=email_type,
sections=[('Case Details', self._build_case_detail_rows())],
note=note,
note_color=note_color,
button_url=f'{self.get_base_url()}/web#id={self.id}&model=sale.order&view_type=form',
sender_name=sales_rep_name,
)
email_to = ', '.join(to_emails) if to_emails else ', '.join(cc_emails[:1])
email_cc = ', '.join(cc_emails) if to_emails else ', '.join(cc_emails[1:])
try:
self.env['mail.mail'].sudo().create({
'subject': f'{subject} - {client_name} - {self.name}',
'body_html': body_html,
'email_to': email_to, 'email_cc': email_cc,
'model': 'sale.order', 'res_id': self.id,
}).send()
self._email_chatter_log(f'{title} email sent', email_to, email_cc)
return True
except Exception as e:
_logger.error(f"Failed to send '{title}' email for {self.name}: {e}")
return False
def _send_assessment_scheduled_email(self):
"""Email when OT assessment is booked. Client + authorizer + sales rep."""
self.ensure_one()
client_name = self.partner_id.name or 'Client'
when = self.x_fc_assessment_start_date.strftime('%B %d, %Y') if self.x_fc_assessment_start_date else 'a date to be confirmed'
return self._adp_send_stage_email(
title='Assessment Scheduled',
subject='Assessment Scheduled',
summary=f'An ADP accessibility assessment for <strong>{client_name}</strong> has been scheduled '
f'for <strong>{when}</strong>.',
email_type='info',
note='<strong>What to expect:</strong> Our Occupational Therapist will visit to evaluate '
'the equipment needs. Please ensure someone is available at the scheduled time. '
'If you need to reschedule, contact the office as soon as possible.',
note_color='#4f8cff',
)
def _send_application_received_email(self):
"""Email when the OT's ADP application docs are received by the office.
Client + authorizer + sales rep. Confirms to the authorizer that
their documents are in our hands and about to be submitted, and
keeps the client in the loop on the handoff.
"""
self.ensure_one()
client_name = self.partner_id.name or 'Client'
return self._adp_send_stage_email(
title='Application Received',
subject='Application Received',
summary=f'We have received the ADP application documents for <strong>{client_name}</strong>. '
f'Our team is preparing to submit the application to ADP.',
email_type='info',
note='<strong>Next steps:</strong> The application will be reviewed internally and submitted '
'to ADP shortly. You will receive another notification once the submission is sent.',
note_color='#4f8cff',
)
def _send_assessment_completed_email(self):
"""Email when OT assessment is done. Client + authorizer + sales rep."""
self.ensure_one()
client_name = self.partner_id.name or 'Client'
return self._adp_send_stage_email(
title='Assessment Completed',
subject='Assessment Completed',
summary=f'The ADP assessment for <strong>{client_name}</strong> has been completed.',
email_type='success',
note='<strong>Next steps:</strong> The authorizer will now prepare the ADP application '
'based on this assessment. We will keep you updated as the application is submitted.',
note_color='#38a169',
)
def _send_accepted_email(self):
"""Email when ADP accepts a submission (before the approval decision).
Client + sales rep only — no authorizer. Per the authorizer email
rule, this is a passive intermediate state requiring no action from
the OT, and it leads to either 'approved' (which DOES notify the
authorizer) or 'needs_correction'/'rejected' (problem states, which
also notify the authorizer). Keeping the authorizer off this email
reduces noise.
"""
self.ensure_one()
client_name = self.partner_id.name or 'Client'
return self._adp_send_stage_email(
title='Submission Accepted by ADP',
subject='Submission Accepted by ADP',
summary=f'Good news — ADP has accepted the submission for <strong>{client_name}</strong> '
f'and it is now under review.',
email_type='success',
note='<strong>What happens now:</strong> ADP typically issues an approval decision within '
'2-3 weeks. We will notify you as soon as we hear back. No action is required from you.',
note_color='#38a169',
include_authorizer=False,
)
def _send_cancelled_email(self):
"""Email when an ADP application is cancelled."""
self.ensure_one()
client_name = self.partner_id.name or 'Client'
return self._adp_send_stage_email(
title='Application Cancelled',
subject='Application Cancelled',
summary=f'The ADP application for <strong>{client_name}</strong> has been cancelled.',
email_type='info',
note='<strong>What this means:</strong> This application is no longer being processed. '
'If this was cancelled in error, please contact our office and we can reopen it.',
note_color='#718096',
)
def _send_expired_email(self):
"""Email when an approved application expires (12-month funding window lapsed)."""
self.ensure_one()
client_name = self.partner_id.name or 'Client'
ICP = self.env['ir.config_parameter'].sudo()
try:
months = int(ICP.get_param('fusion_claims.adp_approval_expiry_months', '12'))
except (TypeError, ValueError):
months = 12
return self._adp_send_stage_email(
title='ADP Approval Expired',
subject='Approval Expired - Action Needed',
summary=f'The ADP funding approval for <strong>{client_name}</strong> has expired because '
f'the equipment was not delivered within the {months}-month window.',
email_type='urgent',
note='<strong>What to do next:</strong> If you still need the equipment, please contact us '
'to discuss reopening the case. A new assessment may be required depending on how '
'much time has passed.',
note_color='#d69e2e',
)
def _send_withdrawal_email(self, reason=None, intent=None):
"""Send notification when application is withdrawn.
@@ -5607,11 +6048,18 @@ class SaleOrder(models.Model):
return False
def _send_ready_for_delivery_email(self, technicians=None, scheduled_datetime=None, notes=None):
"""Send notification when application is marked Ready for Delivery."""
"""Send notification when application is marked Ready for Delivery.
Client + sales rep + delivery technicians only — no authorizer. This
is an operational scheduling email (the delivery team is about to
contact the client to arrange a specific appointment). The authorizer
will be notified at case_closed when the product is actually delivered.
See the 2026-04 email audit authorizer rule.
"""
self.ensure_one()
if not self._is_email_notifications_enabled():
return False
recipients = self._get_email_recipients(include_client=True, include_authorizer=True, include_sales_rep=True)
recipients = self._get_email_recipients(include_client=True, include_authorizer=False, include_sales_rep=True)
to_emails = recipients.get('to', [])
cc_emails = recipients.get('cc', []) + recipients.get('office_cc', [])
@@ -6328,7 +6776,16 @@ class SaleOrder(models.Model):
if self.env.context.get('skip_status_emails'):
new_app_status = None # Disable all email triggers below
if new_app_status in ('submitted', 'resubmitted'):
if new_app_status == 'assessment_scheduled':
for order in self:
order._send_assessment_scheduled_email()
elif new_app_status == 'assessment_completed':
for order in self:
order._send_assessment_completed_email()
elif new_app_status == 'application_received':
for order in self:
order._send_application_received_email()
elif new_app_status in ('submitted', 'resubmitted'):
for order in self:
order._send_submission_email()
# Create submission history record
@@ -6339,9 +6796,10 @@ class SaleOrder(models.Model):
order._send_approval_email()
order._schedule_delivery_reminder()
elif new_app_status == 'accepted':
# 'Accepted' is internal tracking - no external email notification
# But we record it in submission history
# 'Accepted' — notify client + authorizer that ADP acknowledged the submission
# (added 2026-04 email audit fix — was silent before)
for order in self:
order._send_accepted_email()
# Update the most recent pending submission to 'accepted'
pending_submission = self.env['fusion.submission.history'].search([
('sale_order_id', '=', order.id),
@@ -6377,6 +6835,16 @@ class SaleOrder(models.Model):
if not self.env.context.get('skip_status_emails'):
for order in self:
order._send_case_closed_email()
elif new_app_status == 'cancelled':
# 2026-04 email audit fix — notify client + authorizer that
# the application is no longer being processed.
for order in self:
order._send_cancelled_email()
elif new_app_status == 'expired':
# 2026-04 email audit fix — the auto-expire cron (and the
# manual Mark-Expired path if added later) triggers this.
for order in self:
order._send_expired_email()
# ==================================================================
# MARCH OF DIMES STATUS-TRIGGERED EMAILS & SMS

View File

@@ -1274,7 +1274,33 @@
icon="fa-play"
invisible="not x_fc_is_adp_sale or x_fc_adp_application_status != 'on_hold'"
help="Resume this application from hold"/>
<!-- ============================================================ -->
<!-- RECOVERY BUTTONS - bring stuck orders back into the workflow -->
<!-- Added 2026-04 to wire up the exit paths drawn in the FigJam -->
<!-- ============================================================ -->
<button name="action_adp_reopen_cancelled" type="object"
string="Reopen" class="btn-info"
icon="fa-refresh"
invisible="not x_fc_is_adp_sale or x_fc_adp_application_status != 'cancelled'"
confirm="Reopen this cancelled application at the Quotation stage?"
help="Return a cancelled application to Quotation"/>
<button name="action_adp_reopen_expired" type="object"
string="Reopen" class="btn-info"
icon="fa-refresh"
invisible="not x_fc_is_adp_sale or x_fc_adp_application_status != 'expired'"
confirm="Reopen this expired application at the Quotation stage?"
help="Return an expired application to Quotation"/>
<button name="action_adp_resubmit_from_denied" type="object"
string="Resubmit" class="btn-primary"
icon="fa-repeat"
invisible="not x_fc_is_adp_sale or x_fc_adp_application_status != 'denied'"
confirm="Send this denied application back to Ready for Submission for a fresh attempt?"
help="Send a denied application back to Ready for Submission"/>
<!-- ============================================================ -->
<!-- EXCEPTION BUTTONS - Put On Hold / Withdraw -->
<!-- ============================================================ -->
@@ -1472,8 +1498,28 @@
required="x_fc_stage_after_ready_submission"
readonly="x_fc_case_locked"/>
<!-- Recovery Action Buttons - visible only for stuck states -->
<div class="mt-2" colspan="2"
invisible="x_fc_case_locked or x_fc_adp_application_status not in ('cancelled', 'expired', 'denied')">
<button name="action_adp_reopen_cancelled" type="object"
string="Reopen" class="btn-info btn-sm me-1"
icon="fa-refresh"
invisible="x_fc_adp_application_status != 'cancelled'"
confirm="Reopen this cancelled application at the Quotation stage?"/>
<button name="action_adp_reopen_expired" type="object"
string="Reopen" class="btn-info btn-sm me-1"
icon="fa-refresh"
invisible="x_fc_adp_application_status != 'expired'"
confirm="Reopen this expired application at the Quotation stage?"/>
<button name="action_adp_resubmit_from_denied" type="object"
string="Resubmit" class="btn-primary btn-sm me-1"
icon="fa-repeat"
invisible="x_fc_adp_application_status != 'denied'"
confirm="Send this denied application back to Ready for Submission for a fresh attempt?"/>
</div>
<!-- Status Action Buttons (require reason popup) - Hidden when locked -->
<div class="mt-2" colspan="2"
<div class="mt-2" colspan="2"
invisible="x_fc_case_locked or x_fc_adp_application_status in ('quotation', 'cancelled', 'case_closed', 'expired')">
<button name="%(fusion_claims.action_set_status_on_hold)d"
type="action" string="Put On Hold"