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',
'version': '19.0.8.0.2',
'version': '19.0.8.0.3',
'category': 'Sales',
'summary': 'Complete ADP Claims Management with Dashboard, Sales Integration, Billing Automation, and Two-Stage Verification.',
'description': """
@@ -122,6 +122,10 @@
'wizard/mod_awaiting_funding_wizard_views.xml',
'wizard/mod_funding_approved_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_discretionary_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)"/>
</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.
Approved / approved_deduction orders past the configured funding
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',
)
# ==========================================================================
# 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):
"""Get the name for cheque payments, defaulting to company name."""
self.ensure_one()
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',
)
# =========================================================================
# 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
# =========================================================================

View File

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

View File

@@ -432,6 +432,7 @@ class SaleOrder(models.Model):
('contract_received', 'PCA Received'),
('in_production', 'In Production'),
('project_complete', 'Complete'),
('handoff_to_client', 'Handed Off (Client/Authorizer Submitting)'),
('pod_submitted', 'POD Sent'),
('case_closed', 'Closed'),
('on_hold', 'On Hold'),
@@ -441,7 +442,9 @@ class SaleOrder(models.Model):
default='need_to_schedule',
tracking=True,
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
@@ -598,6 +601,108 @@ class SaleOrder(models.Model):
)
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
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')
@@ -7540,8 +7645,21 @@ class SaleOrder(models.Model):
}
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.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):
"""Open wizard to upload PCA document and record receipt."""
@@ -7593,13 +7711,178 @@ class SaleOrder(models.Model):
})
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.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):
"""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.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):
"""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):
"""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()
client_name = self.partner_id.name or 'Client'
return self._send_mod_email(
@@ -7878,7 +8166,7 @@ class SaleOrder(models.Model):
title='Assessment Completed',
summary=f'The accessibility assessment for <strong>{client_name}</strong> has been completed.',
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 '
'based on the assessment. We will send you the proposal once it is ready for review.',
note_color='#38a169',
@@ -8003,7 +8291,12 @@ class SaleOrder(models.Model):
)
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()
client_name = self.partner_id.name or 'Client'
return self._send_mod_email(
@@ -8011,7 +8304,7 @@ class SaleOrder(models.Model):
title='Proof of Delivery Submitted',
summary=f'Photos and proof of delivery for <strong>{client_name}</strong> have been submitted.',
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.',
)
@@ -8046,6 +8339,313 @@ class SaleOrder(models.Model):
'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):
"""Return (within_cap, reset_needed, new_start, max_per_month) tuple.

View File

@@ -330,6 +330,77 @@
</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 -->
<!-- ============================================================= -->

View File

@@ -193,12 +193,57 @@
<!-- Exception buttons -->
<button name="action_mod_on_hold" type="object"
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"
string="Resume" class="btn-success" icon="fa-play"
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>
<!-- ============================================= -->
@@ -234,6 +279,59 @@
Upload Completion Photos and Proof of Delivery to submit to the case worker.
</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 ===== -->
<div class="row">
<!-- 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_funding_approved_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_discretionary_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>