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:
@@ -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',
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
# =========================================================================
|
# =========================================================================
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -330,6 +330,77 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ============================================================= -->
|
||||||
|
<!-- MOD BLANK FORMS + HANDOFF FOLLOW-UP (2026-04 update) -->
|
||||||
|
<!-- ============================================================= -->
|
||||||
|
<h2>March of Dimes — Blank Forms & 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 -->
|
||||||
<!-- ============================================================= -->
|
<!-- ============================================================= -->
|
||||||
|
|||||||
@@ -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 -->
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
84
fusion_claims/wizard/mod_funding_denied_wizard.py
Normal file
84
fusion_claims/wizard/mod_funding_denied_wizard.py
Normal 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'}
|
||||||
40
fusion_claims/wizard/mod_funding_denied_wizard_views.xml
Normal file
40
fusion_claims/wizard/mod_funding_denied_wizard_views.xml
Normal 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>
|
||||||
87
fusion_claims/wizard/mod_resubmit_wizard.py
Normal file
87
fusion_claims/wizard/mod_resubmit_wizard.py
Normal 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'}
|
||||||
39
fusion_claims/wizard/mod_resubmit_wizard_views.xml
Normal file
39
fusion_claims/wizard/mod_resubmit_wizard_views.xml
Normal 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>
|
||||||
92
fusion_claims/wizard/mod_submission_confirmed_wizard.py
Normal file
92
fusion_claims/wizard/mod_submission_confirmed_wizard.py
Normal 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'}
|
||||||
@@ -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>
|
||||||
76
fusion_claims/wizard/mod_submission_path_wizard.py
Normal file
76
fusion_claims/wizard/mod_submission_path_wizard.py
Normal 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'}
|
||||||
39
fusion_claims/wizard/mod_submission_path_wizard_views.xml
Normal file
39
fusion_claims/wizard/mod_submission_path_wizard_views.xml
Normal 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>
|
||||||
Reference in New Issue
Block a user