'
+ )
+ 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(
+ '
'
+ '
Approval Expired Automatically
'
+ f'
Approval was received {days_old} days ago '
+ f'({order.x_fc_claim_approval_date}) — past the {months}-month '
+ f'ADP funding window.
'
+ '
Use Reopen to reinstate the application.
'
+ '
'
+ ),
+ 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 {client_name} has been scheduled '
+ f'for {when}.',
+ email_type='info',
+ note='What to expect: 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 {client_name}. '
+ f'Our team is preparing to submit the application to ADP.',
+ email_type='info',
+ note='Next steps: 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 {client_name} has been completed.',
+ email_type='success',
+ note='Next steps: 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 {client_name} '
+ f'and it is now under review.',
+ email_type='success',
+ note='What happens now: 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 {client_name} has been cancelled.',
+ email_type='info',
+ note='What this means: 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 {client_name} has expired because '
+ f'the equipment was not delivered within the {months}-month window.',
+ email_type='urgent',
+ note='What to do next: 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
diff --git a/fusion_claims/views/sale_order_views.xml b/fusion_claims/views/sale_order_views.xml
index f62501cd..6bde26a2 100644
--- a/fusion_claims/views/sale_order_views.xml
+++ b/fusion_claims/views/sale_order_views.xml
@@ -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"/>
-
+
+
+
+
+
+
+
+
+
+
+
+
@@ -1472,8 +1498,28 @@
required="x_fc_stage_after_ready_submission"
readonly="x_fc_case_locked"/>
+
+