fusion_claims: MOD workflow rework — two-assessment split, 3 submission paths, recovery actions (v19.0.8.0.3)

Reworks the March of Dimes workflow to match reality: the OT does their own
disability assessment and provides the VOD letter; our accessibility specialist
then visits to produce the proposal/drawings/quote; and the application can be
submitted by us (internal), the client, or the authorizer themselves. The old
workflow flattened all this into one assessment state with a dead-end
funding_denied and no document tracking.

Data model (13 new sale.order fields):
- 5 new document binaries + filenames: VOD letter, Application Form (filled),
  Notice of Assessment, Property Tax, Proposal Document
- x_fc_mod_submitted_by Selection (internal/client/authorizer)
- x_fc_mod_handoff_date, x_fc_mod_vod_requested_date
- x_fc_mod_accessibility_specialist_id (m2o res.partner — internal or external)
- x_fc_mod_previous_status_before_hold (for proper resume)
- x_fc_mod_funding_denial_reason (captured via wizard)

Settings (4 res.company fields + res_config_settings mirrors):
- x_fc_mod_application_form (blank) + filename
- x_fc_mod_vod_form (blank) + filename
- x_fc_mod_followup_assignee_mode (office_contact / sales_rep)
- x_fc_mod_followup_office_contact_id

res.partner: added 'accessibility_specialist' to x_fc_contact_type.

State machine:
- New state handoff_to_client between quote_submitted and awaiting_funding,
  used for paths B/C (client or authorizer submits themselves)
- Fixed action_mod_on_hold to save x_fc_mod_previous_status_before_hold
- Fixed action_mod_resume to restore previous status (was hardcoded to
  in_production, losing context for cases held earlier)

4 new wizards:
- mod_submission_path_wizard — chooses submitted_by, auto-fires VOD request
  email on first switch to 'internal'
- mod_funding_denied_wizard — captures denial category + reason
- mod_resubmit_wizard — revises + resubmits denied cases (with optional
  doc clearing)
- mod_submission_confirmed_wizard — records client/authorizer confirmed
  submission, advances to awaiting_funding

8 new action methods:
- action_mod_set_submission_path, action_mod_request_vod,
  action_mod_handoff_to_client (validates docs, fires handoff email),
  action_mod_confirmed_submission, action_mod_resubmit_from_denied,
  action_mod_cancel_from_denied, action_mod_reopen_cancelled
- action_mod_funding_denied now opens the denial wizard

3 new email methods + 2 existing fixes:
- _send_mod_vod_request_email — auto-attaches blank VOD form from company
  settings, sent to authorizer when we are handling submission
- _send_mod_handoff_email — two templates (client vs authorizer), attaches
  proposal + drawing + blank MOD Application Form
- _mod_company_attachment helper for building attachments from company Binary
- Fixed _send_mod_assessment_completed_email to include authorizer
- Fixed _send_mod_pod_submitted_email to include client

New cron:
- _cron_mod_handoff_followup (daily 09:00) — creates mail.activity for office
  to confirm MOD submission. Assignee via company setting (office contact or
  sales rep). Uses existing rolling-window cap (2/month per order).

Views:
- sale_order form: new status-bar buttons (set path, request VOD, handoff,
  confirm, resubmit, cancel, reopen), new document section in MOD Documents
  tab with submission-path tracking, denial details, hold history
- res_config_settings: new MOD blank forms upload + assignee config

Deployed to odoo-westin (westin-v19) and odoo-mobility (mobility). Pre-deploy
FK cleanup from earlier session means mobility updated cleanly without
workaround. HTTP 200 on both, cron verified active, all new fields present.
This commit is contained in:
gsinghpal
2026-04-09 07:34:17 -04:00
parent 8b2cbd9085
commit 0fe8a71c05
17 changed files with 1390 additions and 11 deletions

View File

@@ -5,7 +5,7 @@
{ {
'name': 'Fusion Claims', 'name': 'Fusion Claims',
'version': '19.0.8.0.2', 'version': '19.0.8.0.3',
'category': 'Sales', 'category': 'Sales',
'summary': 'Complete ADP Claims Management with Dashboard, Sales Integration, Billing Automation, and Two-Stage Verification.', 'summary': 'Complete ADP Claims Management with Dashboard, Sales Integration, Billing Automation, and Two-Stage Verification.',
'description': """ 'description': """
@@ -122,6 +122,10 @@
'wizard/mod_awaiting_funding_wizard_views.xml', 'wizard/mod_awaiting_funding_wizard_views.xml',
'wizard/mod_funding_approved_wizard_views.xml', 'wizard/mod_funding_approved_wizard_views.xml',
'wizard/mod_pca_received_wizard_views.xml', 'wizard/mod_pca_received_wizard_views.xml',
'wizard/mod_submission_path_wizard_views.xml',
'wizard/mod_funding_denied_wizard_views.xml',
'wizard/mod_resubmit_wizard_views.xml',
'wizard/mod_submission_confirmed_wizard_views.xml',
'wizard/odsp_sa_mobility_wizard_views.xml', 'wizard/odsp_sa_mobility_wizard_views.xml',
'wizard/odsp_discretionary_wizard_views.xml', 'wizard/odsp_discretionary_wizard_views.xml',
'wizard/odsp_submit_to_odsp_wizard_views.xml', 'wizard/odsp_submit_to_odsp_wizard_views.xml',

View File

@@ -136,6 +136,22 @@
<field name="nextcall" eval="DateTime.now().replace(hour=2, minute=0, second=0)"/> <field name="nextcall" eval="DateTime.now().replace(hour=2, minute=0, second=0)"/>
</record> </record>
<!-- Cron Job: MOD Handoff Follow-up (2026-04 update)
Creates a mail.activity for the office to call the client or
authorizer to confirm they have submitted the MOD application.
Uses the rolling-window cap (shared with existing MOD follow-up)
so activities are spaced out, max 2 per 30-day window per order. -->
<record id="ir_cron_mod_handoff_followup" model="ir.cron">
<field name="name">Fusion Claims: MOD Handoff Follow-up</field>
<field name="model_id" ref="sale.model_sale_order"/>
<field name="state">code</field>
<field name="code">model._cron_mod_handoff_followup()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active">True</field>
<field name="nextcall" eval="DateTime.now().replace(hour=9, minute=0, second=0)"/>
</record>
<!-- Cron Job: ADP 12-month auto-expire for approved applications. <!-- Cron Job: ADP 12-month auto-expire for approved applications.
Approved / approved_deduction orders past the configured funding Approved / approved_deduction orders past the configured funding
window (fusion_claims.adp_approval_expiry_months, default 12 months) window (fusion_claims.adp_approval_expiry_months, default 12 months)

View File

@@ -62,8 +62,63 @@ class ResCompany(models.Model):
help='Contacts who will receive a copy (CC) of all automated ADP notifications', help='Contacts who will receive a copy (CC) of all automated ADP notifications',
) )
# ==========================================================================
# MARCH OF DIMES SETTINGS (shared blank forms + follow-up assignment)
# Added 2026-04 MOD update. Latest revision of the MOD-issued blank forms
# lives here so every case can auto-attach them to outgoing emails.
# ==========================================================================
x_fc_mod_application_form = fields.Binary(
string='MOD Application Form (blank)',
attachment=True,
help='Blank March of Dimes Application Form (latest MOD revision). '
'Auto-attached to the handoff email sent to the client or authorizer '
'when they will be submitting the application themselves.',
)
x_fc_mod_application_form_filename = fields.Char(
string='MOD Application Form Filename',
)
x_fc_mod_vod_form = fields.Binary(
string='Verification of Disability Form (blank)',
attachment=True,
help='Blank March of Dimes Verification of Disability form. '
'Auto-attached to the VOD request email sent to the authorizer when '
'our office is handling the MOD submission internally.',
)
x_fc_mod_vod_form_filename = fields.Char(
string='VOD Form Filename',
)
x_fc_mod_followup_assignee_mode = fields.Selection(
selection=[
('office_contact', 'Designated Office Contact'),
('sales_rep', 'Order Sales Rep'),
],
string='MOD Follow-up Assignee',
default='office_contact',
help='Who gets the follow-up activity when a client or authorizer is '
'handling the MOD submission themselves. Office contact = one '
'designated person handles all follow-ups. Sales rep = the rep '
'on each order is responsible for their own follow-ups.',
)
x_fc_mod_followup_office_contact_id = fields.Many2one(
'res.users',
string='MOD Follow-up Office Contact',
help='The office user who receives MOD handoff follow-up activities '
'when assignee mode is set to Office Contact. Only used when '
'x_fc_mod_followup_assignee_mode == office_contact.',
)
def _get_cheque_payable_name(self): def _get_cheque_payable_name(self):
"""Get the name for cheque payments, defaulting to company name.""" """Get the name for cheque payments, defaulting to company name."""
self.ensure_one() self.ensure_one()
return self.x_fc_cheque_payable_to or self.name return self.x_fc_cheque_payable_to or self.name
def _get_mod_followup_assignee(self, sale_order):
"""Return the res.users who should receive the MOD handoff follow-up
activity for the given order, based on this company's configuration.
Falls back to the order's sales rep when the office contact is missing.
"""
self.ensure_one()
if self.x_fc_mod_followup_assignee_mode == 'office_contact' and self.x_fc_mod_followup_office_contact_id:
return self.x_fc_mod_followup_office_contact_id
return sale_order.user_id or self.env.user

View File

@@ -59,6 +59,38 @@ class ResConfigSettings(models.TransientModel):
string='Refund Policy', string='Refund Policy',
) )
# =========================================================================
# MARCH OF DIMES SETTINGS (added 2026-04 MOD workflow update)
# =========================================================================
fc_mod_application_form = fields.Binary(
related='company_id.x_fc_mod_application_form',
readonly=False,
string='MOD Application Form (blank)',
)
fc_mod_application_form_filename = fields.Char(
related='company_id.x_fc_mod_application_form_filename',
readonly=False,
)
fc_mod_vod_form = fields.Binary(
related='company_id.x_fc_mod_vod_form',
readonly=False,
string='Verification of Disability Form (blank)',
)
fc_mod_vod_form_filename = fields.Char(
related='company_id.x_fc_mod_vod_form_filename',
readonly=False,
)
fc_mod_followup_assignee_mode = fields.Selection(
related='company_id.x_fc_mod_followup_assignee_mode',
readonly=False,
string='MOD Follow-up Assignee',
)
fc_mod_followup_office_contact_id = fields.Many2one(
related='company_id.x_fc_mod_followup_office_contact_id',
readonly=False,
string='Office Follow-up Contact',
)
# ========================================================================= # =========================================================================
# ADP BILLING SETTINGS # ADP BILLING SETTINGS
# ========================================================================= # =========================================================================

View File

@@ -26,6 +26,7 @@ class ResPartner(models.Model):
('muscular_dystrophy', 'Muscular Dystrophy'), ('muscular_dystrophy', 'Muscular Dystrophy'),
('occupational_therapist', 'Occupational Therapist'), ('occupational_therapist', 'Occupational Therapist'),
('physiotherapist', 'Physiotherapist'), ('physiotherapist', 'Physiotherapist'),
('accessibility_specialist', 'Accessibility Specialist'),
('vendor', 'Vendor'), ('vendor', 'Vendor'),
('funding_agency', 'Funding Agency'), ('funding_agency', 'Funding Agency'),
('government_agency', 'Government Agency'), ('government_agency', 'Government Agency'),

View File

@@ -432,6 +432,7 @@ class SaleOrder(models.Model):
('contract_received', 'PCA Received'), ('contract_received', 'PCA Received'),
('in_production', 'In Production'), ('in_production', 'In Production'),
('project_complete', 'Complete'), ('project_complete', 'Complete'),
('handoff_to_client', 'Handed Off (Client/Authorizer Submitting)'),
('pod_submitted', 'POD Sent'), ('pod_submitted', 'POD Sent'),
('case_closed', 'Closed'), ('case_closed', 'Closed'),
('on_hold', 'On Hold'), ('on_hold', 'On Hold'),
@@ -441,7 +442,9 @@ class SaleOrder(models.Model):
default='need_to_schedule', default='need_to_schedule',
tracking=True, tracking=True,
group_expand='_expand_mod_statuses', group_expand='_expand_mod_statuses',
help='March of Dimes case workflow status', help='March of Dimes case workflow status. handoff_to_client means we '
'have given the proposal+quote+drawing to the client or authorizer '
'and are waiting for them to submit the application to MOD.',
) )
@api.model @api.model
@@ -598,6 +601,108 @@ class SaleOrder(models.Model):
) )
x_fc_mod_completion_photos_filename = fields.Char(string='Completion Photos Filename') x_fc_mod_completion_photos_filename = fields.Char(string='Completion Photos Filename')
# ==========================================================================
# MOD DOCUMENTS — added 2026-04 workflow update
# Captures the full application package when WE submit to MOD on behalf
# of the client. The first 4 are required for internal submission; the
# proposal is the narrative document separate from the generated quotation PDF.
# ==========================================================================
x_fc_mod_vod_letter = fields.Binary(
string='Verification of Disability Letter',
attachment=True,
copy=False,
help='OT-signed Verification of Disability letter. Either on the OT\'s '
'letterhead or on the MOD-issued VOD form. Required before internal '
'submission to MOD.',
)
x_fc_mod_vod_letter_filename = fields.Char(string='VOD Letter Filename', copy=False)
x_fc_mod_application_form_doc = fields.Binary(
string='MOD Application Form (filled)',
attachment=True,
copy=False,
help='The filled-out and signed March of Dimes application form.',
)
x_fc_mod_application_form_doc_filename = fields.Char(string='Application Form Filename', copy=False)
x_fc_mod_notice_of_assessment = fields.Binary(
string='Notice of Assessment',
attachment=True,
copy=False,
help='CRA Notice of Assessment — required by MOD as proof of income.',
)
x_fc_mod_notice_of_assessment_filename = fields.Char(string='Notice of Assessment Filename', copy=False)
x_fc_mod_property_tax = fields.Binary(
string='Property Tax Bill',
attachment=True,
copy=False,
help='Property tax bill — required by MOD as proof of residency.',
)
x_fc_mod_property_tax_filename = fields.Char(string='Property Tax Filename', copy=False)
x_fc_mod_proposal_doc = fields.Binary(
string='Proposal Document',
attachment=True,
copy=False,
help='Our narrative proposal document describing the accessibility project. '
'Separate from the generated quotation PDF.',
)
x_fc_mod_proposal_doc_filename = fields.Char(string='Proposal Document Filename', copy=False)
# ==========================================================================
# MOD SUBMISSION PATH — who is submitting the application to March of Dimes?
# ==========================================================================
x_fc_mod_submitted_by = fields.Selection(
selection=[
('internal', 'We submit on client\'s behalf'),
('client', 'Client submits themselves'),
('authorizer', 'Authorizer (OT) submits'),
],
string='Application Submitted By',
copy=False,
tracking=True,
help='Who is submitting the MOD application. Determines which document '
'gates apply and whether we hand off materials to the client/OT.',
)
x_fc_mod_handoff_date = fields.Date(
string='Handoff Date',
copy=False,
tracking=True,
help='Date we handed the application package to the client or authorizer '
'for them to submit to MOD themselves.',
)
# NOTE: x_fc_mod_application_submitted_date already exists at line ~865 —
# we reuse it for the "MOD actually received the application" date across
# all 3 submission paths (internal/client/authorizer).
x_fc_mod_accessibility_specialist_id = fields.Many2one(
'res.partner',
string='Accessibility Specialist',
tracking=True,
help='Internal employee or external contractor who performed the '
'accessibility assessment visit. Can be a contact tagged as '
'"Accessibility Specialist" in the contact type.',
)
x_fc_mod_vod_requested_date = fields.Date(
string='VOD Request Sent',
copy=False,
help='Date we auto-emailed the authorizer the blank VOD form to fill out. '
'Set by action_mod_request_vod.',
)
x_fc_mod_previous_status_before_hold = fields.Char(
string='MOD Status Before Hold',
copy=False,
help='Previous MOD status before the case was put on hold. Used by '
'action_mod_resume to restore the correct state.',
)
x_fc_mod_funding_denial_reason = fields.Text(
string='Funding Denial Reason',
copy=False,
tracking=True,
help='Reason provided when MOD denied funding for this case. Captured '
'by mod_funding_denied_wizard.',
)
# Trail computed fields for MOD documents # Trail computed fields for MOD documents
x_fc_mod_trail_has_drawing = fields.Boolean(compute='_compute_mod_trail', string='Has Drawing') x_fc_mod_trail_has_drawing = fields.Boolean(compute='_compute_mod_trail', string='Has Drawing')
x_fc_mod_trail_has_initial_photos = fields.Boolean(compute='_compute_mod_trail', string='Has Initial Photos') x_fc_mod_trail_has_initial_photos = fields.Boolean(compute='_compute_mod_trail', string='Has Initial Photos')
@@ -7540,8 +7645,21 @@ class SaleOrder(models.Model):
} }
def action_mod_funding_denied(self): def action_mod_funding_denied(self):
"""Open the funding denied wizard to capture denial reason + category.
2026-04 MOD update — was previously a bare write with no reason
capture. The wizard records the category (income/residency/scope/etc),
free-text reason, and fires the existing funding_denied email.
"""
self.ensure_one() self.ensure_one()
self.write({'x_fc_mod_status': 'funding_denied'}) return {
'type': 'ir.actions.act_window',
'name': 'MOD Funding Denied',
'res_model': 'fusion_claims.mod.funding.denied.wizard',
'view_mode': 'form',
'target': 'new',
'context': {'active_id': self.id},
}
def action_mod_contract_received(self): def action_mod_contract_received(self):
"""Open wizard to upload PCA document and record receipt.""" """Open wizard to upload PCA document and record receipt."""
@@ -7593,13 +7711,178 @@ class SaleOrder(models.Model):
}) })
def action_mod_on_hold(self): def action_mod_on_hold(self):
"""Put the MOD case on hold, remembering the previous state for resume.
2026-04 fix — previously lost the previous state so action_mod_resume
was hardcoded to in_production. Now saves the current state and
action_mod_resume restores it properly.
"""
self.ensure_one() self.ensure_one()
self.write({'x_fc_mod_status': 'on_hold'}) if self.x_fc_mod_status in ('on_hold', 'case_closed', 'cancelled'):
raise UserError(_(
"This case cannot be put on hold from its current status (%s)."
) % self.x_fc_mod_status)
self.write({
'x_fc_mod_status': 'on_hold',
'x_fc_mod_previous_status_before_hold': self.x_fc_mod_status,
})
def action_mod_resume(self): def action_mod_resume(self):
"""Resume from on_hold - go back to in_production.""" """Resume from on_hold — restore the previous status.
2026-04 fix — previously hardcoded to 'in_production' which was
destructive for cases that were held earlier in the workflow. Now
uses x_fc_mod_previous_status_before_hold to restore the exact
state the case was in before the hold.
"""
self.ensure_one() self.ensure_one()
self.write({'x_fc_mod_status': 'in_production'}) if self.x_fc_mod_status != 'on_hold':
raise UserError(_("Only held cases can be resumed."))
previous = self.x_fc_mod_previous_status_before_hold or 'in_production'
self.write({
'x_fc_mod_status': previous,
'x_fc_mod_previous_status_before_hold': False,
})
labels = dict(self._fields['x_fc_mod_status'].selection)
self.message_post(
body=Markup(
'<div class="alert alert-success" role="alert">'
'<strong><i class="fa fa-play-circle"></i> Case resumed</strong> — '
f'restored to <strong>{labels.get(previous, previous)}</strong>'
'</div>'
),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
# ==========================================================================
# MOD 2026-04 UPDATE — new submission-path actions + recovery actions
# ==========================================================================
def action_mod_set_submission_path(self):
"""Open the wizard asking who will submit the MOD application."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Set MOD Submission Path',
'res_model': 'fusion_claims.mod.submission.path.wizard',
'view_mode': 'form',
'target': 'new',
'context': {'active_id': self.id},
}
def action_mod_request_vod(self):
"""Email the authorizer the blank VOD form to complete + return.
Fired automatically the first time submitted_by is set to 'internal',
and also available as a manual button for follow-up requests.
"""
self.ensure_one()
if not self.x_fc_authorizer_id or not self.x_fc_authorizer_id.email:
raise UserError(_(
"No authorizer with an email address is set on this order. "
"Please assign an authorizer before requesting the VOD form."
))
self._send_mod_vod_request_email()
return True
def action_mod_handoff_to_client(self):
"""Move a MOD order to 'handoff_to_client' and email the package
to whoever is going to submit (client or authorizer).
Requires x_fc_mod_submitted_by in ('client', 'authorizer') and the
proposal + drawing + quotation to be present. The blank MOD
application form from company settings is attached to the email
so the submitter has everything in one place.
"""
self.ensure_one()
if self.x_fc_mod_submitted_by not in ('client', 'authorizer'):
raise UserError(_(
"Handoff is only valid when submitted_by is 'client' or 'authorizer'. "
"Use 'Set MOD Submission Path' to choose first."
))
missing = []
if not self.x_fc_mod_proposal_doc:
missing.append(_("Proposal Document"))
if not self.x_fc_mod_drawing:
missing.append(_("Drawing"))
if missing:
raise UserError(_(
"Cannot hand off without: %s. Upload the missing documents "
"on the MOD Documents section of the order."
) % ", ".join(missing))
self.with_context(skip_status_emails=True).write({
'x_fc_mod_status': 'handoff_to_client',
'x_fc_mod_handoff_date': fields.Date.today(),
})
try:
self._send_mod_handoff_email()
except Exception as e:
_logger.error(f"action_mod_handoff_to_client: email failed for {self.name}: {e}")
return True
def action_mod_confirmed_submission(self):
"""Open wizard to record that client/authorizer submitted the app."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Confirm MOD Submission',
'res_model': 'fusion_claims.mod.submission.confirmed.wizard',
'view_mode': 'form',
'target': 'new',
'context': {'active_id': self.id},
}
def action_mod_resubmit_from_denied(self):
"""Open the resubmit wizard to revise + resubmit a denied case."""
self.ensure_one()
if self.x_fc_mod_status != 'funding_denied':
raise UserError(_("This action is only available for denied cases."))
return {
'type': 'ir.actions.act_window',
'name': 'Resubmit to MOD',
'res_model': 'fusion_claims.mod.resubmit.wizard',
'view_mode': 'form',
'target': 'new',
'context': {'active_id': self.id},
}
def action_mod_cancel_from_denied(self):
"""Cancel a denied MOD case."""
self.ensure_one()
if self.x_fc_mod_status != 'funding_denied':
raise UserError(_("This action is only available for denied cases."))
self.write({'x_fc_mod_status': 'cancelled'})
self.message_post(
body=Markup(
'<div class="alert alert-secondary" role="alert">'
'<strong><i class="fa fa-times"></i> MOD case cancelled after funding denial.</strong>'
'</div>'
),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
def action_mod_reopen_cancelled(self):
"""Reopen a cancelled MOD case back to the entry state."""
self.ensure_one()
if self.x_fc_mod_status != 'cancelled':
raise UserError(_("This action is only available for cancelled cases."))
self.write({
'x_fc_mod_status': 'need_to_schedule',
'x_fc_mod_funding_denial_reason': False,
})
self.message_post(
body=Markup(
'<div class="alert alert-info" role="alert">'
'<strong><i class="fa fa-refresh"></i> MOD case reopened.</strong> '
'Status reset to "Need to Schedule Assessment".'
'</div>'
),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
def action_cancel(self): def action_cancel(self):
"""Override: also set MOD status to cancelled when order is cancelled.""" """Override: also set MOD status to cancelled when order is cancelled."""
@@ -7870,7 +8153,12 @@ class SaleOrder(models.Model):
) )
def _send_mod_assessment_completed_email(self): def _send_mod_assessment_completed_email(self):
"""Email: Assessment completed. To: Client.""" """Email: Accessibility assessment completed. Client + Authorizer.
2026-04 email audit fix — authorizer was previously excluded. They
need to know our accessibility specialist has finished the visit so
they can expect the proposal/drawings for their VOD letter context.
"""
self.ensure_one() self.ensure_one()
client_name = self.partner_id.name or 'Client' client_name = self.partner_id.name or 'Client'
return self._send_mod_email( return self._send_mod_email(
@@ -7878,7 +8166,7 @@ class SaleOrder(models.Model):
title='Assessment Completed', title='Assessment Completed',
summary=f'The accessibility assessment for <strong>{client_name}</strong> has been completed.', summary=f'The accessibility assessment for <strong>{client_name}</strong> has been completed.',
email_type='success', email_type='success',
include_client=True, include_authorizer=False, include_client=True, include_authorizer=True,
note='<strong>Next steps:</strong> Our team is now preparing the drawings and quotation ' note='<strong>Next steps:</strong> Our team is now preparing the drawings and quotation '
'based on the assessment. We will send you the proposal once it is ready for review.', 'based on the assessment. We will send you the proposal once it is ready for review.',
note_color='#38a169', note_color='#38a169',
@@ -8003,7 +8291,12 @@ class SaleOrder(models.Model):
) )
def _send_mod_pod_submitted_email(self): def _send_mod_pod_submitted_email(self):
"""Email: Photos/POD submitted to MOD. To: MOD contact, CC: Authorizer.""" """Email: Photos/POD submitted to MOD. Client + Authorizer + MOD contact.
2026-04 email audit fix — client was previously excluded. POD
submission is a near-delivery milestone and the client should know
their case is wrapping up.
"""
self.ensure_one() self.ensure_one()
client_name = self.partner_id.name or 'Client' client_name = self.partner_id.name or 'Client'
return self._send_mod_email( return self._send_mod_email(
@@ -8011,7 +8304,7 @@ class SaleOrder(models.Model):
title='Proof of Delivery Submitted', title='Proof of Delivery Submitted',
summary=f'Photos and proof of delivery for <strong>{client_name}</strong> have been submitted.', summary=f'Photos and proof of delivery for <strong>{client_name}</strong> have been submitted.',
email_type='info', email_type='info',
include_client=False, include_authorizer=True, include_mod_contact=True, include_client=True, include_authorizer=True, include_mod_contact=True,
note='Please process the final payment (10%) as per the Payment Commitment Agreement terms.', note='Please process the final payment (10%) as per the Payment Commitment Agreement terms.',
) )
@@ -8046,6 +8339,313 @@ class SaleOrder(models.Model):
'If you experience any issues, please contact us immediately.', 'If you experience any issues, please contact us immediately.',
) )
# ==========================================================================
# MOD 2026-04 UPDATE — new email methods for submission-path workflow
# ==========================================================================
def _mod_company_attachment(self, field_name, filename_field, default_name):
"""Build an ir.attachment record from a Binary on res.company so it
can be attached to an outgoing mail.mail. Returns the attachment ID
list (empty if the company field is not set).
"""
self.ensure_one()
company = self.company_id or self.env.company
data = getattr(company, field_name, False)
if not data:
return []
filename = getattr(company, filename_field, False) or default_name
attachment = self.env['ir.attachment'].sudo().create({
'name': filename,
'datas': data,
'res_model': 'sale.order',
'res_id': self.id,
'type': 'binary',
})
return [attachment.id]
def _send_mod_vod_request_email(self):
"""Email the authorizer the blank VOD form for them to complete.
Fires when submitted_by is first set to 'internal' (via the submission
path wizard) and also available as a manual button (action_mod_request_vod).
Attaches the blank VOD form from company settings.
"""
self.ensure_one()
if not self._is_email_notifications_enabled():
return False
authorizer = self.x_fc_authorizer_id
if not authorizer or not authorizer.email:
_logger.warning(f"_send_mod_vod_request_email: no authorizer email for {self.name}")
return False
client_name = self.partner_id.name or 'Client'
sales_rep = self.user_id
sender_name = sales_rep.name if sales_rep else self.env.user.name
body_html = self._mod_email_build(
title='Verification of Disability Needed',
summary=(
f'Our office is handling the March of Dimes application for '
f'<strong>{client_name}</strong> on their behalf. To complete '
f'the submission we need the <strong>Verification of Disability</strong> '
f'signed by you.'
),
email_type='attention',
sections=[('Case Details', self._build_mod_case_detail_rows())],
note=(
'<strong>What we need:</strong> Please complete the attached '
'blank Verification of Disability form (or provide the same '
'information on your own letterhead) and send it back to us so '
'we can finalise the application submission to March of Dimes.'
),
note_color='#d69e2e',
sender_name=sender_name,
)
attachment_ids = self._mod_company_attachment(
'x_fc_mod_vod_form',
'x_fc_mod_vod_form_filename',
'Verification_of_Disability_Form.pdf',
)
cc = []
if sales_rep and sales_rep.email:
cc.append(sales_rep.email)
try:
mail_vals = {
'subject': f'Verification of Disability needed - {client_name} - {self.name}',
'body_html': body_html,
'email_to': authorizer.email,
'email_cc': ', '.join(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.with_context(skip_all_validations=True).write({
'x_fc_mod_vod_requested_date': fields.Date.today(),
})
self._email_chatter_log(
'VOD request email sent to authorizer',
authorizer.email,
', '.join(cc) if cc else None,
['VOD form attached' if attachment_ids else 'No VOD form configured on company'],
)
return True
except Exception as e:
_logger.error(f"Failed to send MOD VOD request for {self.name}: {e}")
return False
def _send_mod_handoff_email(self):
"""Email the MOD application package to whoever is submitting it
(client OR authorizer). Different subject/body depending on which
party. Attaches proposal + drawing + blank MOD Application Form.
"""
self.ensure_one()
if not self._is_email_notifications_enabled():
return False
if self.x_fc_mod_submitted_by not in ('client', 'authorizer'):
return False
client = self.partner_id
authorizer = self.x_fc_authorizer_id
sales_rep = self.user_id
client_name = client.name or 'Client'
sender_name = sales_rep.name if sales_rep else self.env.user.name
if self.x_fc_mod_submitted_by == 'client':
primary = client
subject_prefix = 'Your March of Dimes Application Package'
title = 'Your MOD Application Package'
summary = (
f'Hi {client_name}, please find attached everything you need to '
f'submit your application to March of Dimes: the proposal, drawings, '
f'quotation, and the blank MOD application form.'
)
note = (
'<strong>Next steps:</strong><ol>'
'<li>Review the proposal and quotation.</li>'
'<li>Complete the attached MOD Application Form.</li>'
'<li>Submit the completed package to March of Dimes.</li>'
'<li><strong>Call us</strong> once you have submitted so we can track the progress.</li>'
'</ol>'
)
cc_partners = [authorizer] if authorizer and authorizer.email else []
else:
primary = authorizer
subject_prefix = f'MOD Application Package for {client_name}'
title = f'MOD Application Package — {client_name}'
summary = (
f'Please find attached the full application package for '
f'<strong>{client_name}</strong>: proposal, drawings, quotation, '
f'and the blank MOD application form. You are handling the '
f'submission to March of Dimes on behalf of your client.'
)
note = (
'<strong>Please confirm with us</strong> once you have submitted '
'the application so we can advance the case in our system.'
)
cc_partners = [client] if client and client.email else []
if not primary or not primary.email:
_logger.warning(
f"_send_mod_handoff_email: no email for {self.x_fc_mod_submitted_by} on {self.name}"
)
return False
body_html = self._mod_email_build(
title=title,
summary=summary,
email_type='info',
sections=[('Case Details', self._build_mod_case_detail_rows())],
note=note,
sender_name=sender_name,
)
# Build attachments: our proposal + drawing + blank MOD Application Form.
attachment_ids = []
if self.x_fc_mod_proposal_doc:
att = self.env['ir.attachment'].sudo().create({
'name': self.x_fc_mod_proposal_doc_filename or 'Proposal.pdf',
'datas': self.x_fc_mod_proposal_doc,
'res_model': 'sale.order',
'res_id': self.id,
'type': 'binary',
})
attachment_ids.append(att.id)
if self.x_fc_mod_drawing:
att = self.env['ir.attachment'].sudo().create({
'name': self.x_fc_mod_drawing_filename or 'Drawing.pdf',
'datas': self.x_fc_mod_drawing,
'res_model': 'sale.order',
'res_id': self.id,
'type': 'binary',
})
attachment_ids.append(att.id)
attachment_ids.extend(self._mod_company_attachment(
'x_fc_mod_application_form',
'x_fc_mod_application_form_filename',
'MOD_Application_Form.pdf',
))
cc_emails = [p.email for p in cc_partners if p and p.email]
if sales_rep and sales_rep.email and sales_rep.email not in cc_emails:
cc_emails.append(sales_rep.email)
try:
mail_vals = {
'subject': f'{subject_prefix} - {self.name}',
'body_html': body_html,
'email_to': primary.email,
'email_cc': ', '.join(cc_emails),
'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'MOD handoff email sent ({self.x_fc_mod_submitted_by})',
primary.email,
', '.join(cc_emails) if cc_emails else None,
[f'{len(attachment_ids)} attachment(s)'],
)
return True
except Exception as e:
_logger.error(f"Failed to send MOD handoff email for {self.name}: {e}")
return False
def _cron_mod_handoff_followup(self):
"""Daily cron: create mail.activity follow-ups for MOD orders in
handoff_to_client state so the office remembers to call the client
and confirm whether they have submitted the application to MOD yet.
Assignee comes from company settings:
- x_fc_mod_followup_assignee_mode == 'office_contact' → the
designated office user
- x_fc_mod_followup_assignee_mode == 'sales_rep' → the order's
sales rep
Reuses the existing rolling-window cap logic via
x_fc_mod_followup_month_count so the office doesn't get spammed
with daily activities.
"""
from datetime import timedelta
ICP = self.env['ir.config_parameter'].sudo()
try:
max_per_month = int(ICP.get_param('fusion_claims.mod_followup_max_per_month', '2'))
except (TypeError, ValueError):
max_per_month = 2
try:
window_days = int(ICP.get_param('fusion_claims.mod_followup_window_days', '30'))
except (TypeError, ValueError):
window_days = 30
activity_type = self.env.ref(
'fusion_claims.mail_activity_type_mod_followup',
raise_if_not_found=False,
)
if not activity_type:
return
today = fields.Date.today()
orders = self.search([
('x_fc_sale_type', '=', 'march_of_dimes'),
('x_fc_mod_status', '=', 'handoff_to_client'),
])
for order in orders:
try:
# Respect the existing rolling-window cap (shared with the
# existing MOD follow-up cron).
start = order.x_fc_mod_followup_month_start
count = order.x_fc_mod_followup_month_count or 0
if start and (today - start).days < window_days and count >= max_per_month:
continue
if not start or (today - start).days >= window_days:
order.with_context(skip_all_validations=True).write({
'x_fc_mod_followup_month_start': today,
'x_fc_mod_followup_month_count': 0,
})
count = 0
# Don't double-schedule — skip if an open activity of our type
# already exists.
existing = self.env['mail.activity'].search([
('res_model', '=', 'sale.order'),
('res_id', '=', order.id),
('activity_type_id', '=', activity_type.id),
], limit=1)
if existing:
continue
company = order.company_id or self.env.company
assignee = company._get_mod_followup_assignee(order)
if not assignee:
continue
client_name = order.partner_id.name or 'client'
handoff_date = order.x_fc_mod_handoff_date
days_since = (today - handoff_date).days if handoff_date else None
summary = f'MOD Follow-up: call {client_name} — confirm application submission'
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,
)
order.with_context(skip_all_validations=True).write({
'x_fc_mod_followup_month_count': count + 1,
})
_logger.info(
f"MOD handoff follow-up scheduled for {order.name} "
f"{assignee.name}"
)
except Exception as e:
_logger.error(
f"_cron_mod_handoff_followup failed for {order.name}: {e}"
)
def _mod_followup_cap_state(self): def _mod_followup_cap_state(self):
"""Return (within_cap, reset_needed, new_start, max_per_month) tuple. """Return (within_cap, reset_needed, new_start, max_per_month) tuple.

View File

@@ -330,6 +330,77 @@
</div> </div>
</div> </div>
<!-- ============================================================= -->
<!-- MOD BLANK FORMS + HANDOFF FOLLOW-UP (2026-04 update) -->
<!-- ============================================================= -->
<h2>March of Dimes — Blank Forms &amp; Handoff Follow-Up</h2>
<p class="text-muted">
Upload the latest revision of the MOD-issued blank forms here.
They are auto-attached to outgoing emails so every case uses the
current version. Re-upload whenever March of Dimes publishes a
new revision.
</p>
<div class="row mt-4 o_settings_container">
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<span class="o_form_label">MOD Application Form (blank)</span>
<div class="text-muted">
Attached to the handoff email when client or authorizer
is submitting the application themselves.
</div>
<div class="mt-2">
<field name="fc_mod_application_form"
filename="fc_mod_application_form_filename"
widget="binary"/>
</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<span class="o_form_label">Verification of Disability Form (blank)</span>
<div class="text-muted">
Attached to the VOD request email sent to the authorizer
when our office is handling the MOD submission internally.
</div>
<div class="mt-2">
<field name="fc_mod_vod_form"
filename="fc_mod_vod_form_filename"
widget="binary"/>
</div>
</div>
</div>
</div>
<div class="row mt-4 o_settings_container">
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<span class="o_form_label">Handoff Follow-up Assignee</span>
<div class="text-muted">
Who gets the follow-up activity when a client or authorizer
is handling the MOD submission themselves. "Office contact"
routes all follow-ups to a single designated person. "Sales
rep" assigns to the rep on each order.
</div>
<div class="mt-2">
<field name="fc_mod_followup_assignee_mode" widget="radio"/>
</div>
</div>
</div>
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<span class="o_form_label">Office Follow-up Contact</span>
<div class="text-muted">
The office user who receives MOD handoff follow-up activities
when assignee mode is set to Office Contact.
</div>
<div class="mt-2">
<field name="fc_mod_followup_office_contact_id"
invisible="fc_mod_followup_assignee_mode != 'office_contact'"
options="{'no_create': True}"/>
</div>
</div>
</div>
</div>
<!-- ============================================================= --> <!-- ============================================================= -->
<!-- DATA MIGRATION --> <!-- DATA MIGRATION -->
<!-- ============================================================= --> <!-- ============================================================= -->

View File

@@ -193,12 +193,57 @@
<!-- Exception buttons --> <!-- Exception buttons -->
<button name="action_mod_on_hold" type="object" <button name="action_mod_on_hold" type="object"
string="Hold" class="btn-warning" icon="fa-pause" string="Hold" class="btn-warning" icon="fa-pause"
invisible="not x_fc_is_mod_sale or x_fc_mod_status not in ('contract_received', 'in_production', 'project_complete')"/> invisible="not x_fc_is_mod_sale or x_fc_mod_status in ('on_hold', 'case_closed', 'cancelled', False)"/>
<button name="action_mod_resume" type="object" <button name="action_mod_resume" type="object"
string="Resume" class="btn-success" icon="fa-play" string="Resume" class="btn-success" icon="fa-play"
invisible="not x_fc_is_mod_sale or x_fc_mod_status != 'on_hold'"/> invisible="not x_fc_is_mod_sale or x_fc_mod_status != 'on_hold'"/>
<!-- ======================================================= -->
<!-- MOD 2026-04 UPDATE — new submission-path + recovery btns -->
<!-- ======================================================= -->
<!-- Set MOD Submission Path (internal / client / authorizer) -->
<button name="action_mod_set_submission_path" type="object"
string="Set Submission Path" class="btn-info" icon="fa-sitemap"
invisible="not x_fc_is_mod_sale or x_fc_mod_status not in ('assessment_completed', 'processing_drawings', 'quote_submitted')"
help="Choose who is submitting the MOD application (our office, the client, or the authorizer)"/>
<!-- Request VOD from Authorizer (manual re-send) -->
<button name="action_mod_request_vod" type="object"
string="Request VOD from Authorizer" class="btn-warning" icon="fa-envelope"
invisible="not x_fc_is_mod_sale or x_fc_mod_submitted_by != 'internal' or x_fc_mod_vod_letter"
help="Email the authorizer the blank Verification of Disability form to complete. Fires automatically once on first setting submitted_by=internal, this button is for manual re-sends."/>
<!-- Handoff to Client/Authorizer (paths B/C) -->
<button name="action_mod_handoff_to_client" type="object"
string="Handoff Package" class="btn-primary" icon="fa-share"
invisible="not x_fc_is_mod_sale or x_fc_mod_submitted_by not in ('client', 'authorizer') or x_fc_mod_status not in ('processing_drawings', 'quote_submitted')"
confirm="This will email the full MOD application package to the client or authorizer and move the case to Handoff state. Continue?"
help="Email proposal + drawing + blank MOD application form to whoever is submitting, and move the case to handoff_to_client"/>
<!-- Confirm Submission (for handoff_to_client state) -->
<button name="action_mod_confirmed_submission" type="object"
string="Confirm Submission" class="btn-success" icon="fa-check-circle"
invisible="not x_fc_is_mod_sale or x_fc_mod_status != 'handoff_to_client'"
help="Client or authorizer confirmed they submitted the application to MOD. Record the date and advance to Awaiting Funding."/>
<!-- Recovery buttons for funding_denied -->
<button name="action_mod_resubmit_from_denied" type="object"
string="Resubmit" class="btn-primary" icon="fa-undo"
invisible="not x_fc_is_mod_sale or x_fc_mod_status != 'funding_denied'"
help="Revise and resubmit the case to March of Dimes after a denial"/>
<button name="action_mod_cancel_from_denied" type="object"
string="Cancel Case" class="btn-danger" icon="fa-times"
invisible="not x_fc_is_mod_sale or x_fc_mod_status != 'funding_denied'"
confirm="Cancel this denied MOD case? This action can be reversed with Reopen."/>
<button name="action_mod_reopen_cancelled" type="object"
string="Reopen" class="btn-info" icon="fa-refresh"
invisible="not x_fc_is_mod_sale or x_fc_mod_status != 'cancelled'"
confirm="Reopen this cancelled MOD case? Status will reset to Need to Schedule."/>
</xpath> </xpath>
<!-- ============================================= --> <!-- ============================================= -->
@@ -234,6 +279,59 @@
Upload Completion Photos and Proof of Delivery to submit to the case worker. Upload Completion Photos and Proof of Delivery to submit to the case worker.
</div> </div>
<!-- ===== 2026-04 MOD Update: Submission Path + Application Package ===== -->
<separator string="MOD Submission Path"/>
<div class="alert alert-secondary" role="alert"
invisible="x_fc_mod_submitted_by">
<i class="fa fa-question-circle"/> <strong>Who is submitting this application?</strong>
Click <strong>Set Submission Path</strong> in the status bar above to choose:
internal (we submit), client, or authorizer.
</div>
<group col="4">
<field name="x_fc_mod_submitted_by" readonly="1"/>
<field name="x_fc_mod_handoff_date" readonly="1"
invisible="x_fc_mod_submitted_by == 'internal'"/>
<field name="x_fc_mod_application_submitted_date" readonly="1"/>
<field name="x_fc_mod_vod_requested_date" readonly="1"
invisible="x_fc_mod_submitted_by != 'internal'"/>
<field name="x_fc_mod_accessibility_specialist_id"
options="{'no_create': True}"/>
</group>
<separator string="MOD Application Package (when WE submit on client's behalf)"
invisible="x_fc_mod_submitted_by != 'internal'"/>
<group col="2" invisible="x_fc_mod_submitted_by != 'internal'">
<field name="x_fc_mod_vod_letter"
filename="x_fc_mod_vod_letter_filename"
widget="binary"/>
<field name="x_fc_mod_application_form_doc"
filename="x_fc_mod_application_form_doc_filename"
widget="binary"/>
<field name="x_fc_mod_notice_of_assessment"
filename="x_fc_mod_notice_of_assessment_filename"
widget="binary"/>
<field name="x_fc_mod_property_tax"
filename="x_fc_mod_property_tax_filename"
widget="binary"/>
</group>
<separator string="Proposal Document"/>
<group col="2">
<field name="x_fc_mod_proposal_doc"
filename="x_fc_mod_proposal_doc_filename"
widget="binary"/>
</group>
<separator string="Funding Denial Details" invisible="x_fc_mod_status != 'funding_denied'"/>
<group invisible="x_fc_mod_status != 'funding_denied'">
<field name="x_fc_mod_funding_denial_reason" readonly="1" nolabel="1"/>
</group>
<separator string="Hold History" invisible="not x_fc_mod_previous_status_before_hold"/>
<group invisible="not x_fc_mod_previous_status_before_hold">
<field name="x_fc_mod_previous_status_before_hold" readonly="1"/>
</group>
<!-- ===== Row 1: Assessment Documents ===== --> <!-- ===== Row 1: Assessment Documents ===== -->
<div class="row"> <div class="row">
<!-- Column 1: Assessment and Design --> <!-- Column 1: Assessment and Design -->

View File

@@ -23,6 +23,10 @@ from . import send_to_mod_wizard
from . import mod_awaiting_funding_wizard from . import mod_awaiting_funding_wizard
from . import mod_funding_approved_wizard from . import mod_funding_approved_wizard
from . import mod_pca_received_wizard from . import mod_pca_received_wizard
from . import mod_submission_path_wizard
from . import mod_funding_denied_wizard
from . import mod_resubmit_wizard
from . import mod_submission_confirmed_wizard
from . import odsp_sa_mobility_wizard from . import odsp_sa_mobility_wizard
from . import odsp_discretionary_wizard from . import odsp_discretionary_wizard
from . import odsp_pre_approved_wizard from . import odsp_pre_approved_wizard

View File

@@ -0,0 +1,84 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""
MOD Funding Denied Wizard — captures the denial reason and sets the order
status. Previously action_mod_funding_denied was a bare write with no reason
capture; this wizard replaces that flow.
"""
from odoo import models, fields, api, _
from odoo.exceptions import UserError
from markupsafe import Markup
class ModFundingDeniedWizard(models.TransientModel):
_name = 'fusion_claims.mod.funding.denied.wizard'
_description = 'MOD - Funding Denied Reason Capture'
sale_order_id = fields.Many2one('sale.order', required=True, readonly=True)
denial_date = fields.Date(
string='Denial Date',
required=True,
default=fields.Date.context_today,
)
denial_reason_category = fields.Selection(
selection=[
('income_too_high', 'Client income exceeds MOD threshold'),
('residency', 'Residency requirement not met'),
('project_scope', 'Project scope not eligible'),
('missing_docs', 'Missing documentation'),
('funding_depleted', 'MOD funding depleted for this period'),
('other', 'Other'),
],
string='Denial Category',
required=True,
)
denial_reason = fields.Text(
string='Denial Details',
required=True,
help='MOD\'s stated reason for denying funding. Captured for the '
'audit trail and used when generating the client denial email.',
)
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
if self.env.context.get('active_id'):
order = self.env['sale.order'].browse(self.env.context['active_id'])
res['sale_order_id'] = order.id
return res
def action_confirm(self):
self.ensure_one()
order = self.sale_order_id
if order.x_fc_mod_status not in ('awaiting_funding', 'quote_submitted', 'handoff_to_client'):
raise UserError(
_("Funding can only be denied from awaiting_funding, "
"quote_submitted or handoff_to_client. Current: %s") % order.x_fc_mod_status
)
labels = dict(self._fields['denial_reason_category'].selection)
category_label = labels.get(self.denial_reason_category, self.denial_reason_category)
full_reason = f'[{category_label}] {self.denial_reason}'
order.write({
'x_fc_mod_status': 'funding_denied',
'x_fc_mod_funding_denial_reason': full_reason,
})
body = (
f'<div class="alert alert-danger" role="alert">'
f'<strong><i class="fa fa-ban"></i> Funding Denied by March of Dimes</strong>'
f'<ul>'
f'<li><strong>Date:</strong> {self.denial_date.strftime("%B %d, %Y")}</li>'
f'<li><strong>Category:</strong> {category_label}</li>'
f'<li><strong>Details:</strong> {self.denial_reason}</li>'
f'</ul>'
f'</div>'
)
order.message_post(
body=Markup(body),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
return {'type': 'ir.actions.act_window_close'}

View File

@@ -0,0 +1,40 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_mod_funding_denied_wizard_form" model="ir.ui.view">
<field name="name">fusion_claims.mod.funding.denied.wizard.form</field>
<field name="model">fusion_claims.mod.funding.denied.wizard</field>
<field name="arch" type="xml">
<form string="MOD Funding Denied">
<sheet>
<div class="alert alert-danger" role="alert">
<strong><i class="fa fa-ban"/> Record MOD Funding Denial</strong>
<p class="mb-0 mt-2">
Capture the denial reason from March of Dimes. This will be logged
to the case history and included in the notification sent to the
client and authorizer.
</p>
</div>
<field name="sale_order_id" invisible="1"/>
<group>
<field name="denial_date"/>
<field name="denial_reason_category" required="1"/>
<field name="denial_reason" required="1"
placeholder="Paste or summarise MOD's stated reason..."/>
</group>
</sheet>
<footer>
<button name="action_confirm" type="object"
string="Record Denial" class="btn-danger"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="action_mod_funding_denied_wizard" model="ir.actions.act_window">
<field name="name">Funding Denied by MOD</field>
<field name="res_model">fusion_claims.mod.funding.denied.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>

View File

@@ -0,0 +1,87 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""
MOD Resubmit Wizard — lets the office revise an order that MOD has denied,
and kick it back into the workflow at `processing_drawings` so the specialist
can update drawings/proposal/quotation before it is re-submitted to MOD.
"""
from odoo import models, fields, api, _
from odoo.exceptions import UserError
from markupsafe import Markup
class ModResubmitWizard(models.TransientModel):
_name = 'fusion_claims.mod.resubmit.wizard'
_description = 'MOD - Resubmit After Denial'
sale_order_id = fields.Many2one('sale.order', required=True, readonly=True)
revision_notes = fields.Text(
string='Revision Notes',
required=True,
help='Describe what is being revised for the resubmission '
'(scope changes, updated pricing, additional documentation, etc).',
)
clear_old_documents = fields.Boolean(
string='Clear old drawings / proposal / quotation',
default=False,
help='Tick this if the new submission needs entirely new drawings and '
'proposal. The previous documents are preserved in chatter before '
'being cleared.',
)
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
if self.env.context.get('active_id'):
order = self.env['sale.order'].browse(self.env.context['active_id'])
res['sale_order_id'] = order.id
return res
def action_confirm(self):
self.ensure_one()
order = self.sale_order_id
if order.x_fc_mod_status != 'funding_denied':
raise UserError(_("Only denied orders can be resubmitted via this wizard."))
if self.clear_old_documents:
# Preserve the old docs in chatter before clearing them.
preserved = []
if order.x_fc_mod_drawing:
preserved.append('Drawing')
if order.x_fc_mod_proposal_doc:
preserved.append('Proposal')
if preserved:
order.message_post(
body=Markup(
f'<div class="alert alert-warning" role="alert">'
f'<strong>Previous documents cleared for resubmission:</strong> '
f'{", ".join(preserved)}<br/>'
f'See prior chatter for the originals.'
f'</div>'
),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
order.with_context(skip_status_emails=True).write({
'x_fc_mod_drawing': False,
'x_fc_mod_drawing_filename': False,
'x_fc_mod_proposal_doc': False,
'x_fc_mod_proposal_doc_filename': False,
})
order.with_context(skip_status_emails=True).write({
'x_fc_mod_status': 'processing_drawings',
})
body = (
f'<div class="alert alert-info" role="alert">'
f'<strong><i class="fa fa-undo"></i> Resubmitting to March of Dimes after denial</strong>'
f'<p class="mb-0 mt-2"><strong>Revision notes:</strong> {self.revision_notes}</p>'
f'</div>'
)
order.message_post(
body=Markup(body),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
return {'type': 'ir.actions.act_window_close'}

View File

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_mod_resubmit_wizard_form" model="ir.ui.view">
<field name="name">fusion_claims.mod.resubmit.wizard.form</field>
<field name="model">fusion_claims.mod.resubmit.wizard</field>
<field name="arch" type="xml">
<form string="Resubmit to MOD">
<sheet>
<div class="alert alert-info" role="alert">
<strong><i class="fa fa-undo"/> Resubmit Denied Application</strong>
<p class="mb-0 mt-2">
Return this denied case to <strong>Processing Drawings</strong> so the
accessibility specialist can update the proposal, drawings, or quote
before resubmitting to March of Dimes.
</p>
</div>
<field name="sale_order_id" invisible="1"/>
<group>
<field name="revision_notes" required="1"
placeholder="What is being revised? Scope change, updated pricing, new docs..."/>
<field name="clear_old_documents"/>
</group>
</sheet>
<footer>
<button name="action_confirm" type="object"
string="Resubmit" class="btn-primary"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="action_mod_resubmit_wizard" model="ir.actions.act_window">
<field name="name">Resubmit to MOD</field>
<field name="res_model">fusion_claims.mod.resubmit.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>

View File

@@ -0,0 +1,92 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""
MOD Submission Confirmed Wizard — office confirms that a client or authorizer
has submitted the application to MOD, captures the actual submission date,
and advances the order from handoff_to_client to awaiting_funding.
"""
from odoo import models, fields, api, _
from odoo.exceptions import UserError
from markupsafe import Markup
class ModSubmissionConfirmedWizard(models.TransientModel):
_name = 'fusion_claims.mod.submission.confirmed.wizard'
_description = 'MOD - Confirm Application Submitted'
sale_order_id = fields.Many2one('sale.order', required=True, readonly=True)
submitted_by_label = fields.Char(string='Submitted By', readonly=True)
application_submitted_date = fields.Date(
string='Actual Submission Date',
required=True,
default=fields.Date.context_today,
help='Date the client or authorizer told us they actually submitted '
'the application to March of Dimes.',
)
confirmation_source = fields.Selection(
selection=[
('phone_call', 'Phone call with client'),
('email', 'Email from client'),
('client_portal', 'Client used our portal'),
('authorizer', 'Confirmed by authorizer (OT)'),
('other', 'Other'),
],
string='How was this confirmed?',
required=True,
default='phone_call',
)
confirmation_notes = fields.Text(
string='Notes from confirmation',
help='What did the client say? Confirmation number from MOD if given?',
)
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
if self.env.context.get('active_id'):
order = self.env['sale.order'].browse(self.env.context['active_id'])
res['sale_order_id'] = order.id
path_labels = {
'internal': 'We (internal)',
'client': 'Client',
'authorizer': 'Authorizer (OT)',
}
res['submitted_by_label'] = path_labels.get(
order.x_fc_mod_submitted_by, 'Unknown')
return res
def action_confirm(self):
self.ensure_one()
order = self.sale_order_id
if order.x_fc_mod_status != 'handoff_to_client':
raise UserError(
_("This wizard is only for orders that have been handed off "
"to the client or authorizer for submission. Current status: %s")
% order.x_fc_mod_status
)
order.write({
'x_fc_mod_status': 'awaiting_funding',
'x_fc_mod_application_submitted_date': self.application_submitted_date,
})
source_labels = dict(self._fields['confirmation_source'].selection)
body = (
f'<div class="alert alert-success" role="alert">'
f'<strong><i class="fa fa-check-circle"></i> Application Submission Confirmed</strong>'
f'<ul>'
f'<li><strong>Submitted By:</strong> {self.submitted_by_label}</li>'
f'<li><strong>Submission Date:</strong> {self.application_submitted_date.strftime("%B %d, %Y")}</li>'
f'<li><strong>Confirmed Via:</strong> {source_labels[self.confirmation_source]}</li>'
f'</ul>'
)
if self.confirmation_notes:
body += f'<p class="mb-0"><strong>Notes:</strong> {self.confirmation_notes}</p>'
body += '</div>'
order.message_post(
body=Markup(body),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
return {'type': 'ir.actions.act_window_close'}

View File

@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_mod_submission_confirmed_wizard_form" model="ir.ui.view">
<field name="name">fusion_claims.mod.submission.confirmed.wizard.form</field>
<field name="model">fusion_claims.mod.submission.confirmed.wizard</field>
<field name="arch" type="xml">
<form string="Confirm MOD Submission">
<sheet>
<div class="alert alert-success" role="alert">
<strong><i class="fa fa-check-circle"/> Confirm Application Submission</strong>
<p class="mb-0 mt-2">
Record that the <strong>client</strong> or <strong>authorizer</strong>
has actually submitted the application to March of Dimes. The order
will advance to <strong>Awaiting Funding</strong>.
</p>
</div>
<field name="sale_order_id" invisible="1"/>
<group>
<field name="submitted_by_label" readonly="1"/>
<field name="application_submitted_date" required="1"/>
<field name="confirmation_source" required="1"/>
<field name="confirmation_notes"
placeholder="What did the client say? Any reference number from MOD?"/>
</group>
</sheet>
<footer>
<button name="action_confirm" type="object"
string="Confirm Submission" class="btn-primary"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="action_mod_submission_confirmed_wizard" model="ir.actions.act_window">
<field name="name">Confirm MOD Submission</field>
<field name="res_model">fusion_claims.mod.submission.confirmed.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>

View File

@@ -0,0 +1,76 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""
MOD Submission Path Wizard — asks the office which party is submitting the
March of Dimes application. Sets x_fc_mod_submitted_by on the order and,
when the path is 'internal', auto-triggers the VOD request email to the
authorizer the first time it is selected.
"""
from odoo import models, fields, api, _
from odoo.exceptions import UserError
from markupsafe import Markup
class ModSubmissionPathWizard(models.TransientModel):
_name = 'fusion_claims.mod.submission.path.wizard'
_description = 'MOD - Set Submission Path'
sale_order_id = fields.Many2one('sale.order', required=True, readonly=True)
submitted_by = fields.Selection(
selection=[
('internal', 'We submit on client\'s behalf'),
('client', 'Client submits themselves'),
('authorizer', 'Authorizer (OT) submits'),
],
string='Who is submitting to March of Dimes?',
required=True,
)
notes = fields.Text(string='Notes')
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
if self.env.context.get('active_id'):
order = self.env['sale.order'].browse(self.env.context['active_id'])
res['sale_order_id'] = order.id
if order.x_fc_mod_submitted_by:
res['submitted_by'] = order.x_fc_mod_submitted_by
return res
def action_confirm(self):
self.ensure_one()
order = self.sale_order_id
previous = order.x_fc_mod_submitted_by
vals = {'x_fc_mod_submitted_by': self.submitted_by}
order.with_context(skip_status_emails=True).write(vals)
labels = dict(self._fields['submitted_by'].selection)
body = (
f'<div class="alert alert-info" role="alert">'
f'<strong>MOD submission path set:</strong> {labels[self.submitted_by]}'
)
if self.notes:
body += f'<br/>{self.notes}'
body += '</div>'
order.message_post(
body=Markup(body),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
# First-time internal path → auto-trigger the VOD request email so
# the authorizer knows to fill and send the VOD form back.
if (
self.submitted_by == 'internal'
and previous != 'internal'
and not order.x_fc_mod_vod_requested_date
):
try:
order._send_mod_vod_request_email()
except Exception:
# Don't block the wizard if the email fails — office can
# retry via the manual "Request VOD" button.
pass
return {'type': 'ir.actions.act_window_close'}

View File

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_mod_submission_path_wizard_form" model="ir.ui.view">
<field name="name">fusion_claims.mod.submission.path.wizard.form</field>
<field name="model">fusion_claims.mod.submission.path.wizard</field>
<field name="arch" type="xml">
<form string="Set MOD Submission Path">
<sheet>
<div class="alert alert-info" role="alert">
<strong><i class="fa fa-info-circle"/> Who is submitting this application to March of Dimes?</strong>
<p class="mb-0 mt-2">
This determines which document gates apply and whether we hand off materials
to the client or authorizer for them to submit. Selecting
<strong>"We submit on client's behalf"</strong> will automatically email the
authorizer the blank Verification of Disability form to complete.
</p>
</div>
<field name="sale_order_id" invisible="1"/>
<group>
<field name="submitted_by" widget="radio" required="1"/>
<field name="notes" placeholder="Optional notes..."/>
</group>
</sheet>
<footer>
<button name="action_confirm" type="object"
string="Confirm" class="btn-primary"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="action_mod_submission_path_wizard" model="ir.actions.act_window">
<field name="name">Set MOD Submission Path</field>
<field name="res_model">fusion_claims.mod.submission.path.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
</record>
</odoo>