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(
+ '
'
+ '
Reassessment Order Created
'
+ f'
This order was created from {self.name} '
+ f'({prev_label}) for reassessment by the authorizer.
'
+ '
The authorizer should complete a new assessment before the application is re-submitted.
'
+ '
'
+ ),
+ message_type='notification',
+ subtype_xmlid='mail.mt_note',
)
- return True
+ self.message_post(
+ body=Markup(
+ ''
+ '
Reassessment Order Created
'
+ f'
A new order {new_order.name} '
+ 'was created for reassessment of this case.
'
+ '
'
+ ),
+ 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 @@
-
+
+
+
+
+