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:
@@ -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': """
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user