From 8b2cbd90853b9f6d3cba08083d45f8fd6b275d41 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Thu, 9 Apr 2026 06:06:33 -0400 Subject: [PATCH] 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. --- fusion_claims/__manifest__.py | 2 +- .../data/ir_config_parameter_data.xml | 8 + fusion_claims/data/ir_cron_data.xml | 16 + fusion_claims/models/sale_order.py | 482 +++++++++++++++++- fusion_claims/views/sale_order_views.xml | 50 +- 5 files changed, 548 insertions(+), 10 deletions(-) diff --git a/fusion_claims/__manifest__.py b/fusion_claims/__manifest__.py index ee494cbb..1e1ccb1c 100644 --- a/fusion_claims/__manifest__.py +++ b/fusion_claims/__manifest__.py @@ -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': """ diff --git a/fusion_claims/data/ir_config_parameter_data.xml b/fusion_claims/data/ir_config_parameter_data.xml index 54739279..f0ae63ee 100644 --- a/fusion_claims/data/ir_config_parameter_data.xml +++ b/fusion_claims/data/ir_config_parameter_data.xml @@ -133,6 +133,14 @@ 10 + + + fusion_claims.adp_approval_expiry_months + 12 + + fusion_claims.sa_mobility_email diff --git a/fusion_claims/data/ir_cron_data.xml b/fusion_claims/data/ir_cron_data.xml index b0c835f7..cb5c8580 100644 --- a/fusion_claims/data/ir_cron_data.xml +++ b/fusion_claims/data/ir_cron_data.xml @@ -136,5 +136,21 @@ + + + Fusion Claims: ADP Expire Approved Applications + + code + model._cron_adp_expire_approved() + 1 + days + True + + + diff --git a/fusion_claims/models/sale_order.py b/fusion_claims/models/sale_order.py index 74ddd68d..74839b9b 100644 --- a/fusion_claims/models/sale_order.py +++ b/fusion_claims/models/sale_order.py @@ -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'
  • {k}: {v}
  • ' for k, v in details.items()) + detail_html = f'' + body = ( + f'' + ) + 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( + '' + ), + 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"/> - + + + + + + +