diff --git a/.DS_Store b/.DS_Store index c0a5f1dc..64b305d7 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/fusion_claims/data/ir_cron_data.xml b/fusion_claims/data/ir_cron_data.xml index 9d32d66e..9632ff61 100644 --- a/fusion_claims/data/ir_cron_data.xml +++ b/fusion_claims/data/ir_cron_data.xml @@ -168,5 +168,26 @@ + + + Fusion Claims: ADP Hold Expiry Reminders + + code + model._cron_adp_hold_expiry_reminders() + 1 + days + True + + + diff --git a/fusion_claims/models/sale_order.py b/fusion_claims/models/sale_order.py index 6927f961..332977c2 100644 --- a/fusion_claims/models/sale_order.py +++ b/fusion_claims/models/sale_order.py @@ -344,15 +344,31 @@ class SaleOrder(models.Model): help='Sale type is locked after application is submitted to ADP', ) - @api.depends('x_fc_adp_application_status') + @api.depends('x_fc_adp_application_status', 'x_fc_wsib_status', + 'x_fc_insurance_status', 'x_fc_mdc_status', 'x_fc_hardship_status') def _compute_sale_type_locked(self): - """Sale type is locked once application is submitted to ADP.""" - locked_statuses = [ + """Sale type is locked once a workflow has progressed past quotation.""" + adp_locked = { 'submitted', 'accepted', 'rejected', 'resubmitted', 'approved', 'approved_deduction', 'ready_bill', 'billed', 'case_closed', - ] + } + # Any non-quotation state of a funder-specific workflow locks the sale type. for order in self: - order.x_fc_sale_type_locked = order.x_fc_adp_application_status in locked_statuses + if order.x_fc_adp_application_status in adp_locked: + order.x_fc_sale_type_locked = True + continue + funder_statuses = [ + (order.x_fc_sale_type == 'wsib', order.x_fc_wsib_status), + (order.x_fc_sale_type == 'insurance', order.x_fc_insurance_status), + (order.x_fc_sale_type == 'muscular_dystrophy', order.x_fc_mdc_status), + (order.x_fc_sale_type == 'hardship', order.x_fc_hardship_status), + ] + locked = False + for active, status in funder_statuses: + if active and status and status != 'quotation': + locked = True + break + order.x_fc_sale_type_locked = locked x_fc_client_type = fields.Selection( selection=[ @@ -445,7 +461,9 @@ class SaleOrder(models.Model): help='March of Dimes case workflow status. handoff_to_client sits ' 'parallel to quote_submitted: both lead to awaiting_funding but ' 'handoff_to_client means we have given the proposal+quote+drawing ' - 'to the client or authorizer and are waiting for them to submit.', + 'to the client or authorizer and are waiting for them to submit. ' + 'From funding_denied, move back to awaiting_funding to reapply ' + 'with updated documents, or to case_closed to close the file.', ) @api.model @@ -963,6 +981,13 @@ class SaleOrder(models.Model): tracking=True, help='Case worker name for this order', ) + x_fc_odsp_previous_status_before_hold = fields.Char( + string='ODSP Status Before Hold', + copy=False, + help='Previous ODSP division-specific status before the case was put ' + 'on hold. Restored by action_odsp_resume. 2026-04 fix — resume ' + 'used to hardcode quotation which destroyed workflow progress.', + ) # --- SA Mobility status --- x_fc_sa_status = fields.Selection( @@ -984,6 +1009,9 @@ class SaleOrder(models.Model): default='quotation', tracking=True, group_expand='_expand_sa_statuses', + help='SA Mobility case workflow status. From Denied, move back to ' + 'Submitted to SA Mobility to reapply, or to Case Closed to ' + 'close the file.', ) @api.model @@ -1018,6 +1046,9 @@ class SaleOrder(models.Model): default='quotation', tracking=True, group_expand='_expand_odsp_std_statuses', + help='Standard ODSP case workflow status. From Denied, move back to ' + 'Submitted to ODSP to reapply, or to Case Closed to close the ' + 'file.', ) @api.model @@ -1051,6 +1082,10 @@ class SaleOrder(models.Model): default='quotation', tracking=True, group_expand='_expand_ow_statuses', + help='Ontario Works case workflow status. OW pays upfront so the ' + 'order is payment_received before ready_delivery. From Denied, ' + 'move back to Submitted to Ontario Works to reapply, or to ' + 'Case Closed to close the file.', ) @api.model @@ -1107,6 +1142,27 @@ class SaleOrder(models.Model): self.ensure_one() return self.x_fc_sale_type in ('odsp', 'adp_odsp') + @api.constrains('x_fc_odsp_division') + def _check_odsp_division_change(self): + """Block changing ODSP division once the division-specific status has + advanced past 'quotation'. Otherwise switching divisions leaves the + old division's work orphaned on one status field while the new + division starts fresh at quotation. + + 2026-04 fix — previously there was no guard; users could silently + lose workflow progress by toggling the division selector. + """ + for order in self: + if not order._is_odsp_sale() or not order.x_fc_odsp_division: + continue + current_status = order._get_odsp_status() + if current_status and current_status != 'quotation': + raise UserError(_( + "Cannot change ODSP Division on %s — workflow progress " + "exists on the current division (status: %s). Close or " + "cancel the current case before switching divisions." + ) % (order.name, current_status)) + @api.onchange('partner_id') def _onchange_partner_odsp_case_worker(self): """Auto-populate ODSP case worker from partner when partner changes.""" @@ -1498,13 +1554,42 @@ class SaleOrder(models.Model): self._odsp_advance_status('case_closed', "ODSP case closed.") def action_odsp_on_hold(self): + """Put ODSP case on hold, remembering the previous status for resume. + + 2026-04 fix — aligns with MoD hold/resume pattern. Previously the + resume action was hardcoded to 'quotation' which wiped workflow + progress. + """ self.ensure_one() + current_status = self._get_odsp_status() + if current_status in ('on_hold', 'case_closed', 'cancelled'): + raise UserError(_( + "This case cannot be put on hold from its current status (%s)." + ) % current_status) + self.with_context(skip_all_validations=True).write({ + 'x_fc_odsp_previous_status_before_hold': current_status, + }) self._odsp_advance_status('on_hold', "ODSP case placed on hold.") def action_odsp_resume(self): - """Resume from on_hold -- go back to the previous logical status.""" + """Resume from on_hold — restore the previous status. + + 2026-04 fix — used to hardcode 'quotation' which destroyed workflow + progress. Now uses x_fc_odsp_previous_status_before_hold like MoD. + Falls back to 'quotation' only if no previous status was captured + (e.g. legacy records held before this fix shipped). + """ self.ensure_one() - self._odsp_advance_status('quotation', "ODSP case resumed.") + if self._get_odsp_status() != 'on_hold': + raise UserError(_("Only held cases can be resumed.")) + previous = self.x_fc_odsp_previous_status_before_hold or 'quotation' + self._odsp_advance_status( + previous, + "ODSP case resumed — restored to previous status.", + ) + self.with_context(skip_all_validations=True).write({ + 'x_fc_odsp_previous_status_before_hold': False, + }) def action_odsp_denied(self): self.ensure_one() @@ -1693,6 +1778,462 @@ class SaleOrder(models.Model): ) _logger.info("POD signature applied to approval form for %s", self.name) + # ========================================================================== + # WSIB WORKFLOW FIELDS + # ========================================================================== + x_fc_wsib_status = fields.Selection( + selection=[ + ('quotation', 'Quotation'), + ('assessment_scheduled', 'Assessment Scheduled'), + ('assessment_completed', 'Assessment Completed'), + ('documents_ready', 'Documents Ready'), + ('submitted_to_wsib', 'Submitted to WSIB'), + ('pre_approved', 'Pre-Approved'), + ('ready_delivery', 'Ready for Delivery'), + ('delivered', 'Delivered'), + ('pod_submitted', 'POD Submitted'), + ('invoice_submitted', 'Invoice Submitted'), + ('payment_received', 'Payment Received'), + ('case_closed', 'Case Closed'), + ('on_hold', 'On Hold'), + ('denied', 'Denied'), + ('cancelled', 'Cancelled'), + ], + string='WSIB Status', + default='quotation', + tracking=True, + copy=False, + group_expand='_expand_wsib_statuses', + help='WSIB case workflow status. From Denied, move back to ' + 'Submitted to WSIB to reapply, or to Case Closed to close the file.', + ) + + @api.model + def _expand_wsib_statuses(self, states, domain): + main = [ + 'quotation', 'assessment_scheduled', 'assessment_completed', + 'documents_ready', 'submitted_to_wsib', 'pre_approved', + 'ready_delivery', 'delivered', 'pod_submitted', + 'invoice_submitted', 'payment_received', 'case_closed', + ] + result = list(main) + for s in (states or []): + if s and s not in result: + result.append(s) + return result + + x_fc_wsib_claim_number = fields.Char( + string='WSIB Claim Number', + tracking=True, + copy=False, + ) + x_fc_wsib_adjudicator_name = fields.Char( + string='WSIB Adjudicator', + tracking=True, + ) + x_fc_wsib_form_7_date = fields.Date( + string='Form 7 Date', + tracking=True, + help='Date Form 7 was submitted by the authorizer.', + ) + x_fc_wsib_approval_date = fields.Date( + string='WSIB Approval Date', + tracking=True, + ) + x_fc_wsib_approval_letter = fields.Binary( + string='WSIB Approval Letter', + attachment=True, + ) + x_fc_wsib_approval_letter_filename = fields.Char(string='WSIB Approval Letter Filename') + + x_fc_is_wsib_sale = fields.Boolean( + compute='_compute_is_wsib_sale', + store=True, + string='Is WSIB Sale', + ) + x_fc_show_wsib_fields = fields.Boolean( + compute='_compute_show_wsib_fields', + string='Show WSIB Fields', + ) + + @api.depends('x_fc_sale_type') + def _compute_is_wsib_sale(self): + for order in self: + order.x_fc_is_wsib_sale = order.x_fc_sale_type == 'wsib' + + @api.depends('x_fc_sale_type') + def _compute_show_wsib_fields(self): + for order in self: + order.x_fc_show_wsib_fields = order.x_fc_sale_type == 'wsib' + + def _is_wsib_sale(self): + self.ensure_one() + return self.x_fc_sale_type == 'wsib' + + # ========================================================================== + # INSURANCE WORKFLOW FIELDS + # ========================================================================== + x_fc_insurance_status = fields.Selection( + selection=[ + ('quotation', 'Quotation'), + ('home_assessment_scheduled', 'Home Assessment Scheduled'), + ('home_assessment_completed', 'Home Assessment Completed'), + ('documents_ready', 'Documents Ready'), + # Client-submit branch + ('submitted_by_client', 'Submitted by Client'), + ('approval_received', 'Approval Received'), + ('payment_received_from_client', 'Payment Received from Client'), + # Direct-bill branch + ('pre_auth_submitted', 'Pre-Auth Submitted'), + ('pre_auth_approved', 'Pre-Auth Approved'), + # Shared tail + ('product_ordered', 'Product Ordered'), + ('delivered', 'Delivered'), + ('pod_to_client', 'POD Given to Client'), + ('pod_invoice_submitted', 'POD & Invoice Submitted'), + ('payment_received', 'Payment Received'), + ('case_closed', 'Case Closed'), + ('on_hold', 'On Hold'), + ('denied', 'Denied'), + ('cancelled', 'Cancelled'), + ], + string='Insurance Status', + default='quotation', + tracking=True, + copy=False, + group_expand='_expand_insurance_statuses', + help='Insurance case workflow status. From Denied, move back to ' + 'Documents Ready to reapply, or to Case Closed to close the file. ' + 'On the client-submit path, POD Given to Client is followed by ' + 'Case Closed (client is reimbursed directly by the insurer).', + ) + + @api.model + def _expand_insurance_statuses(self, states, domain): + main = [ + 'quotation', 'documents_ready', + 'submitted_by_client', 'pre_auth_submitted', + 'approval_received', 'pre_auth_approved', + 'product_ordered', 'delivered', + 'pod_to_client', 'pod_invoice_submitted', + 'payment_received', 'case_closed', + ] + result = list(main) + for s in (states or []): + if s and s not in result: + result.append(s) + return result + + x_fc_insurance_submission_mode = fields.Selection( + selection=[ + ('client_submits', 'Client Submits'), + ('direct_bill', 'Direct Bill'), + ], + string='Submission Mode', + tracking=True, + help='Whether the client submits on their own or we direct-bill the insurer.', + ) + x_fc_insurance_company_id = fields.Many2one( + 'res.partner', + string='Insurance Company', + tracking=True, + ) + x_fc_insurance_policy_number = fields.Char(string='Policy Number', tracking=True) + x_fc_insurance_claim_number = fields.Char(string='Insurance Claim Number', tracking=True) + x_fc_insurance_pre_auth_amount = fields.Monetary( + string='Pre-Auth Amount', + currency_field='currency_id', + tracking=True, + ) + x_fc_insurance_pre_auth_expiry = fields.Date(string='Pre-Auth Expiry', tracking=True) + x_fc_insurance_home_assessment_required = fields.Boolean( + string='Home Assessment Required', + tracking=True, + ) + x_fc_insurance_letter_source = fields.Selection( + selection=[ + ('occupational_therapist', 'Occupational Therapist'), + ('physiotherapist', 'Physiotherapist'), + ('doctor', 'Doctor'), + ], + string='Letter Source', + tracking=True, + ) + x_fc_insurance_approval_letter = fields.Binary(string='Insurance Approval Letter', attachment=True) + x_fc_insurance_approval_letter_filename = fields.Char(string='Approval Letter Filename') + + x_fc_is_insurance_sale = fields.Boolean( + compute='_compute_is_insurance_sale', + store=True, + string='Is Insurance Sale', + ) + x_fc_show_insurance_fields = fields.Boolean( + compute='_compute_show_insurance_fields', + string='Show Insurance Fields', + ) + + @api.depends('x_fc_sale_type') + def _compute_is_insurance_sale(self): + for order in self: + order.x_fc_is_insurance_sale = order.x_fc_sale_type == 'insurance' + + @api.depends('x_fc_sale_type') + def _compute_show_insurance_fields(self): + for order in self: + order.x_fc_show_insurance_fields = order.x_fc_sale_type == 'insurance' + + def _is_insurance_sale(self): + self.ensure_one() + return self.x_fc_sale_type == 'insurance' + + # ========================================================================== + # MUSCULAR DYSTROPHY (MDC) WORKFLOW FIELDS + # ========================================================================== + x_fc_mdc_status = fields.Selection( + selection=[ + ('quotation', 'Quotation'), + ('awaiting_ot_letter', 'Awaiting OT Letter'), + ('documents_ready', 'Documents Ready'), + ('submitted_to_mdc', 'Submitted to MDC'), + ('po_received', 'Purchase Order Received'), + ('product_ordered', 'Product Ordered'), + ('delivered', 'Delivered'), + ('pod_invoice_submitted', 'POD & Invoice Submitted'), + ('awaiting_payment', 'Awaiting Payment'), + ('payment_received', 'Payment Received'), + ('case_closed', 'Case Closed'), + ('not_enrolled', 'Client Not Enrolled'), + ('on_hold', 'On Hold'), + ('denied', 'Denied'), + ('withdrawn', 'Withdrawn'), + ('cancelled', 'Cancelled'), + ], + string='MDC Status', + default='quotation', + tracking=True, + copy=False, + group_expand='_expand_mdc_statuses', + help='Muscular Dystrophy Canada case workflow status. ' + 'From Denied, move back to Submitted to MDC to reapply, or to ' + 'Case Closed to close the file. From Client Not Enrolled, move ' + 'back to Quotation once enrollment is verified.', + ) + + @api.model + def _expand_mdc_statuses(self, states, domain): + main = [ + 'quotation', 'awaiting_ot_letter', 'documents_ready', + 'submitted_to_mdc', 'po_received', 'product_ordered', + 'delivered', 'pod_invoice_submitted', 'awaiting_payment', + 'payment_received', 'case_closed', + ] + result = list(main) + for s in (states or []): + if s and s not in result: + result.append(s) + return result + + x_fc_mdc_client_id_number = fields.Char( + string='MDC Client ID', + tracking=True, + help='Client ID in the Muscular Dystrophy Canada system.', + ) + x_fc_mdc_enrollment_verified = fields.Boolean( + string='Enrollment Verified', + tracking=True, + ) + x_fc_mdc_enrollment_verified_date = fields.Date( + string='Enrollment Verified Date', + tracking=True, + ) + x_fc_mdc_submitted_by = fields.Selection( + selection=[ + ('our_team', 'Our Team'), + ('client', 'Client'), + ('authorizer', 'Authorizer'), + ], + string='Submitted By', + tracking=True, + ) + x_fc_mdc_po_number = fields.Char(string='MDC PO Number', tracking=True, copy=False) + x_fc_mdc_po_date = fields.Date(string='PO Date', tracking=True) + x_fc_mdc_po_amount = fields.Monetary( + string='PO Amount', + currency_field='currency_id', + tracking=True, + ) + x_fc_mdc_payment_due_date = fields.Date( + string='Payment Due Date', + compute='_compute_mdc_payment_due_date', + store=True, + help='PO date + 90 days.', + ) + x_fc_mdc_letter_source = fields.Selection( + selection=[ + ('occupational_therapist', 'Occupational Therapist'), + ('physiotherapist', 'Physiotherapist'), + ], + string='Letter Source', + tracking=True, + ) + x_fc_mdc_po_document = fields.Binary(string='MDC Purchase Order', attachment=True) + x_fc_mdc_po_document_filename = fields.Char(string='PO Filename') + + @api.depends('x_fc_mdc_po_date') + def _compute_mdc_payment_due_date(self): + from datetime import timedelta + for order in self: + if order.x_fc_mdc_po_date: + order.x_fc_mdc_payment_due_date = order.x_fc_mdc_po_date + timedelta(days=90) + else: + order.x_fc_mdc_payment_due_date = False + + x_fc_is_mdc_sale = fields.Boolean( + compute='_compute_is_mdc_sale', + store=True, + string='Is MDC Sale', + ) + x_fc_show_mdc_fields = fields.Boolean( + compute='_compute_show_mdc_fields', + string='Show MDC Fields', + ) + + @api.depends('x_fc_sale_type') + def _compute_is_mdc_sale(self): + for order in self: + order.x_fc_is_mdc_sale = order.x_fc_sale_type == 'muscular_dystrophy' + + @api.depends('x_fc_sale_type') + def _compute_show_mdc_fields(self): + for order in self: + order.x_fc_show_mdc_fields = order.x_fc_sale_type == 'muscular_dystrophy' + + def _is_mdc_sale(self): + self.ensure_one() + return self.x_fc_sale_type == 'muscular_dystrophy' + + # ========================================================================== + # HARDSHIP FUNDING WORKFLOW FIELDS + # ========================================================================== + x_fc_hardship_status = fields.Selection( + selection=[ + ('quotation', 'Quotation'), + ('awaiting_pre_assessment', 'Awaiting Pre-Assessment'), + ('pre_assessment_complete', 'Pre-Assessment Complete'), + ('application_package_ready', 'Application Package Ready'), + ('submitted_to_hf', 'Submitted to Hardship Funder'), + ('eligibility_interview', 'Eligibility Interview Scheduled'), + ('approval_received', 'Approval Received'), + ('product_ordered', 'Product Ordered'), + ('delivered', 'Delivered'), + ('pod_invoice_submitted', 'POD & Invoice Submitted'), + ('payment_received', 'Payment Received'), + ('case_closed', 'Case Closed'), + ('eligibility_failed', 'Eligibility Failed'), + ('on_hold', 'On Hold'), + ('denied', 'Denied'), + ('cancelled', 'Cancelled'), + ], + string='Hardship Status', + default='quotation', + tracking=True, + copy=False, + group_expand='_expand_hardship_statuses', + help='Hardship funding case workflow status. From Denied or ' + 'Eligibility Failed, move back to Application Package Ready to ' + 'reapply with a different funder or updated documents, or to ' + 'Case Closed to close the file.', + ) + + @api.model + def _expand_hardship_statuses(self, states, domain): + main = [ + 'quotation', 'awaiting_pre_assessment', 'pre_assessment_complete', + 'application_package_ready', 'submitted_to_hf', 'eligibility_interview', + 'approval_received', 'product_ordered', 'delivered', + 'pod_invoice_submitted', 'payment_received', 'case_closed', + ] + result = list(main) + for s in (states or []): + if s and s not in result: + result.append(s) + return result + + x_fc_hardship_funder_id = fields.Many2one( + 'res.partner', + string='Hardship Funder', + tracking=True, + help='Foundation or organization providing hardship funding.', + ) + x_fc_hardship_submitted_by = fields.Selection( + selection=[ + ('our_team', 'Our Team'), + ('client', 'Client'), + ('authorizer', 'Authorizer'), + ], + string='Submitted By', + tracking=True, + ) + x_fc_hardship_interview_date = fields.Date( + string='Eligibility Interview Date', + tracking=True, + ) + x_fc_hardship_approval_date = fields.Date(string='Approval Date', tracking=True) + x_fc_hardship_approval_amount = fields.Monetary( + string='Approval Amount', + currency_field='currency_id', + tracking=True, + ) + x_fc_hardship_client_portion = fields.Monetary( + string='Client Portion', + currency_field='currency_id', + tracking=True, + help='Client contribution when approval is partial.', + ) + x_fc_hardship_approval_received_via = fields.Selection( + selection=[ + ('fax', 'Fax'), + ('email', 'Email'), + ('mail', 'Mail'), + ], + string='Approval Received Via', + tracking=True, + ) + x_fc_hardship_pre_assessment_source = fields.Selection( + selection=[ + ('occupational_therapist', 'Occupational Therapist'), + ('physiotherapist', 'Physiotherapist'), + ], + string='Pre-Assessment Source', + tracking=True, + ) + x_fc_hardship_approval_letter = fields.Binary(string='Hardship Approval Letter', attachment=True) + x_fc_hardship_approval_letter_filename = fields.Char(string='Approval Letter Filename') + + x_fc_is_hardship_sale = fields.Boolean( + compute='_compute_is_hardship_sale', + store=True, + string='Is Hardship Sale', + ) + x_fc_show_hardship_fields = fields.Boolean( + compute='_compute_show_hardship_fields', + string='Show Hardship Fields', + ) + + @api.depends('x_fc_sale_type') + def _compute_is_hardship_sale(self): + for order in self: + order.x_fc_is_hardship_sale = order.x_fc_sale_type == 'hardship' + + @api.depends('x_fc_sale_type') + def _compute_show_hardship_fields(self): + for order in self: + order.x_fc_show_hardship_fields = order.x_fc_sale_type == 'hardship' + + def _is_hardship_sale(self): + self.ensure_one() + return self.x_fc_sale_type == 'hardship' + # ========================================================================== # ADP CLAIM FIELDS # ========================================================================== @@ -1886,12 +2427,62 @@ class SaleOrder(models.Model): string='Status Before Withdrawal', help='Records the status before withdrawal for audit trail.', ) - + x_fc_previous_status_before_cancel = fields.Char( + string='Status Before Cancel', + copy=False, + help='Records the status before cancellation so reopen can restore ' + 'the correct state (only used when the cancellation was NOT ' + 'reported to ADP — reported cancellations require a new order).', + ) + x_fc_status_before_delivery = fields.Char( string='Status Before Delivery', help='Status before the order was marked Ready for Delivery (for reverting if task cancelled)', ) + # ========================================================================== + # ADP HOLD + CANCEL + REASSESSMENT WORKFLOW FIELDS (2026-04) + # ========================================================================== + x_fc_cancel_reported_to_adp = fields.Boolean( + string='Cancellation Reported to ADP', + default=True, + copy=False, + tracking=True, + help='Check this when the cancellation has been reported to ADP. ' + 'A reported cancellation requires the authorizer to re-assess ' + 'the client before any new application — the case cannot be ' + 'reopened directly; a new order must be created using the ' + 'Create Reassessment Order action.', + ) + x_fc_hold_reminder_last_sent = fields.Date( + string='Hold Reminder Last Sent', + copy=False, + help='Tracks the last monthly hold-expiry reminder sent to the ' + 'client. Resets when the case resumes from hold.', + ) + x_fc_hold_final_warning_sent = fields.Boolean( + string='Hold Final Warning Sent', + default=False, + copy=False, + help='One-shot flag: becomes True once the final pre-expiry warning ' + '(client + authorizer) has been emailed. Resets on resume.', + ) + x_fc_previous_sale_order_id = fields.Many2one( + 'sale.order', + string='Previous Order (Re-assessed From)', + copy=False, + readonly=True, + help='If this order was created as a reassessment of an older ADP ' + 'application whose funding expired or whose cancellation was ' + 'reported to ADP, this points to that original order.', + ) + x_fc_next_sale_order_ids = fields.One2many( + 'sale.order', + 'x_fc_previous_sale_order_id', + string='Subsequent Orders', + help='Newer orders that were created by re-assessing this one.', + ) + # ========================================================================== # DELIVERY TECHNICIAN TRACKING # ========================================================================== @@ -3713,30 +4304,34 @@ class SaleOrder(models.Model): def action_resume_from_hold(self): """Resume the application from on-hold status. - - Returns the application to its previous status before being put on hold. + + 2026-04 update — hold is only allowed from approved/approved_deduction + (see action_adp_put_on_hold), so resume must land on one of those + states. If the stored previous-status is missing or stale, we default + to 'approved' (the common case). This also clears the monthly- + reminder tracking so a future hold starts with a fresh cadence. """ self.ensure_one() - + if self.x_fc_adp_application_status != 'on_hold': raise UserError("This action is only available for applications that are On Hold.") - - # Get the previous status + + # With the 2026-04 hold restriction, only approved variants can be + # held. Accept the stored value only if it's one of those; otherwise + # fall back safely to 'approved'. previous_status = self.x_fc_previous_status_before_hold - - # If no previous status recorded, default to 'approved' - if not previous_status: + if previous_status not in ('approved', 'approved_deduction'): previous_status = 'approved' - - # Get status labels for message + status_labels = dict(self._fields['x_fc_adp_application_status'].selection) prev_label = status_labels.get(previous_status, previous_status) - - # Update the status + self.with_context(skip_status_validation=True).write({ 'x_fc_adp_application_status': previous_status, 'x_fc_on_hold_date': False, 'x_fc_previous_status_before_hold': False, + 'x_fc_hold_reminder_last_sent': False, + 'x_fc_hold_final_warning_sent': False, }) # Post to chatter @@ -3835,25 +4430,34 @@ class SaleOrder(models.Model): ) def action_adp_put_on_hold(self): - """Put the ADP application on hold, preserving the previous state for resume. + """Put the ADP application on hold. - 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. + 2026-04 business rule — hold is only allowed from Approved or + Approved with Deduction. Submitted / Accepted / pre-approval states + cannot be held; the case must complete the ADP review first. This + reflects how the office actually uses hold: after approval, the + client asks to defer delivery. + + The current state is saved to x_fc_previous_status_before_hold so + action_resume_from_hold can restore it. Sends the standard on-hold + notification email (client + authorizer + sales rep). """ self.ensure_one() current = self.x_fc_adp_application_status - blocked = ('on_hold', 'case_closed', 'cancelled', 'expired', 'withdrawn') - if current in blocked: + allowed = ('approved', 'approved_deduction') + if current not in allowed: raise UserError( - _("This application cannot be put on hold from its current state (%s).") % current + _("Hold is only available after the application is Approved " + "(or Approved with Deduction). Cases earlier in the workflow " + "must complete ADP review first. Current status: %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, + # Reset hold-reminder state so cadence starts fresh. + 'x_fc_hold_reminder_last_sent': False, + 'x_fc_hold_final_warning_sent': False, }) labels = dict(self._fields['x_fc_adp_application_status'].selection) self._adp_chatter_transition( @@ -3960,10 +4564,14 @@ class SaleOrder(models.Model): return True def action_adp_cancel(self): - """Cancel the ADP application. Can be called from most active states. + """Cancel the ADP application. Saves current state for possible reopen. - Does not reset dates or clear documents — use action_adp_reopen_cancelled - to bring a cancelled order back into the workflow if needed. + 2026-04 update — records x_fc_previous_status_before_cancel so that + if the cancellation was NOT reported to ADP (rare), a reopen can + restore the exact previous state instead of resetting to quotation. + Cancellations that were reported to ADP require a fresh assessment + by the authorizer — use action_adp_duplicate_for_reassessment for + those (the reopen action will block and point to duplicate). """ self.ensure_one() current = self.x_fc_adp_application_status @@ -3974,6 +4582,7 @@ class SaleOrder(models.Model): ) self.with_context(skip_status_validation=True).write({ 'x_fc_adp_application_status': 'cancelled', + 'x_fc_previous_status_before_cancel': current, }) self._adp_chatter_transition( title='Application Cancelled', @@ -3983,34 +4592,127 @@ class SaleOrder(models.Model): return True def action_adp_reopen_cancelled(self): - """Bring a cancelled order back into the workflow at the Quotation stage.""" + """Bring a cancelled order back into the workflow. + + 2026-04 business rule — if the cancellation was reported to ADP, + the case CANNOT be reopened directly: the authorizer must reassess + the client (their condition may have changed) and a new order must + be created. The user is redirected to action_adp_duplicate_for_reassessment. + + If the cancellation was NOT reported to ADP, restore the previous + workflow state (preserves documents, submission prep, etc.). + """ self.ensure_one() if self.x_fc_adp_application_status != 'cancelled': raise UserError(_("This action is only available for cancelled applications.")) + + if self.x_fc_cancel_reported_to_adp: + raise UserError(_( + "This cancellation was reported to ADP. The case cannot be " + "reopened directly — the authorizer must reassess the client " + "before a new application can be submitted.\n\n" + "Use 'Create Reassessment Order' instead to start a new order " + "linked to this one." + )) + + # Not reported — safe to restore the previous workflow state. + previous = self.x_fc_previous_status_before_cancel or 'quotation' self.with_context(skip_status_validation=True).write({ - 'x_fc_adp_application_status': 'quotation', + 'x_fc_adp_application_status': previous, + 'x_fc_previous_status_before_cancel': False, }) + labels = dict(self._fields['x_fc_adp_application_status'].selection) self._adp_chatter_transition( title='Cancelled Application Reopened', icon='fa-refresh', colour_class='info', + details={'Restored To': labels.get(previous, previous)}, ) return True def action_adp_reopen_expired(self): - """Bring an expired application back into the workflow at the Quotation stage.""" + """Backwards-compat shim — routes old 'Reopen Expired' buttons to the + new duplicate-for-reassessment flow (2026-04 policy: expired cases + cannot be self-renewed, the authorizer must reassess). Kept so that + studio views / stored views in existing databases still resolve the + method name; remove once all callers have been migrated. + """ + return self.action_adp_duplicate_for_reassessment() + + def action_adp_duplicate_for_reassessment(self): + """Create a new sale order as a reassessment of this one. + + Used when: + - Funding has expired (12 months after approval with no delivery) + - A cancellation was reported to ADP + + In both cases, per business policy, the client's needs must be + reassessed by the authorizer (condition may have changed after 6+ + months). The new order starts fresh at Quotation stage with the + same partner, authorizer, and products. Products can be edited. + The old order stays in its terminal state (expired/cancelled) as + a historical record. The new order has x_fc_previous_sale_order_id + pointing back at the old order for audit trail. + """ 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({ + allowed_current = ('expired', 'cancelled') + if self.x_fc_adp_application_status not in allowed_current: + raise UserError(_( + "Create Reassessment Order is only available on expired or " + "cancelled applications. Current status: %s" + ) % self.x_fc_adp_application_status) + + # Reset ADP workflow-specific fields on the copy so the new order + # starts clean. Most documents and dates are already copy=False on + # their field definitions; the list below covers fields that are + # copy=True by default but must not carry over. + copy_defaults = { 'x_fc_adp_application_status': 'quotation', - }) - self._adp_chatter_transition( - title='Expired Application Reopened', - icon='fa-refresh', - colour_class='info', + 'x_fc_previous_sale_order_id': self.id, + 'x_fc_cancel_reported_to_adp': True, # default for the new order + 'x_fc_previous_status_before_hold': False, + 'x_fc_previous_status_before_withdrawal': False, + 'x_fc_previous_status_before_cancel': False, + 'x_fc_hold_reminder_last_sent': False, + 'x_fc_hold_final_warning_sent': False, + 'x_fc_on_hold_date': False, + 'x_fc_acceptance_reminder_sent': False, + } + new_order = self.copy(default=copy_defaults) + + labels = dict(self._fields['x_fc_adp_application_status'].selection) + prev_label = labels.get(self.x_fc_adp_application_status, self.x_fc_adp_application_status) + new_order.message_post( + body=Markup( + '' + ), + message_type='notification', + subtype_xmlid='mail.mt_note', ) - return True + self.message_post( + body=Markup( + '' + ), + message_type='notification', + subtype_xmlid='mail.mt_note', + ) + return { + 'name': _('Reassessment Order'), + 'type': 'ir.actions.act_window', + 'res_model': 'sale.order', + 'res_id': new_order.id, + 'view_mode': 'form', + 'target': 'current', + } def action_adp_resubmit_from_denied(self): """Send a denied application back to Ready for Submission for a fresh attempt.""" @@ -4036,7 +4738,13 @@ class SaleOrder(models.Model): @api.model def _cron_adp_expire_approved(self): - """Auto-expire approved/approved_deduction orders older than the configured window.""" + """Auto-expire ADP orders older than the configured approval window. + + 2026-04 update — previously this only scanned approved / approved_deduction. + Now also scans on_hold and ready_delivery because the 12-month funding + window is measured from approval_date and does NOT pause during those + states. A case that sat on hold for a year is still expired. + """ from dateutil.relativedelta import relativedelta ICP = self.env['ir.config_parameter'].sudo() try: @@ -4045,7 +4753,8 @@ class SaleOrder(models.Model): months = 12 cutoff = fields.Date.today() - relativedelta(months=months) stale = self.search([ - ('x_fc_adp_application_status', 'in', ('approved', 'approved_deduction')), + ('x_fc_adp_application_status', 'in', + ('approved', 'approved_deduction', 'on_hold', 'ready_delivery')), ('x_fc_claim_approval_date', '!=', False), ('x_fc_claim_approval_date', '<=', cutoff), ]) @@ -4072,6 +4781,206 @@ class SaleOrder(models.Model): except Exception as e: _logger.error(f"Failed to auto-expire {order.name}: {e}") + # ========================================================================== + # ADP HOLD-EXPIRY REMINDERS (2026-04) + # ========================================================================== + def _send_hold_expiry_reminder_email(self, days_to_expiry): + """Monthly reminder to the client while the case is on hold. + + Client-only. Authorizer is deliberately excluded per the 2026-04 + authorizer email policy — OTs work many cases and shouldn't be + notified of every hold tick. They get notified once, at the final + warning, and once when the case actually expires. + + Returns True if an email was sent, False otherwise (no client email, + notifications disabled, etc). + """ + self.ensure_one() + if not self._is_email_notifications_enabled(): + return False + client = self.partner_id + if not client or not client.email: + # Per the business rule: "If the email is not in the system, + # don't send it." Skip silently. + return False + + client_name = client.name or 'Client' + sales_rep_name = (self.user_id or self.env.user).name + approval_date = self.x_fc_claim_approval_date + approval_str = approval_date.strftime('%B %d, %Y') if approval_date else 'the approval date' + + body_html = self._email_build( + title='ADP Case On Hold — Funding Expiry Reminder', + summary=( + f'Your ADP application for {client_name} is currently ' + f'on hold. Your ADP funding was approved on {approval_str} ' + f'and will expire in {days_to_expiry} days if the equipment ' + f'is not delivered.' + ), + email_type='attention', + sections=[('Case Details', self._build_case_detail_rows())], + note=('What to do next: If you would like to proceed with ' + 'your equipment delivery, please contact our office so we can schedule it ' + 'before your funding expires.'), + note_color='#d69e2e', + button_url=f'{self.get_base_url()}/web#id={self.id}&model=sale.order&view_type=form', + sender_name=sales_rep_name, + ) + try: + self.env['mail.mail'].sudo().create({ + 'subject': f'ADP Case On Hold — {client_name} — {self.name}', + 'body_html': body_html, + 'email_to': client.email, + 'model': 'sale.order', 'res_id': self.id, + }).send() + self._email_chatter_log( + 'Hold expiry reminder sent to client', client.email, + extra_lines=[f'Days to funding expiry: {days_to_expiry}'], + ) + return True + except Exception as e: + _logger.error(f"Failed to send hold expiry reminder for {self.name}: {e}") + return False + + def _send_hold_final_warning_email(self, days_to_expiry): + """Final pre-expiry warning: client + authorizer, one-shot. + + Fires once when the funding is within the configured final-warning + window (default 30 days). This is the ONE hold-related email the + authorizer receives — the policy is that they shouldn't be notified + of every monthly reminder, but the last-chance warning matters + because it may mean a new assessment will be needed. + """ + self.ensure_one() + if not self._is_email_notifications_enabled(): + return False + client = self.partner_id + authorizer = self.x_fc_authorizer_id + to_emails = [] + if client and client.email: + to_emails.append(client.email) + cc_emails = [] + if authorizer and authorizer.email: + cc_emails.append(authorizer.email) + if not to_emails and not cc_emails: + return False + + client_name = client.name if client else 'Client' + sales_rep_name = (self.user_id or self.env.user).name + approval_date = self.x_fc_claim_approval_date + approval_str = approval_date.strftime('%B %d, %Y') if approval_date else 'the approval date' + + body_html = self._email_build( + title='Final Reminder — ADP Funding About to Expire', + summary=( + f'This is a final reminder that the ADP funding approval for ' + f'{client_name} (approved on {approval_str}) ' + f'expires in {days_to_expiry} days. The equipment has not ' + f'been delivered.' + ), + email_type='urgent', + sections=[('Case Details', self._build_case_detail_rows())], + note=('Important: Once the funding window passes, a new ' + 'authorizer assessment will be required before a new application can ' + 'be submitted. Please contact our office as soon as possible to ' + 'schedule delivery.'), + note_color='#c53030', + 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'Final Reminder: ADP Funding Expiring — {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('Hold final warning sent', email_to, email_cc) + return True + except Exception as e: + _logger.error(f"Failed to send hold final warning for {self.name}: {e}") + return False + + @api.model + def _cron_adp_hold_expiry_reminders(self): + """Cron: Monthly reminder + final warning for on-hold ADP cases. + + Logic per case: + 1. If days-to-expiry <= final_warning_days AND not yet sent → send + final warning (client + authorizer), mark flag, skip monthly. + 2. Else if last-reminder was >= interval_days ago → send monthly + reminder (client only), update last-sent date. + 3. Else skip. + + Cases with no client email are silently skipped (business rule). + Cases past expiry are handled by _cron_adp_expire_approved. + """ + from dateutil.relativedelta import relativedelta + + if not self._is_email_notifications_enabled(): + return + + ICP = self.env['ir.config_parameter'].sudo() + try: + interval_days = int(ICP.get_param( + 'fusion_claims.adp_hold_reminder_interval_days', '30')) + except (TypeError, ValueError): + interval_days = 30 + try: + final_warning_days = int(ICP.get_param( + 'fusion_claims.adp_hold_final_warning_days_before_expiry', '30')) + except (TypeError, ValueError): + final_warning_days = 30 + try: + expiry_months = int(ICP.get_param( + 'fusion_claims.adp_approval_expiry_months', '12')) + except (TypeError, ValueError): + expiry_months = 12 + try: + max_per_run = int(ICP.get_param( + 'fusion_claims.adp_hold_reminder_max_per_cron_run', '20')) + except (TypeError, ValueError): + max_per_run = 20 + + today = fields.Date.today() + + orders = self.search([ + ('x_fc_is_adp_sale', '=', True), + ('x_fc_adp_application_status', '=', 'on_hold'), + ('x_fc_claim_approval_date', '!=', False), + ], limit=max_per_run or None) + + for order in orders: + try: + expiry_date = order.x_fc_claim_approval_date + relativedelta(months=expiry_months) + days_to_expiry = (expiry_date - today).days + if days_to_expiry <= 0: + # Already past expiry — expire cron will handle. + continue + + # Final pre-expiry warning (one-shot, client + authorizer). + if (days_to_expiry <= final_warning_days + and not order.x_fc_hold_final_warning_sent): + if order._send_hold_final_warning_email(days_to_expiry): + order.with_context(skip_all_validations=True).write({ + 'x_fc_hold_final_warning_sent': True, + 'x_fc_hold_reminder_last_sent': today, + }) + continue + + # Monthly client-only reminder. + last_sent = order.x_fc_hold_reminder_last_sent or order.x_fc_on_hold_date + if last_sent and (today - last_sent).days < interval_days: + continue + if order._send_hold_expiry_reminder_email(days_to_expiry): + order.with_context(skip_all_validations=True).write({ + 'x_fc_hold_reminder_last_sent': today, + }) + except Exception as e: + _logger.error(f"Hold expiry reminder failed for {order.name}: {e}") + def action_set_ready_to_bill(self): """Open the Ready to Bill wizard to collect POD and delivery date. @@ -6522,6 +7431,19 @@ class SaleOrder(models.Model): # Track status changes for auto-actions new_app_status = vals.get('x_fc_adp_application_status') new_mod_status = vals.get('x_fc_mod_status') + new_wsib_status = vals.get('x_fc_wsib_status') + new_insurance_status = vals.get('x_fc_insurance_status') + new_mdc_status = vals.get('x_fc_mdc_status') + new_hardship_status = vals.get('x_fc_hardship_status') + + # Pre-write snapshot of MoD status per record. Needed because we + # reset follow-up counters ONLY on a real status change, and the + # comparison must happen before super().write() overwrites the field. + # 2026-04 fix: the old code compared post-write so the reset never + # fired and the rolling cap kept accumulating indefinitely. + old_mod_status_by_id = { + order.id: order.x_fc_mod_status for order in self + } if new_mod_status else {} # Handle document correction flow - clear document fields and submission date when needs_correction if new_app_status == 'needs_correction': @@ -6897,6 +7819,14 @@ class SaleOrder(models.Model): # Create submission history record submission_type = 'resubmission' if new_app_status == 'resubmitted' else 'initial' self.env['fusion.submission.history'].create_from_submission(order, submission_type=submission_type) + # 2026-04 anti-spam fix — reset acceptance-reminder flag so a + # fresh reminder can fire for this new submission cycle. + # Without this, a resubmission after rejection never triggers + # the acceptance reminder because the flag stays True. + if order.x_fc_acceptance_reminder_sent: + order.with_context(skip_all_validations=True).write({ + 'x_fc_acceptance_reminder_sent': False, + }) elif new_app_status in ('approved', 'approved_deduction'): for order in self: order._send_approval_email() @@ -6959,10 +7889,13 @@ class SaleOrder(models.Model): # Reset rolling follow-up counters on ANY real MOD status change — # this is the natural "new chapter" moment. Applies even when # skip_status_emails is set so imports/migrations also reset. + # Use pre-write snapshot (old_mod_status_by_id) because super() + # has already overwritten order.x_fc_mod_status at this point. for order in self: if not order._is_mod_sale(): continue - if order.x_fc_mod_status != new_mod_status: + old_status = old_mod_status_by_id.get(order.id) + if old_status != new_mod_status: order.with_context(skip_all_validations=True).write({ 'x_fc_mod_followup_month_count': 0, 'x_fc_mod_followup_month_start': False, @@ -7004,9 +7937,30 @@ class SaleOrder(models.Model): order._send_mod_pod_submitted_email() elif new_mod_status == 'case_closed': order._send_mod_case_closed_email() + elif new_mod_status == 'cancelled': + order._send_mod_cancelled_email() except Exception as e: _logger.error(f"MOD status email/sms failed for {order.name} ({new_mod_status}): {e}") + # ================================================================== + # FUNDER WORKFLOW STATUS-TRIGGERED EMAILS + # WSIB / Insurance / Muscular Dystrophy / Hardship Funding + # ================================================================== + funder_dispatches = [ + (new_wsib_status, 'wsib', self._WSIB_EMAIL_TRIGGERS, '_is_wsib_sale'), + (new_insurance_status, 'insurance', self._INSURANCE_EMAIL_TRIGGERS, '_is_insurance_sale'), + (new_mdc_status, 'muscular_dystrophy', self._MDC_EMAIL_TRIGGERS, '_is_mdc_sale'), + (new_hardship_status, 'hardship', self._HARDSHIP_EMAIL_TRIGGERS, '_is_hardship_sale'), + ] + if not self.env.context.get('skip_status_emails'): + for new_status, sale_type, trigger_map, is_fn in funder_dispatches: + if not new_status: + continue + for order in self: + if not getattr(order, is_fn)(): + continue + order._fire_funder_emails(trigger_map, new_status) + # Check if we need to recalculate ICP = self.env['ir.config_parameter'].sudo() sale_type_field = ICP.get_param('fusion_claims.field_sale_type', 'x_fc_sale_type') @@ -7481,32 +8435,52 @@ class SaleOrder(models.Model): @api.model def _cron_send_acceptance_reminders(self): """Cron job: Send reminders for orders still in 'submitted' status next business day. - + Per business rule: If 'Accepted by ADP' not marked within 1 business day after submission: - First email to Office Notification Recipients - Second email to Office + Sales Rep + + 2026-04 anti-spam updates: + - 14-day backlog guard so the first cron run after deploy does not + dump reminders for every old stuck 'submitted' case. Configurable + via fusion_claims.acceptance_reminder_max_age_days (default 14). + - Per-run cap via fusion_claims.acceptance_reminder_max_per_cron_run + (default 10) to spread large backlogs across multiple runs. """ from datetime import timedelta - + if not self._is_email_notifications_enabled(): _logger.info("Email notifications disabled, skipping acceptance reminders") return - + + ICP = self.env['ir.config_parameter'].sudo() + try: + max_age_days = int(ICP.get_param( + 'fusion_claims.acceptance_reminder_max_age_days', '14')) + except (TypeError, ValueError): + max_age_days = 14 + try: + max_per_run = int(ICP.get_param( + 'fusion_claims.acceptance_reminder_max_per_cron_run', '10')) + except (TypeError, ValueError): + max_per_run = 10 + today = fields.Date.today() - + # Find orders where: # - Status is still 'submitted' (not accepted, rejected, or later) - # - Submission date was at least 1 business day ago - # - # For simplicity, we check if submission was 2+ days ago (covers weekends) + # - Submission date is between max_age_days ago and 2 days ago + # (lower bound skips weekends; upper bound skips stale backlog) cutoff_date = today - timedelta(days=2) - + min_date = today - timedelta(days=max_age_days) + orders = self.search([ ('x_fc_is_adp_sale', '=', True), ('x_fc_adp_application_status', '=', 'submitted'), ('x_fc_claim_submission_date', '<=', cutoff_date), + ('x_fc_claim_submission_date', '>=', min_date), ('x_fc_acceptance_reminder_sent', '=', False), - ]) + ], limit=max_per_run or None) if not orders: _logger.info("Acceptance reminder cron: No orders require reminders") @@ -8340,6 +9314,23 @@ class SaleOrder(models.Model): 'If you experience any issues, please contact us immediately.', ) + def _send_mod_cancelled_email(self): + """Email: Case cancelled. To: Client, CC: Authorizer. + + 2026-04 email audit fix — MoD previously did not notify anyone when + a case was cancelled. Mirrors the ADP cancelled-email pattern. + """ + self.ensure_one() + client_name = self.partner_id.name or 'Client' + return self._send_mod_email( + subject_prefix='Case Cancelled', + title='Case Cancelled', + summary=f'The accessibility modification case for {client_name} has been cancelled.', + email_type='urgent', + include_client=True, include_authorizer=True, + note='This is an update for your records.', + ) + # ========================================================================== # MOD 2026-04 UPDATE — new email methods for submission-path workflow # ========================================================================== @@ -8629,12 +9620,19 @@ class SaleOrder(models.Model): if days_since is not None: summary += f' ({days_since} days since handoff)' - order.activity_schedule( - 'fusion_claims.mail_activity_type_mod_followup', - date_deadline=today + timedelta(days=3), - user_id=assignee.id, - summary=summary, - ) + # automated=True suppresses the default Odoo activity- + # assigned email so the assignee only sees the activity on + # their dashboard, not in their inbox. + self.env['mail.activity'].create({ + 'res_model': 'sale.order', + 'res_model_id': self.env['ir.model']._get('sale.order').id, + 'res_id': order.id, + 'activity_type_id': activity_type.id, + 'date_deadline': today + timedelta(days=3), + 'summary': summary, + 'user_id': assignee.id, + 'automated': True, + }) order.with_context(skip_all_validations=True).write({ 'x_fc_mod_followup_month_count': count + 1, }) @@ -8857,15 +9855,29 @@ class SaleOrder(models.Model): """Cron: Schedule bi-weekly follow-up activities for MOD cases awaiting funding. Only creates a new mail.activity when (a) no open follow-up activity - already exists for the order AND (b) the rolling 30-day follow-up cap - has not been reached. This prevents the old tight loop where the - schedule cron and the escalate cron endlessly recreated and deleted - the same activity day after day. + already exists for the order, (b) the rolling 30-day follow-up cap + has not been reached, AND (c) the per-cron-run throttle has not been + hit. Prevents bulk spamming the sales rep when many cases hit their + 14-day mark on the same day. + + 2026-04 anti-spam updates: + - Shares the rolling 2/30-day cap with the email escalator (previously + activities were uncapped while emails were capped). + - Hard per-run cap via fusion_claims.mod_followup_schedule_max_per_cron_run + (default 10) so large backlogs roll out across multiple cron runs. + - Activities are created with automated=True so Odoo does NOT fire + an "activity assigned" email to the rep. The activity still shows + on their dashboard so they can action it when convenient. """ from datetime import timedelta ICP = self.env['ir.config_parameter'].sudo() interval_days = int(ICP.get_param('fusion_claims.mod_followup_interval_days', '14')) + try: + max_per_run = int(ICP.get_param( + 'fusion_claims.mod_followup_schedule_max_per_cron_run', '10')) + except (TypeError, ValueError): + max_per_run = 10 # Statuses that need follow-up (waiting for funding decision) followup_statuses = ['quote_submitted', 'awaiting_funding'] @@ -8881,7 +9893,14 @@ class SaleOrder(models.Model): if not activity_type: return + scheduled_count = 0 for order in orders: + if max_per_run and scheduled_count >= max_per_run: + _logger.info( + f"MOD schedule cron reached per-run cap ({max_per_run}); " + f"remaining orders will roll over to the next run" + ) + break try: # Check if there's already an open activity of this type first — # if so, do nothing. Not even a date bump (the old code bumped @@ -8896,9 +9915,15 @@ class SaleOrder(models.Model): continue # No open activity. Respect the rolling cap before creating a - # new one — if the cap is hit, skip entirely and let the case - # sit quietly until the window resets or MOD status advances. - within_cap, _reset, _new_start, _max = order._mod_followup_cap_state() + # new one — shared with the email escalator so activities and + # emails draw from the same 2/30-day pool per case. + within_cap, reset_needed, new_start, _max = order._mod_followup_cap_state() + if reset_needed: + order.with_context(skip_all_validations=True).write({ + 'x_fc_mod_followup_month_start': new_start, + 'x_fc_mod_followup_month_count': 0, + 'x_fc_mod_followup_cap_notified': False, + }) if not within_cap: _logger.info( f"MOD follow-up skipped for {order.name}: monthly cap reached" @@ -8915,15 +9940,25 @@ class SaleOrder(models.Model): if new_followup <= today: new_followup = today + timedelta(days=1) - order.activity_schedule( - 'fusion_claims.mail_activity_type_mod_followup', - date_deadline=new_followup, - user_id=(order.user_id or self.env.user).id, - summary=f'MOD Follow-up: Call {order.partner_id.name or "client"} for funding update', - ) + # Create activity directly with automated=True to suppress + # Odoo's default "activity assigned" email notification. The + # activity still appears on the assignee's dashboard. + assignee_id = (order.user_id or self.env.user).id + self.env['mail.activity'].create({ + 'res_model': 'sale.order', + 'res_model_id': self.env['ir.model']._get('sale.order').id, + 'res_id': order.id, + 'activity_type_id': activity_type.id, + 'date_deadline': new_followup, + 'summary': f'MOD Follow-up: Call {order.partner_id.name or "client"} for funding update', + 'user_id': assignee_id, + 'automated': True, + }) order.with_context(skip_all_validations=True).write({ 'x_fc_mod_next_followup_date': new_followup, + 'x_fc_mod_followup_month_count': (order.x_fc_mod_followup_month_count or 0) + 1, }) + scheduled_count += 1 _logger.info(f"Scheduled MOD follow-up for {order.name} on {new_followup}") except Exception as e: _logger.error(f"Failed to schedule MOD follow-up for {order.name}: {e}") @@ -9198,3 +10233,368 @@ class SaleOrder(models.Model): _logger.info(f"ODSP submission email sent for {self.name} to {office_email}") except Exception as e: _logger.error(f"Failed to send ODSP submission email for {self.name}: {e}") + + # ========================================================================== + # SHARED FUNDER WORKFLOW EMAILS (WSIB / Insurance / MDC / Hardship) + # ========================================================================== + _FUNDER_LABELS = { + 'wsib': 'WSIB', + 'insurance': 'Insurance', + 'muscular_dystrophy': 'Muscular Dystrophy Canada', + 'hardship': 'Hardship Funding', + } + + def _funder_label(self): + self.ensure_one() + return self._FUNDER_LABELS.get(self.x_fc_sale_type, 'Funder') + + def _build_funder_case_rows(self): + """Factual case details for funder workflow emails.""" + self.ensure_one() + + def fmt(d): + return d.strftime('%B %d, %Y') if d else None + + product_summary = '' + if self.order_line: + product_summary = self.order_line[0].product_id.display_name or '' + if len(self.order_line) > 1: + product_summary += f' (+ {len(self.order_line) - 1} more)' + + rows = [ + ('Case', self.name), + ('Client', self.partner_id.name or 'N/A'), + ('Funder', self._funder_label()), + ('Product', product_summary or None), + ('Order Date', fmt(self.date_order.date() if self.date_order else None)), + ] + + if self._is_wsib_sale(): + rows.extend([ + ('WSIB Claim #', self.x_fc_wsib_claim_number or None), + ('Adjudicator', self.x_fc_wsib_adjudicator_name or None), + ('Approval Date', fmt(self.x_fc_wsib_approval_date)), + ]) + elif self._is_insurance_sale(): + rows.extend([ + ('Insurance Company', self.x_fc_insurance_company_id.name if self.x_fc_insurance_company_id else None), + ('Policy #', self.x_fc_insurance_policy_number or None), + ('Claim #', self.x_fc_insurance_claim_number or None), + ('Pre-Auth Expiry', fmt(self.x_fc_insurance_pre_auth_expiry)), + ]) + elif self._is_mdc_sale(): + rows.extend([ + ('MDC Client ID', self.x_fc_mdc_client_id_number or None), + ('PO Number', self.x_fc_mdc_po_number or None), + ('PO Date', fmt(self.x_fc_mdc_po_date)), + ]) + elif self._is_hardship_sale(): + rows.extend([ + ('Funder', self.x_fc_hardship_funder_id.name if self.x_fc_hardship_funder_id else None), + ('Approval Date', fmt(self.x_fc_hardship_approval_date)), + ]) + + if self.x_fc_adp_delivery_date: + rows.append(('Delivery Date', fmt(self.x_fc_adp_delivery_date))) + + return [(l, v) for l, v in rows if v] + + def _funder_is_funded_sale(self): + self.ensure_one() + return self.x_fc_sale_type in self._FUNDER_LABELS + + def _send_funder_email(self, recipient, milestone, email_type, title, summary, + attachment_ids=None, attachments_note=None): + """Unified sender for funder workflow emails. + + recipient: 'client' | 'authorizer' + milestone: short string for chatter log / subject (e.g. 'Funding Approved') + """ + self.ensure_one() + if not self._is_email_notifications_enabled(): + return False + if not self._funder_is_funded_sale(): + return False + + client = self.partner_id + authorizer = self.x_fc_authorizer_id + sales_rep = self.user_id + + if recipient == 'client': + if not client or not client.email: + return False + to_email = client.email + cc_list = [] + if authorizer and authorizer.email: + cc_list.append(authorizer.email) + if sales_rep and sales_rep.email: + cc_list.append(sales_rep.email) + elif recipient == 'authorizer': + if not authorizer or not authorizer.email: + return False + to_email = authorizer.email + cc_list = [] + if sales_rep and sales_rep.email: + cc_list.append(sales_rep.email) + else: + return False + + cc_list.extend(self._get_office_cc_emails()) + email_cc = ', '.join(cc_list) if cc_list else '' + + body_html = self._email_build( + title=title, + summary=summary, + email_type=email_type, + sections=[('Case Details', self._build_funder_case_rows())], + attachments_note=attachments_note, + sender_name=(sales_rep.name if sales_rep else self.env.user.name), + ) + + client_name = client.name or 'Client' + subject = f'{milestone} - {client_name} - {self.name}' + + try: + mail_vals = { + 'subject': subject, + 'body_html': body_html, + 'email_to': to_email, + 'email_cc': email_cc, + 'model': 'sale.order', + 'res_id': self.id, + } + if attachment_ids: + mail_vals['attachment_ids'] = [(6, 0, attachment_ids)] + self.env['mail.mail'].sudo().create(mail_vals).send() + self._email_chatter_log( + f'{milestone} email sent to {recipient}', to_email, email_cc, + ) + return True + except Exception as e: + _logger.error(f"Failed to send {milestone} email ({recipient}) for {self.name}: {e}") + return False + + def _get_funder_approval_attachments(self): + """Return attachment IDs for the approval letter, if any, based on sale type.""" + self.ensure_one() + if self._is_wsib_sale() and self.x_fc_wsib_approval_letter: + return self._get_and_prepare_field_attachment( + 'x_fc_wsib_approval_letter', 'WSIB Approval Letter') + if self._is_insurance_sale() and self.x_fc_insurance_approval_letter: + return self._get_and_prepare_field_attachment( + 'x_fc_insurance_approval_letter', 'Insurance Approval Letter') + if self._is_mdc_sale() and self.x_fc_mdc_po_document: + return self._get_and_prepare_field_attachment( + 'x_fc_mdc_po_document', 'MDC Purchase Order') + if self._is_hardship_sale() and self.x_fc_hardship_approval_letter: + return self._get_and_prepare_field_attachment( + 'x_fc_hardship_approval_letter', 'Hardship Approval Letter') + return False + + # --- Client-facing --------------------------------------------------------- + def _send_funder_package_ready_client_email(self): + """Quotation + application package prepared. Sent to client.""" + self.ensure_one() + client_name = self.partner_id.name or 'Client' + funder = self._funder_label() + return self._send_funder_email( + recipient='client', + milestone='Quotation & Application Package', + email_type='info', + title='Quotation & Application Package', + summary=( + f'Your quotation and application package for {funder} ' + f'has been prepared.' + ), + ) + + def _send_funder_approval_client_email(self): + """Funding approved. Sent to client.""" + self.ensure_one() + funder = self._funder_label() + att = self._get_funder_approval_attachments() + att_ids = [att] if att else None + att_note = 'Approval Letter (PDF)' if att else None + return self._send_funder_email( + recipient='client', + milestone=f'{funder} Approved', + email_type='success', + title='Funding Approved', + summary=( + f'Your funding application with {funder} has been approved.' + ), + attachment_ids=att_ids, + attachments_note=att_note, + ) + + def _send_funder_delivered_client_email(self): + """Product delivered. Sent to client.""" + self.ensure_one() + return self._send_funder_email( + recipient='client', + milestone='Delivery Completed', + email_type='info', + title='Product Delivered', + summary='Your equipment has been delivered.', + ) + + def _send_funder_case_closed_client_email(self): + """Case closed. Sent to client.""" + self.ensure_one() + return self._send_funder_email( + recipient='client', + milestone='Case Closed', + email_type='info', + title='Case Closed', + summary='Your case has been closed.', + ) + + def _send_funder_denial_client_email(self): + """Case denied. Sent to client.""" + self.ensure_one() + funder = self._funder_label() + return self._send_funder_email( + recipient='client', + milestone='Case Denied', + email_type='urgent', + title='Case Denied', + summary=( + f'Your funding application with {funder} has been denied.' + ), + ) + + # --- Authorizer-facing ----------------------------------------------------- + def _send_funder_package_ready_authorizer_email(self): + """Package ready. Sent to authorizer (only when they submit).""" + self.ensure_one() + client_name = self.partner_id.name or 'your client' + funder = self._funder_label() + return self._send_funder_email( + recipient='authorizer', + milestone='Application Package', + email_type='info', + title='Application Package', + summary=( + f'Application package prepared for {funder} ' + f'regarding your client {client_name}.' + ), + ) + + def _send_funder_approval_authorizer_email(self): + """Funding approved. Sent to authorizer.""" + self.ensure_one() + client_name = self.partner_id.name or 'your client' + funder = self._funder_label() + att = self._get_funder_approval_attachments() + att_ids = [att] if att else None + att_note = 'Approval Letter (PDF)' if att else None + return self._send_funder_email( + recipient='authorizer', + milestone=f'{funder} Approved (Authorizer)', + email_type='success', + title='Funding Approved', + summary=( + f'Funding has been approved by {funder} ' + f'for your client {client_name}.' + ), + attachment_ids=att_ids, + attachments_note=att_note, + ) + + def _send_funder_delivered_authorizer_email(self): + """Delivery complete. Sent to authorizer.""" + self.ensure_one() + client_name = self.partner_id.name or 'your client' + funder = self._funder_label() + return self._send_funder_email( + recipient='authorizer', + milestone='Delivery Completed (Authorizer)', + email_type='info', + title='Product Delivered', + summary=( + f'The equipment has been delivered to your client ' + f'{client_name} (funded by {funder}).' + ), + ) + + def _send_funder_denial_authorizer_email(self): + """Case denied. Sent to authorizer.""" + self.ensure_one() + client_name = self.partner_id.name or 'your client' + funder = self._funder_label() + return self._send_funder_email( + recipient='authorizer', + milestone='Case Denied (Authorizer)', + email_type='urgent', + title='Case Denied', + summary=( + f'The funding application with {funder} ' + f'for your client {client_name} has been denied.' + ), + ) + + # ========================================================================== + # Status-change dispatch helpers for funder workflows + # ========================================================================== + # Trigger map: new_status -> list of methods to call. + # Keep to key decision points only — see the 2026-04 email policy. + _WSIB_EMAIL_TRIGGERS = { + 'documents_ready': ['_send_funder_package_ready_client_email', + '_send_funder_package_ready_authorizer_email'], + 'pre_approved': ['_send_funder_approval_client_email', + '_send_funder_approval_authorizer_email'], + 'delivered': ['_send_funder_delivered_client_email', + '_send_funder_delivered_authorizer_email'], + 'case_closed': ['_send_funder_case_closed_client_email'], + 'denied': ['_send_funder_denial_client_email', + '_send_funder_denial_authorizer_email'], + } + _INSURANCE_EMAIL_TRIGGERS = { + 'documents_ready': ['_send_funder_package_ready_client_email'], + 'approval_received': ['_send_funder_approval_client_email', + '_send_funder_approval_authorizer_email'], + 'pre_auth_approved': ['_send_funder_approval_client_email', + '_send_funder_approval_authorizer_email'], + 'delivered': ['_send_funder_delivered_client_email', + '_send_funder_delivered_authorizer_email'], + 'case_closed': ['_send_funder_case_closed_client_email'], + 'denied': ['_send_funder_denial_client_email', + '_send_funder_denial_authorizer_email'], + } + _MDC_EMAIL_TRIGGERS = { + 'documents_ready': ['_send_funder_package_ready_client_email', + '_send_funder_package_ready_authorizer_email'], + 'po_received': ['_send_funder_approval_client_email', + '_send_funder_approval_authorizer_email'], + 'delivered': ['_send_funder_delivered_client_email', + '_send_funder_delivered_authorizer_email'], + 'case_closed': ['_send_funder_case_closed_client_email'], + 'denied': ['_send_funder_denial_client_email', + '_send_funder_denial_authorizer_email'], + } + _HARDSHIP_EMAIL_TRIGGERS = { + 'application_package_ready': ['_send_funder_package_ready_client_email', + '_send_funder_package_ready_authorizer_email'], + 'approval_received': ['_send_funder_approval_client_email', + '_send_funder_approval_authorizer_email'], + 'delivered': ['_send_funder_delivered_client_email', + '_send_funder_delivered_authorizer_email'], + 'case_closed': ['_send_funder_case_closed_client_email'], + 'denied': ['_send_funder_denial_client_email', + '_send_funder_denial_authorizer_email'], + 'eligibility_failed': ['_send_funder_denial_client_email', + '_send_funder_denial_authorizer_email'], + } + + def _fire_funder_emails(self, trigger_map, new_status): + """Fire all emails mapped to new_status for this order.""" + self.ensure_one() + method_names = trigger_map.get(new_status, []) + for method_name in method_names: + try: + getattr(self, method_name)() + except Exception as e: + _logger.error( + "Funder email %s failed for %s at status %s: %s", + method_name, self.name, new_status, e, + ) diff --git a/fusion_claims/report/invoice_report_landscape.xml b/fusion_claims/report/invoice_report_landscape.xml index b64231ac..286cf1e4 100644 --- a/fusion_claims/report/invoice_report_landscape.xml +++ b/fusion_claims/report/invoice_report_landscape.xml @@ -12,12 +12,16 @@ - + + + + + diff --git a/fusion_claims/report/report_accessibility_contract.xml b/fusion_claims/report/report_accessibility_contract.xml index cf19654a..2f8d124d 100644 --- a/fusion_claims/report/report_accessibility_contract.xml +++ b/fusion_claims/report/report_accessibility_contract.xml @@ -12,19 +12,22 @@ - + + + +
diff --git a/fusion_claims/report/report_mod_quotation.xml b/fusion_claims/report/report_mod_quotation.xml index 417b09aa..3cb8cf5a 100644 --- a/fusion_claims/report/report_mod_quotation.xml +++ b/fusion_claims/report/report_mod_quotation.xml @@ -9,21 +9,25 @@ + + + +
diff --git a/fusion_claims/report/report_proof_of_delivery.xml b/fusion_claims/report/report_proof_of_delivery.xml index 355b90d2..cb29311c 100644 --- a/fusion_claims/report/report_proof_of_delivery.xml +++ b/fusion_claims/report/report_proof_of_delivery.xml @@ -12,12 +12,16 @@ - + + + + + diff --git a/fusion_claims/report/sale_report_landscape.xml b/fusion_claims/report/sale_report_landscape.xml index 8eaecb2f..207ccf1d 100644 --- a/fusion_claims/report/sale_report_landscape.xml +++ b/fusion_claims/report/sale_report_landscape.xml @@ -12,12 +12,16 @@ - + + + + +