feat: separate fusion field service and LTC into standalone modules, update core modules

- fusion_claims: separated field service logic, updated controllers/views
- fusion_tasks: updated task views and map integration
- fusion_authorizer_portal: added page 11 signing, schedule booking, migrations
- fusion_shipping: new standalone shipping module (Canada Post, FedEx, DHL, Purolator)
- fusion_ltc_management: new standalone LTC management module
This commit is contained in:
2026-03-11 16:19:52 +00:00
parent 1f79cdcaaf
commit 431052920e
274 changed files with 52782 additions and 7302 deletions

View File

@@ -30,4 +30,4 @@ from . import odsp_discretionary_wizard
from . import odsp_pre_approved_wizard
from . import odsp_ready_delivery_wizard
from . import odsp_submit_to_odsp_wizard
from . import ltc_repair_create_so_wizard
from . import send_page11_wizard

View File

@@ -34,18 +34,42 @@ class ApplicationReceivedWizard(models.TransientModel):
signed_pages_11_12 = fields.Binary(
string='Signed Pages 11 & 12',
required=True,
help='Upload the signed pages 11 and 12 from the application',
help='Upload the signed pages 11 and 12 from the application. '
'Not required if a remote signing request has been sent.',
)
signed_pages_filename = fields.Char(
string='Pages Filename',
)
has_pending_page11_request = fields.Boolean(
compute='_compute_has_pending_page11_request',
)
has_signed_page11 = fields.Boolean(
compute='_compute_has_pending_page11_request',
)
notes = fields.Text(
string='Notes',
help='Any notes about the received application',
)
@api.depends('sale_order_id')
def _compute_has_pending_page11_request(self):
for wiz in self:
order = wiz.sale_order_id
if order:
requests = order.page11_sign_request_ids
wiz.has_pending_page11_request = bool(
requests.filtered(lambda r: r.state in ('draft', 'sent'))
)
wiz.has_signed_page11 = bool(
order.x_fc_signed_pages_11_12
or requests.filtered(lambda r: r.state == 'signed')
)
else:
wiz.has_pending_page11_request = False
wiz.has_signed_page11 = False
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
@@ -53,7 +77,6 @@ class ApplicationReceivedWizard(models.TransientModel):
if active_id:
order = self.env['sale.order'].browse(active_id)
res['sale_order_id'] = order.id
# Pre-fill if documents already exist
if order.x_fc_original_application:
res['original_application'] = order.x_fc_original_application
res['original_application_filename'] = order.x_fc_original_application_filename
@@ -91,20 +114,33 @@ class ApplicationReceivedWizard(models.TransientModel):
if order.x_fc_adp_application_status not in ('assessment_completed', 'waiting_for_application'):
raise UserError("Can only receive application from 'Waiting for Application' status.")
# Validate files are uploaded
if not self.original_application:
raise UserError("Please upload the Original ADP Application.")
if not self.signed_pages_11_12:
raise UserError("Please upload the Signed Pages 11 & 12.")
# Update sale order with documents
order.with_context(skip_status_validation=True).write({
page11_covered = bool(
self.signed_pages_11_12
or order.x_fc_signed_pages_11_12
or order.page11_sign_request_ids.filtered(
lambda r: r.state in ('sent', 'signed')
)
)
if not page11_covered:
raise UserError(
"Signed Pages 11 & 12 are required.\n\n"
"You can either upload the file here, or use the "
"'Request Page 11 Signature' button on the sale order "
"to send it for remote signing before confirming."
)
vals = {
'x_fc_adp_application_status': 'application_received',
'x_fc_original_application': self.original_application,
'x_fc_original_application_filename': self.original_application_filename,
'x_fc_signed_pages_11_12': self.signed_pages_11_12,
'x_fc_signed_pages_filename': self.signed_pages_filename,
})
}
if self.signed_pages_11_12:
vals['x_fc_signed_pages_11_12'] = self.signed_pages_11_12
vals['x_fc_signed_pages_filename'] = self.signed_pages_filename
order.with_context(skip_status_validation=True).write(vals)
# Post to chatter
from datetime import date
@@ -128,3 +164,15 @@ class ApplicationReceivedWizard(models.TransientModel):
)
return {'type': 'ir.actions.act_window_close'}
def action_request_page11_signature(self):
"""Open the Page 11 remote signing wizard from within the Application Received wizard."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Request Page 11 Signature',
'res_model': 'fusion_claims.send.page11.wizard',
'view_mode': 'form',
'target': 'new',
'context': {'default_sale_order_id': self.sale_order_id.id},
}

View File

@@ -10,10 +10,12 @@
<strong><i class="fa fa-info-circle"/> Upload Required Documents</strong>
<p class="mb-0">Please upload the ADP application documents received from the client.</p>
</div>
<field name="sale_order_id" invisible="1"/>
<field name="has_pending_page11_request" invisible="1"/>
<field name="has_signed_page11" invisible="1"/>
<group>
<field name="sale_order_id" invisible="1"/>
<group string="Original ADP Application">
<field name="original_application" filename="original_application_filename"
widget="binary" class="oe_inline"/>
@@ -24,6 +26,28 @@
<field name="signed_pages_11_12" filename="signed_pages_filename"
widget="binary" class="oe_inline"/>
<field name="signed_pages_filename" invisible="1"/>
<div invisible="has_signed_page11" class="mt-2">
<span class="text-muted small">Don't have signed pages? </span>
<button name="action_request_page11_signature" type="object"
string="Request Remote Signature"
class="btn btn-sm btn-outline-warning"
icon="fa-pencil-square-o"
help="Send Page 11 to a family member or agent for digital signing"/>
</div>
<div invisible="not has_pending_page11_request" class="mt-2">
<div class="alert alert-warning mb-0 py-2 px-3">
<i class="fa fa-clock-o"/> A remote signing request has been sent.
You can proceed without uploading signed pages -- they will be auto-filled when signed.
</div>
</div>
<div invisible="not has_signed_page11 or signed_pages_11_12" class="mt-2">
<div class="alert alert-success mb-0 py-2 px-3">
<i class="fa fa-check-circle"/> Page 11 has been signed remotely.
</div>
</div>
</group>
</group>

View File

@@ -11,7 +11,6 @@ _logger = logging.getLogger(__name__)
class AssessmentCompletedWizard(models.TransientModel):
"""Wizard to record assessment completion date."""
_name = 'fusion_claims.assessment.completed.wizard'
_description = 'Assessment Completed Wizard'
@@ -21,18 +20,49 @@ class AssessmentCompletedWizard(models.TransientModel):
required=True,
readonly=True,
)
is_override = fields.Boolean(
string='Scheduling Override',
compute='_compute_is_override',
store=False,
)
assessment_start_date = fields.Date(
string='Assessment Start Date',
required=True,
help='Date the assessment was conducted',
)
completion_date = fields.Date(
string='Assessment Completion Date',
required=True,
default=fields.Date.context_today,
)
notes = fields.Text(
string='Assessment Notes',
help='Any notes from the assessment',
string='Notes',
help='Notes from the assessment',
)
override_reason = fields.Text(
string='Override Reason',
help='Mandatory when skipping the scheduling step. Explain why the assessment was completed without scheduling through the system.',
)
notify_authorizer = fields.Boolean(
string='Notify Authorizer',
default=True,
help='Send email to the authorizer about assessment completion',
)
@api.depends('sale_order_id')
def _compute_is_override(self):
for rec in self:
rec.is_override = (
rec.sale_order_id
and rec.sale_order_id.x_fc_adp_application_status == 'quotation'
)
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
@@ -40,43 +70,174 @@ class AssessmentCompletedWizard(models.TransientModel):
if active_id:
order = self.env['sale.order'].browse(active_id)
res['sale_order_id'] = order.id
if order.x_fc_assessment_start_date:
res['assessment_start_date'] = order.x_fc_assessment_start_date
else:
res['assessment_start_date'] = fields.Date.context_today(self)
return res
def action_complete(self):
"""Mark assessment as completed."""
self.ensure_one()
order = self.sale_order_id
if order.x_fc_adp_application_status != 'assessment_scheduled':
raise UserError("Can only complete assessment from 'Assessment Scheduled' status.")
# Validate completion date is not before start date
if order.x_fc_assessment_start_date and self.completion_date < order.x_fc_assessment_start_date:
current_status = order.x_fc_adp_application_status
is_override = current_status == 'quotation'
if current_status not in ('quotation', 'assessment_scheduled'):
raise UserError(
f"Completion date ({self.completion_date}) cannot be before "
f"assessment start date ({order.x_fc_assessment_start_date})."
_("Can only complete assessment from 'Quotation' or 'Assessment Scheduled' status.")
)
# Update sale order
order.with_context(skip_status_validation=True).write({
if is_override and not (self.override_reason or '').strip():
raise UserError(
_("Override Reason is mandatory when skipping the assessment scheduling step. "
"Please explain why this assessment was completed without being scheduled through the system.")
)
if self.completion_date < self.assessment_start_date:
raise UserError(
_("Completion date (%s) cannot be before assessment start date (%s).")
% (self.completion_date, self.assessment_start_date)
)
write_vals = {
'x_fc_adp_application_status': 'assessment_completed',
'x_fc_assessment_end_date': self.completion_date,
})
# Post to chatter
notes_html = f'<p style="margin: 4px 0 0 0;"><strong>Notes:</strong> {self.notes}</p>' if self.notes else ''
order.message_post(
body=Markup(
'<div style="background: #d4edda; border-left: 4px solid #28a745; padding: 12px; margin: 8px 0; border-radius: 4px;">'
'<h4 style="color: #28a745; margin: 0 0 8px 0;"><i class="fa fa-check-square-o"/> Assessment Completed</h4>'
f'<p style="margin: 0;"><strong>Completion Date:</strong> {self.completion_date.strftime("%B %d, %Y")}</p>'
f'{notes_html}'
}
if is_override or not order.x_fc_assessment_start_date:
write_vals['x_fc_assessment_start_date'] = self.assessment_start_date
order.with_context(skip_status_validation=True).write(write_vals)
if is_override:
override_html = Markup(
'<div style="background:#fff3cd;border-left:4px solid #ffc107;padding:12px;margin:8px 0;border-radius:4px;">'
'<h4 style="color:#856404;margin:0 0 8px 0;">'
'<i class="fa fa-exclamation-triangle"/> Assessment Scheduling Override</h4>'
'<p style="margin:0;"><strong>Override by:</strong> %s</p>'
'<p style="margin:4px 0 0 0;"><strong>Reason:</strong> %s</p>'
'<p style="margin:4px 0 0 0;"><strong>Assessment Date:</strong> %s to %s</p>'
'%s'
'</div>'
),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
) % (
self.env.user.name,
self.override_reason.strip(),
self.assessment_start_date.strftime("%B %d, %Y"),
self.completion_date.strftime("%B %d, %Y"),
Markup('<p style="margin:4px 0 0 0;"><strong>Notes:</strong> %s</p>') % self.notes if self.notes else Markup(''),
)
order.message_post(
body=override_html,
message_type='notification',
subtype_xmlid='mail.mt_note',
)
else:
notes_html = (
Markup('<p style="margin:4px 0 0 0;"><strong>Notes:</strong> %s</p>') % self.notes
) if self.notes else Markup('')
order.message_post(
body=Markup(
'<div style="background:#d4edda;border-left:4px solid #28a745;padding:12px;margin:8px 0;border-radius:4px;">'
'<h4 style="color:#28a745;margin:0 0 8px 0;">'
'<i class="fa fa-check-square-o"/> Assessment Completed</h4>'
'<p style="margin:0;"><strong>Completion Date:</strong> %s</p>'
'%s'
'</div>'
) % (self.completion_date.strftime("%B %d, %Y"), notes_html),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
if self.notify_authorizer:
self._send_backend_completion_email(order, is_override)
return {'type': 'ir.actions.act_window_close'}
def _send_backend_completion_email(self, order, is_override):
"""Send assessment completion email when done from backend."""
self.ensure_one()
if not order._email_is_enabled():
return
authorizer = order.x_fc_authorizer_id
if not authorizer or not authorizer.email:
_logger.info("No authorizer email for %s, skipping notification", order.name)
return
to_email = authorizer.email
cc_emails = []
if order.user_id and order.user_id.email:
cc_emails.append(order.user_id.email)
company = self.env.company
office_partners = company.sudo().x_fc_office_notification_ids
cc_emails.extend([p.email for p in office_partners if p.email])
client_name = order.partner_id.name or 'Client'
override_note = ''
if is_override:
override_note = (
'<div style="background:#fff3cd;border-left:3px solid #ffc107;padding:8px 12px;'
'margin:12px 0;border-radius:4px;">'
'<strong>Note:</strong> This assessment was completed without being scheduled '
'through the system. '
f'<strong>Reason:</strong> {self.override_reason.strip()}'
'</div>'
)
sections = [
('Assessment Details', [
('Client', client_name),
('Case', order.name),
('Assessment Date', f"{self.assessment_start_date.strftime('%B %d, %Y')} to {self.completion_date.strftime('%B %d, %Y')}"),
('Completed by', self.env.user.name),
]),
]
if self.notes:
sections.append(('Notes', [('', self.notes)]))
summary = (
f'The assessment for <strong>{client_name}</strong> ({order.name}) '
f'has been completed on {self.completion_date.strftime("%B %d, %Y")}.'
)
if is_override:
summary += f' {override_note}'
email_body = order._email_build(
title='Assessment Completed',
summary=summary,
email_type='success',
sections=sections,
note='<strong>Next step:</strong> Please submit the ADP application '
'(including pages 11-12 signed by the client) so we can proceed.',
button_url=f'{order.get_base_url()}/web#id={order.id}&model=sale.order&view_type=form',
button_text='View Case',
sender_name=order.user_id.name if order.user_id else 'The Team',
)
try:
self.env['mail.mail'].sudo().create({
'subject': f'Assessment Completed - {client_name} - {order.name}',
'body_html': email_body,
'email_to': to_email,
'email_cc': ', '.join(cc_emails) if cc_emails else False,
'model': 'sale.order',
'res_id': order.id,
'auto_delete': True,
}).send()
order.message_post(
body=Markup(
'<div class="alert alert-info" role="alert">'
'<strong>Assessment Completed email sent</strong>'
'<ul class="mb-0 mt-1"><li>To: %s</li>'
'<li>CC: %s</li></ul></div>'
) % (to_email, ', '.join(cc_emails) or 'None'),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
_logger.info("Sent backend assessment completed email for %s", order.name)
except Exception as e:
_logger.error("Failed to send assessment completed email for %s: %s", order.name, e)

View File

@@ -1,18 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Assessment Completed Wizard Form View -->
<record id="view_assessment_completed_wizard_form" model="ir.ui.view">
<field name="name">fusion_claims.assessment.completed.wizard.form</field>
<field name="model">fusion_claims.assessment.completed.wizard</field>
<field name="arch" type="xml">
<form string="Assessment Completed">
<field name="sale_order_id" invisible="1"/>
<field name="is_override" invisible="1"/>
<div invisible="not is_override"
class="alert alert-warning mb-3" role="alert">
<strong>Scheduling Override:</strong>
This assessment was not scheduled through the system.
A reason is required to proceed.
</div>
<group>
<field name="sale_order_id" invisible="1"/>
<field name="completion_date"/>
<field name="notes" placeholder="Enter any notes from the assessment..."/>
<group string="Assessment Dates">
<field name="assessment_start_date"/>
<field name="completion_date"/>
</group>
<group string="Details">
<field name="notes"
placeholder="Enter any notes from the assessment..."/>
<field name="notify_authorizer"/>
</group>
</group>
<group invisible="not is_override">
<field name="override_reason"
required="is_override"
placeholder="e.g., Authorizer completed the assessment independently and sent us the application directly..."
widget="text"/>
</group>
<footer>
<button name="action_complete" type="object"
<button name="action_complete" type="object"
string="Mark Complete" class="btn-primary"
icon="fa-check"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
@@ -21,7 +44,6 @@
</field>
</record>
<!-- Action for the wizard -->
<record id="action_assessment_completed_wizard" model="ir.actions.act_window">
<field name="name">Assessment Completed</field>
<field name="res_model">fusion_claims.assessment.completed.wizard</field>

View File

@@ -1,48 +0,0 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from odoo import models, fields, api, _
from odoo.exceptions import UserError
class LTCRepairCreateSOWizard(models.TransientModel):
_name = 'fusion.ltc.repair.create.so.wizard'
_description = 'LTC Repair - Link Contact & Create Sale Order'
repair_id = fields.Many2one(
'fusion.ltc.repair',
string='Repair Request',
required=True,
readonly=True,
)
client_name = fields.Char(
string='Client Name',
readonly=True,
)
action_type = fields.Selection([
('create_new', 'Create New Contact'),
('link_existing', 'Link to Existing Contact'),
], string='Action', default='create_new', required=True)
partner_id = fields.Many2one(
'res.partner',
string='Existing Contact',
)
def action_confirm(self):
self.ensure_one()
repair = self.repair_id
if self.action_type == 'create_new':
if not self.client_name:
raise UserError(_('Client name is required to create a new contact.'))
partner = self.env['res.partner'].create({
'name': self.client_name,
})
repair.client_id = partner
elif self.action_type == 'link_existing':
if not self.partner_id:
raise UserError(_('Please select an existing contact.'))
repair.client_id = self.partner_id
return repair._create_linked_sale_order()

View File

@@ -1,28 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_ltc_repair_create_so_wizard_form" model="ir.ui.view">
<field name="name">fusion.ltc.repair.create.so.wizard.form</field>
<field name="model">fusion.ltc.repair.create.so.wizard</field>
<field name="arch" type="xml">
<form string="Link Contact">
<group>
<field name="repair_id" invisible="1"/>
<field name="client_name"/>
<field name="action_type" widget="radio"/>
<field name="partner_id"
invisible="action_type != 'link_existing'"
required="action_type == 'link_existing'"/>
</group>
<footer>
<button name="action_confirm" type="object"
string="Confirm &amp; Create Sale Order"
class="btn-primary"/>
<button string="Cancel" class="btn-secondary"
special="cancel"/>
</footer>
</form>
</field>
</record>
</odoo>

View File

@@ -34,11 +34,11 @@ class ReadyForSubmissionWizard(models.TransientModel):
# Client References (may already be filled)
client_ref_1 = fields.Char(
string='Client Reference 1',
help='First client reference number (e.g., PO number)',
help='First two letters of the client\'s first name and last two letters of their last name. Example: John Doe = JODO',
)
client_ref_2 = fields.Char(
string='Client Reference 2',
help='Second client reference number',
help='Last four digits of the client\'s health card number. Example: 1234',
)
# Reason for Application

View File

@@ -59,7 +59,7 @@
<field name="claim_authorization_date"/>
</group>
<group string="Client References">
<field name="client_ref_1" placeholder="e.g., DOJO"/>
<field name="client_ref_1" placeholder="e.g., JODO"/>
<field name="client_ref_2" placeholder="e.g., 1234"/>
</group>
</group>

View File

@@ -0,0 +1,92 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
from datetime import timedelta
from odoo import models, fields, api, _
from odoo.exceptions import UserError
class SendPage11Wizard(models.TransientModel):
_name = 'fusion_claims.send.page11.wizard'
_description = 'Send Page 11 for Remote Signing'
sale_order_id = fields.Many2one(
'sale.order', string='Sale Order',
required=True, readonly=True,
)
signer_email = fields.Char(string='Recipient Email', required=True)
signer_type = fields.Selection([
('client', 'Client (Self)'),
('spouse', 'Spouse'),
('parent', 'Parent'),
('legal_guardian', 'Legal Guardian'),
('poa', 'Power of Attorney'),
('public_trustee', 'Public Trustee'),
], string='Signer Type', default='client', required=True)
signer_name = fields.Char(string='Signer Name', required=True)
custom_message = fields.Text(
string='Personal Message',
help='Optional message to include in the signing request email.',
)
expiry_days = fields.Integer(
string='Link Valid For (days)', default=7, required=True,
)
client_name = fields.Char(string='Client', readonly=True)
case_ref = fields.Char(string='Case Reference', readonly=True)
@api.model
def default_get(self, fields_list):
res = super().default_get(fields_list)
active_id = self.env.context.get('active_id')
if not active_id:
return res
order = self.env['sale.order'].browse(active_id)
res['sale_order_id'] = order.id
res['client_name'] = order.partner_id.name or ''
res['case_ref'] = order.name or ''
res['signer_name'] = order.partner_id.name or ''
res['signer_email'] = order.partner_id.email or ''
return res
def action_send(self):
"""Create a signing request and send the email."""
self.ensure_one()
if not self.signer_email:
raise UserError(_("Please enter the recipient's email address."))
if self.expiry_days < 1:
raise UserError(_("Expiry must be at least 1 day."))
request = self.env['fusion.page11.sign.request'].create({
'sale_order_id': self.sale_order_id.id,
'signer_email': self.signer_email,
'signer_type': self.signer_type,
'signer_name': self.signer_name,
'custom_message': self.custom_message,
'expiry_date': fields.Datetime.now() + timedelta(days=self.expiry_days),
'consent_signed_by': 'applicant' if self.signer_type == 'client' else 'agent',
'signer_relationship': dict(self._fields['signer_type'].selection).get(
self.signer_type, ''
) if self.signer_type != 'client' else '',
})
request._send_signing_email()
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': _('Page 11 Signing Request Sent'),
'message': _(
'Signing request has been sent to %s.',
self.signer_email,
),
'type': 'success',
'sticky': False,
'next': {'type': 'ir.actions.act_window_close'},
},
}

View File

@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="view_send_page11_wizard_form" model="ir.ui.view">
<field name="name">fusion_claims.send.page11.wizard.form</field>
<field name="model">fusion_claims.send.page11.wizard</field>
<field name="arch" type="xml">
<form string="Send Page 11 for Signing">
<group>
<group string="Case Information">
<field name="sale_order_id" invisible="1"/>
<field name="client_name"/>
<field name="case_ref"/>
</group>
<group string="Recipient">
<field name="signer_name"/>
<field name="signer_email" widget="email"/>
<field name="signer_type"/>
<field name="expiry_days"/>
</group>
</group>
<group string="Personal Message (Optional)">
<field name="custom_message" nolabel="1" placeholder="Add a personal note to include in the email..."/>
</group>
<footer>
<button name="action_send" type="object" string="Send Signing Request" class="btn-primary"/>
<button string="Cancel" class="btn-secondary" special="cancel"/>
</footer>
</form>
</field>
</record>
<record id="action_send_page11_wizard" model="ir.actions.act_window">
<field name="name">Request Page 11 Signature</field>
<field name="res_model">fusion_claims.send.page11.wizard</field>
<field name="view_mode">form</field>
<field name="target">new</field>
<field name="context">{'default_sale_order_id': active_id}</field>
</record>
</odoo>

View File

@@ -61,6 +61,18 @@ class StatusChangeReasonWizard(models.TransientModel):
help='Select the reason ADP denied the funding',
)
# ==========================================================================
# WITHDRAWAL INTENT (for 'withdrawn' status)
# ==========================================================================
withdrawal_intent = fields.Selection(
selection=[
('cancel', 'Cancel Application'),
('resubmit', 'Withdraw for Correction & Resubmission'),
],
string='What would you like to do after withdrawal?',
default='resubmit',
)
reason = fields.Text(
string='Reason / Additional Details',
help='Please provide additional details for this status change.',
@@ -181,8 +193,10 @@ class StatusChangeReasonWizard(models.TransientModel):
}
header_color, bg_color, border_color = status_colors.get(new_status, ('#17a2b8', '#f0f9ff', '#bee5eb'))
# For on_hold, also store the previous status and hold date
# Build initial update vals
update_vals = {'x_fc_adp_application_status': new_status}
if new_status == 'withdrawn':
update_vals['x_fc_previous_status_before_withdrawal'] = self.previous_status
# =================================================================
# REJECTED: ADP rejected submission (within 24 hours)
@@ -261,7 +275,7 @@ class StatusChangeReasonWizard(models.TransientModel):
# Don't post message here - _send_on_hold_email() will post the message
message_body = None
elif new_status == 'withdrawn':
# Don't post message here - _send_withdrawal_email() will post the message
# Handled entirely below based on withdrawal_intent
message_body = None
elif new_status == 'cancelled':
# Cancelled has its own detailed message posted later
@@ -302,10 +316,129 @@ class StatusChangeReasonWizard(models.TransientModel):
order._send_correction_needed_email(reason=reason)
# =================================================================
# WITHDRAWN: Send email notification to all parties
# WITHDRAWN: Branch based on withdrawal intent
# =================================================================
if new_status == 'withdrawn':
order._send_withdrawal_email(reason=reason)
intent = self.withdrawal_intent
if intent == 'cancel':
# ---------------------------------------------------------
# WITHDRAW & CANCEL: Cancel invoices + SO
# ---------------------------------------------------------
cancelled_invoices = []
cancelled_so = False
# Cancel related invoices first
invoices = order.invoice_ids.filtered(lambda inv: inv.state != 'cancel')
for invoice in invoices:
try:
inv_msg = Markup(f'''
<div class="alert alert-danger" role="alert">
<h5 class="alert-heading"><i class="fa fa-ban"></i> Invoice Cancelled (Withdrawal)</h5>
<ul>
<li><strong>Related Order:</strong> {order.name}</li>
<li><strong>Cancelled By:</strong> {user_name}</li>
<li><strong>Date:</strong> {change_date}</li>
</ul>
<hr>
<p class="mb-0"><strong>Reason:</strong> {reason}</p>
</div>
''')
invoice.message_post(
body=inv_msg,
message_type='notification',
subtype_xmlid='mail.mt_note',
)
if invoice.state == 'posted':
invoice.button_draft()
invoice.button_cancel()
cancelled_invoices.append(invoice.name)
except Exception as e:
warn_msg = Markup(f'''
<div class="alert alert-warning" role="alert">
<p class="mb-0"><i class="fa fa-exclamation-triangle"></i> <strong>Warning:</strong> Could not cancel invoice {invoice.name}</p>
<p class="mb-0 small">{str(e)}</p>
</div>
''')
order.message_post(
body=warn_msg,
message_type='notification',
subtype_xmlid='mail.mt_note',
)
# Cancel the sale order itself
if order.state not in ('cancel', 'done'):
try:
order._action_cancel()
cancelled_so = True
except Exception as e:
warn_msg = Markup(f'''
<div class="alert alert-warning" role="alert">
<p class="mb-0"><i class="fa fa-exclamation-triangle"></i> <strong>Warning:</strong> Could not cancel sale order</p>
<p class="mb-0 small">{str(e)}</p>
</div>
''')
order.message_post(
body=warn_msg,
message_type='notification',
subtype_xmlid='mail.mt_note',
)
# Build cancellation summary
invoice_list_html = ''
if cancelled_invoices:
invoice_items = ''.join([f'<li>{inv}</li>' for inv in cancelled_invoices])
invoice_list_html = f'<li><strong>Invoices Cancelled:</strong><ul>{invoice_items}</ul></li>'
so_status = 'Cancelled' if cancelled_so else 'Not applicable'
summary_msg = Markup(f'''
<div class="alert alert-danger" role="alert">
<h5 class="alert-heading"><i class="fa fa-ban"></i> Application Withdrawn &amp; Cancelled</h5>
<ul>
<li><strong>Withdrawn By:</strong> {user_name}</li>
<li><strong>Date:</strong> {change_date}</li>
<li><strong>Sale Order:</strong> {so_status}</li>
{invoice_list_html}
</ul>
<hr>
<p class="mb-0"><strong>Reason:</strong> {reason}</p>
</div>
''')
order.message_post(
body=summary_msg,
message_type='notification',
subtype_xmlid='mail.mt_note',
)
order._send_withdrawal_email(reason=reason, intent='cancel')
else:
# ---------------------------------------------------------
# WITHDRAW & RESUBMIT: Return to ready_submission
# ---------------------------------------------------------
order.with_context(skip_status_validation=True).write({
'x_fc_adp_application_status': 'ready_submission',
'x_fc_previous_status_before_withdrawal': self.previous_status,
})
resubmit_msg = Markup(f'''
<div class="alert alert-info" role="alert">
<h5 class="alert-heading"><i class="fa fa-undo"></i> Application Withdrawn for Correction</h5>
<ul>
<li><strong>Withdrawn By:</strong> {user_name}</li>
<li><strong>Date:</strong> {change_date}</li>
<li><strong>Status Returned To:</strong> Ready for Submission</li>
</ul>
<hr>
<p class="mb-0"><strong>Reason:</strong> {reason}</p>
<p class="mb-0 mt-2"><i class="fa fa-info-circle"></i> Make corrections and click <strong>Submit Application</strong> to resubmit.</p>
</div>
''')
order.message_post(
body=resubmit_msg,
message_type='notification',
subtype_xmlid='mail.mt_note',
)
order._send_withdrawal_email(reason=reason, intent='resubmit')
# =================================================================
# ON HOLD: Send email notification to all parties

View File

@@ -26,7 +26,7 @@
<div class="alert alert-info mb-3 rounded-0" role="alert" invisible="new_status != 'withdrawn'"
style="margin: -16px -16px 16px -16px; padding: 12px 16px;">
<i class="fa fa-undo me-2"/> You are about to <strong>Withdraw</strong> this application. Please provide the reason for withdrawal.
<i class="fa fa-undo me-2"/> You are about to <strong>Withdraw</strong> this application. Please select what you would like to do after withdrawal.
</div>
<div class="alert alert-secondary mb-3 rounded-0" role="alert" invisible="new_status != 'on_hold'"
@@ -45,6 +45,26 @@
</div>
<div class="px-3 pb-3">
<!-- WITHDRAWAL INTENT (for 'withdrawn' status) -->
<group invisible="new_status != 'withdrawn'">
<group>
<field name="withdrawal_intent"
required="new_status == 'withdrawn'"
widget="radio"
options="{'horizontal': false}"/>
</group>
</group>
<div class="alert alert-danger mt-2 mb-3" role="alert"
invisible="new_status != 'withdrawn' or withdrawal_intent != 'cancel'">
<i class="fa fa-exclamation-triangle me-2"/>
This will <strong>permanently cancel</strong> the sale order and all related invoices. This action cannot be undone.
</div>
<div class="alert alert-info mt-2 mb-3" role="alert"
invisible="new_status != 'withdrawn' or withdrawal_intent != 'resubmit'">
<i class="fa fa-info-circle me-2"/>
The application will return to <strong>Ready for Submission</strong> status. You can make corrections and resubmit.
</div>
<!-- REJECTION REASON (for 'rejected' status) -->
<group invisible="new_status != 'rejected'">
<group>