Files
Odoo-Modules/fusion_claims/models/sale_order.py
Nexa Admin 431052920e 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
2026-03-11 16:19:52 +00:00

8055 lines
351 KiB
Python

# -*- coding: utf-8 -*-
# Copyright 2024-2025 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
# Part of the Fusion Claim Assistant product family.
import logging
import re
from markupsafe import Markup
from odoo import models, fields, api
from odoo.exceptions import ValidationError, UserError
_logger = logging.getLogger(__name__)
class SaleOrder(models.Model):
_name = 'sale.order'
_inherit = ['sale.order', 'fusion_claims.adp.posting.schedule.mixin', 'fusion.email.builder.mixin']
@property
def _rec_names_search(self):
return ['name', 'partner_id.name']
@api.depends('name', 'partner_id.name')
def _compute_display_name(self):
for order in self:
name = order.name or ''
if order.partner_id and order.partner_id.name:
name = f"{name} - {order.partner_id.name}"
order.display_name = name
# ==========================================================================
# FIELD FLAGS
# ==========================================================================
x_fc_is_adp_sale = fields.Boolean(
compute='_compute_is_adp_sale',
store=True,
string='Is ADP Sale',
help='True only for ADP or ADP/ODSP sale types',
)
# ==========================================================================
# INVOICE COUNT FIELDS (Separate ADP and Client invoices)
# ==========================================================================
x_fc_adp_invoice_count = fields.Integer(
compute='_compute_invoice_counts',
string='ADP Invoices',
)
x_fc_client_invoice_count = fields.Integer(
compute='_compute_invoice_counts',
string='Client Invoices',
)
@api.depends('x_fc_adp_invoice_id', 'x_fc_client_invoice_id')
def _compute_invoice_counts(self):
"""Compute separate counts for ADP and Client invoices.
Uses x_fc_source_sale_order_id for direct linking instead of invoice_ids
which only works when invoice lines have sale_line_ids.
Also includes manually mapped invoices from x_fc_adp_invoice_id and x_fc_client_invoice_id.
"""
AccountMove = self.env['account.move'].sudo()
for order in self:
# Search for invoices directly linked to this order via x_fc_source_sale_order_id
adp_invoices = AccountMove.search([
('x_fc_source_sale_order_id', '=', order.id),
('x_fc_adp_invoice_portion', '=', 'adp'),
('state', '!=', 'cancel'),
])
client_invoices = AccountMove.search([
('x_fc_source_sale_order_id', '=', order.id),
('x_fc_adp_invoice_portion', '=', 'client'),
('state', '!=', 'cancel'),
])
# Also include manually mapped invoices from Invoice Mapping section
if order.x_fc_adp_invoice_id and order.x_fc_adp_invoice_id.state != 'cancel':
adp_invoices |= order.x_fc_adp_invoice_id
if order.x_fc_client_invoice_id and order.x_fc_client_invoice_id.state != 'cancel':
client_invoices |= order.x_fc_client_invoice_id
order.x_fc_adp_invoice_count = len(adp_invoices)
order.x_fc_client_invoice_count = len(client_invoices)
# ==========================================================================
# MOD INVOICE COUNT FIELDS (Separate MOD and Client invoices)
# ==========================================================================
x_fc_mod_invoice_count = fields.Integer(
compute='_compute_mod_invoice_counts',
string='MOD Invoices',
)
x_fc_mod_client_invoice_count = fields.Integer(
compute='_compute_mod_invoice_counts',
string='Client Invoices (MOD)',
)
@api.depends('order_line')
def _compute_mod_invoice_counts(self):
"""Compute separate counts for MOD and Client invoices on MOD cases."""
AccountMove = self.env['account.move'].sudo()
for order in self:
if not order.x_fc_is_mod_sale:
order.x_fc_mod_invoice_count = 0
order.x_fc_mod_client_invoice_count = 0
continue
# MOD portion invoices (full or adp = MOD's share)
mod_invoices = AccountMove.search([
('x_fc_source_sale_order_id', '=', order.id),
('x_fc_adp_invoice_portion', 'in', ('full', 'adp')),
('state', '!=', 'cancel'),
])
# Client portion invoices
client_invoices = AccountMove.search([
('x_fc_source_sale_order_id', '=', order.id),
('x_fc_adp_invoice_portion', '=', 'client'),
('state', '!=', 'cancel'),
])
order.x_fc_mod_invoice_count = len(mod_invoices)
order.x_fc_mod_client_invoice_count = len(client_invoices)
def action_view_mod_invoices(self):
"""Open MOD portion invoices for this order."""
self.ensure_one()
invoices = self.env['account.move'].sudo().search([
('x_fc_source_sale_order_id', '=', self.id),
('x_fc_adp_invoice_portion', 'in', ('full', 'adp')),
('state', '!=', 'cancel'),
])
action = {
'name': 'MOD Invoices',
'type': 'ir.actions.act_window',
'res_model': 'account.move',
'view_mode': 'list,form',
'domain': [('id', 'in', invoices.ids)],
'context': {'default_move_type': 'out_invoice'},
}
if len(invoices) == 1:
action['view_mode'] = 'form'
action['res_id'] = invoices.id
return action
def action_view_mod_client_invoices(self):
"""Open Client portion invoices for MOD cases."""
self.ensure_one()
invoices = self.env['account.move'].sudo().search([
('x_fc_source_sale_order_id', '=', self.id),
('x_fc_adp_invoice_portion', '=', 'client'),
('state', '!=', 'cancel'),
])
action = {
'name': 'Client Invoices',
'type': 'ir.actions.act_window',
'res_model': 'account.move',
'view_mode': 'list,form',
'domain': [('id', 'in', invoices.ids)],
'context': {'default_move_type': 'out_invoice'},
}
if len(invoices) == 1:
action['view_mode'] = 'form'
action['res_id'] = invoices.id
return action
# ==========================================================================
# VENDOR BILL LINKING (for audit trail)
# ==========================================================================
x_fc_vendor_bill_ids = fields.Many2many(
'account.move',
'sale_order_vendor_bill_rel',
'sale_order_id',
'move_id',
string='Vendor Bills',
domain=[('move_type', '=', 'in_invoice')],
help='Vendor bills/invoices linked to this sales order for audit trail purposes.',
)
x_fc_vendor_bill_count = fields.Integer(
compute='_compute_vendor_bill_count',
string='Vendor Bills',
)
@api.depends('x_fc_vendor_bill_ids')
def _compute_vendor_bill_count(self):
"""Compute count of linked vendor bills."""
for order in self:
order.x_fc_vendor_bill_count = len(order.x_fc_vendor_bill_ids)
def action_view_vendor_bills(self):
"""Open view of linked vendor bills."""
self.ensure_one()
action = {
'name': 'Vendor Bills',
'type': 'ir.actions.act_window',
'res_model': 'account.move',
'view_mode': 'list,form',
'domain': [('id', 'in', self.x_fc_vendor_bill_ids.ids)],
'context': {'default_move_type': 'in_invoice'},
}
if len(self.x_fc_vendor_bill_ids) == 1:
action['view_mode'] = 'form'
action['res_id'] = self.x_fc_vendor_bill_ids.id
return action
# ==========================================================================
# SUBMISSION HISTORY (for tracking all submissions/resubmissions to ADP)
# ==========================================================================
x_fc_submission_history_ids = fields.One2many(
'fusion.submission.history',
'sale_order_id',
string='Submission History',
help='History of all submissions and resubmissions to ADP',
)
x_fc_submission_count = fields.Integer(
string='Submissions',
compute='_compute_submission_count',
)
@api.depends('x_fc_submission_history_ids')
def _compute_submission_count(self):
"""Compute the number of submissions for this order."""
for order in self:
order.x_fc_submission_count = len(order.x_fc_submission_history_ids)
def action_view_submission_history(self):
"""Open the submission history for this order."""
self.ensure_one()
return {
'name': 'Submission History',
'type': 'ir.actions.act_window',
'res_model': 'fusion.submission.history',
'view_mode': 'list,form',
'domain': [('sale_order_id', '=', self.id)],
'context': {'default_sale_order_id': self.id},
}
# ==========================================================================
# TECHNICIAN TASKS
# ==========================================================================
x_fc_technician_task_ids = fields.One2many(
'fusion.technician.task',
'sale_order_id',
string='Technician Tasks',
)
x_fc_technician_task_count = fields.Integer(
string='Tasks',
compute='_compute_technician_task_count',
)
@api.depends('x_fc_technician_task_ids')
def _compute_technician_task_count(self):
for order in self:
order.x_fc_technician_task_count = len(order.x_fc_technician_task_ids)
def action_view_technician_tasks(self):
"""Open the technician tasks linked to this order."""
self.ensure_one()
action = {
'name': 'Technician Tasks',
'type': 'ir.actions.act_window',
'res_model': 'fusion.technician.task',
'view_mode': 'list,form',
'domain': [('sale_order_id', '=', self.id)],
'context': {'default_sale_order_id': self.id},
}
if len(self.x_fc_technician_task_ids) == 1:
action['view_mode'] = 'form'
action['res_id'] = self.x_fc_technician_task_ids.id
return action
# LOANER EQUIPMENT TRACKING
# ==========================================================================
x_fc_loaner_checkout_ids = fields.One2many(
'fusion.loaner.checkout',
'sale_order_id',
string='Loaner Checkouts',
help='Loaner equipment checked out for this order',
)
x_fc_loaner_count = fields.Integer(
string='Loaners',
compute='_compute_loaner_count',
)
x_fc_active_loaner_count = fields.Integer(
string='Active Loaners',
compute='_compute_loaner_count',
)
x_fc_has_overdue_loaner = fields.Boolean(
string='Has Overdue Loaner',
compute='_compute_loaner_count',
help='True if any active loaner is past its expected return date',
)
@api.depends('x_fc_loaner_checkout_ids', 'x_fc_loaner_checkout_ids.state',
'x_fc_loaner_checkout_ids.expected_return_date')
def _compute_loaner_count(self):
"""Compute loaner counts and overdue status for this order."""
today = fields.Date.today()
for order in self:
active = order.x_fc_loaner_checkout_ids.filtered(
lambda l: l.state in ('checked_out', 'overdue', 'rental_pending')
)
order.x_fc_loaner_count = len(order.x_fc_loaner_checkout_ids)
order.x_fc_active_loaner_count = len(active)
order.x_fc_has_overdue_loaner = any(
l.state == 'overdue' or (l.expected_return_date and l.expected_return_date < today)
for l in active
)
def action_view_loaners(self):
"""Open the loaner checkouts for this order."""
self.ensure_one()
action = {
'name': 'Loaner Checkouts',
'type': 'ir.actions.act_window',
'res_model': 'fusion.loaner.checkout',
'view_mode': 'tree,form',
'domain': [('sale_order_id', '=', self.id)],
'context': {'default_sale_order_id': self.id},
}
if len(self.x_fc_loaner_checkout_ids) == 1:
action['view_mode'] = 'form'
action['res_id'] = self.x_fc_loaner_checkout_ids.id
return action
def action_checkout_loaner(self):
"""Open the loaner checkout wizard."""
self.ensure_one()
return {
'name': 'Checkout Loaner',
'type': 'ir.actions.act_window',
'res_model': 'fusion.loaner.checkout.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'default_sale_order_id': self.id,
'default_partner_id': self.partner_id.id,
'default_authorizer_id': self.x_fc_authorizer_id.id if self.x_fc_authorizer_id else False,
},
}
def action_checkin_loaner(self):
"""Open the return wizard for the active loaner on this order."""
self.ensure_one()
active_loaners = self.x_fc_loaner_checkout_ids.filtered(
lambda l: l.state in ('checked_out', 'overdue', 'rental_pending')
)
if not active_loaners:
raise UserError("No active loaners to check in for this order.")
if len(active_loaners) == 1:
return active_loaners.action_return()
# Multiple active loaners - show the list so user can pick which one to return
return {
'name': 'Return Loaner',
'type': 'ir.actions.act_window',
'res_model': 'fusion.loaner.checkout',
'view_mode': 'tree,form',
'domain': [('id', 'in', active_loaners.ids)],
'target': 'current',
}
def action_ready_for_delivery(self):
"""Open the task scheduling form to schedule a delivery task.
Instead of a separate wizard, this opens the full technician task
form pre-filled with delivery defaults. When the task is saved,
the sale order is automatically marked as Ready for Delivery.
"""
self.ensure_one()
# Validate the order can be marked ready for delivery
if not self._is_adp_sale():
raise UserError("Ready for Delivery is only available for ADP sales.")
valid_statuses = ('approved', 'approved_deduction')
if self.x_fc_early_delivery:
valid_statuses = ('submitted', 'accepted', 'approved', 'approved_deduction')
if self.x_fc_adp_application_status not in valid_statuses:
if self.x_fc_early_delivery:
raise UserError(
"For early delivery, the application must be at least Submitted.\n"
f"Current status: {dict(self._fields['x_fc_adp_application_status'].selection).get(self.x_fc_adp_application_status)}"
)
else:
raise UserError(
"The application must be Approved before marking Ready for Delivery.\n"
"To deliver before approval, check 'Early Delivery' first.\n"
f"Current status: {dict(self._fields['x_fc_adp_application_status'].selection).get(self.x_fc_adp_application_status)}"
)
return {
'name': 'Schedule Delivery Task',
'type': 'ir.actions.act_window',
'res_model': 'fusion.technician.task',
'view_mode': 'form',
'target': 'new',
'context': {
'default_task_type': 'delivery',
'default_sale_order_id': self.id,
'default_partner_id': self.partner_id.id,
'default_pod_required': True,
'mark_ready_for_delivery': True,
},
}
@api.depends('x_fc_sale_type')
def _compute_is_adp_sale(self):
"""Compute if this is an ADP sale - only ADP or ADP/ODSP sale types."""
for order in self:
order.x_fc_is_adp_sale = order._is_adp_sale()
# ==========================================================================
# SALE TYPE AND CLIENT TYPE FIELDS
# ==========================================================================
x_fc_sale_type = fields.Selection(
selection=[
('adp', 'ADP'),
('adp_odsp', 'ADP/ODSP'),
('odsp', 'ODSP'),
('wsib', 'WSIB'),
('direct_private', 'Direct/Private'),
('insurance', 'Insurance'),
('march_of_dimes', 'March of Dimes'),
('muscular_dystrophy', 'Muscular Dystrophy'),
('other', 'Others'),
('rental', 'Rentals'),
('hardship', 'Hardship Funding'),
],
string='Sale Type',
index=True,
copy=True,
tracking=True,
help='Type of sale for billing purposes. This field determines the workflow and billing rules.',
)
x_fc_sale_type_locked = fields.Boolean(
string='Sale Type Locked',
compute='_compute_sale_type_locked',
help='Sale type is locked after application is submitted to ADP',
)
@api.depends('x_fc_adp_application_status')
def _compute_sale_type_locked(self):
"""Sale type is locked once application is submitted to ADP."""
locked_statuses = [
'submitted', 'accepted', 'rejected', 'resubmitted',
'approved', 'approved_deduction', 'ready_bill', 'billed', 'case_closed',
]
for order in self:
order.x_fc_sale_type_locked = order.x_fc_adp_application_status in locked_statuses
x_fc_client_type = fields.Selection(
selection=[
('REG', 'REG'),
('ODS', 'ODS'),
('OWP', 'OWP'),
('ACS', 'ACS'),
('LTC', 'LTC'),
('SEN', 'SEN'),
('CCA', 'CCA'),
],
string='Client Type',
tracking=True,
help='Client type for ADP portion calculations. REG = 75%/25%, others = 100%/0%',
)
# Authorizer Required field - only for certain sale types
# For: odsp, direct_private, insurance, other (NOT rental)
x_fc_authorizer_required = fields.Selection(
selection=[
('yes', 'Yes'),
('no', 'No'),
],
string='Authorizer Required?',
help='For ODSP, Direct/Private, Insurance, Others - specify if an authorizer is needed.',
)
# Computed field to determine if authorizer should be shown
x_fc_show_authorizer = fields.Boolean(
compute='_compute_show_authorizer',
string='Show Authorizer',
)
@api.depends('x_fc_sale_type', 'x_fc_authorizer_required')
def _compute_show_authorizer(self):
"""Compute whether to show the authorizer field based on sale type and authorizer_required."""
optional_auth_types = ('odsp', 'direct_private', 'insurance', 'other')
always_auth_types = ('adp', 'adp_odsp', 'wsib', 'march_of_dimes', 'muscular_dystrophy')
for order in self:
sale_type = order.x_fc_sale_type
if sale_type == 'rental':
order.x_fc_show_authorizer = False
elif sale_type in always_auth_types:
order.x_fc_show_authorizer = True
elif sale_type in optional_auth_types:
order.x_fc_show_authorizer = order.x_fc_authorizer_required == 'yes'
else:
order.x_fc_show_authorizer = False
# Computed field to determine if "Authorizer Required?" question should be shown
x_fc_show_authorizer_question = fields.Boolean(
compute='_compute_show_authorizer_question',
string='Show Authorizer Question',
)
@api.depends('x_fc_sale_type')
def _compute_show_authorizer_question(self):
"""Compute whether to show the 'Authorizer Required?' field."""
optional_auth_types = ('odsp', 'direct_private', 'insurance', 'other')
for order in self:
order.x_fc_show_authorizer_question = order.x_fc_sale_type in optional_auth_types
# ==========================================================================
# MARCH OF DIMES FIELDS
# ==========================================================================
x_fc_mod_status = fields.Selection(
selection=[
('need_to_schedule', 'Schedule Assessment'),
('assessment_scheduled', 'Assessment Booked'),
('assessment_completed', 'Assessment Done'),
('processing_drawings', 'Processing Drawing'),
('quote_submitted', 'Quote Sent'),
('awaiting_funding', 'Awaiting Funding'),
('funding_approved', 'Approved'),
('funding_denied', 'Denied'),
('contract_received', 'PCA Received'),
('in_production', 'In Production'),
('project_complete', 'Complete'),
('pod_submitted', 'POD Sent'),
('case_closed', 'Closed'),
('on_hold', 'On Hold'),
('cancelled', 'Cancelled'),
],
string='MOD Status',
default='need_to_schedule',
tracking=True,
group_expand='_expand_mod_statuses',
help='March of Dimes case workflow status',
)
@api.model
def _expand_mod_statuses(self, states, domain):
"""Return the main MOD workflow statuses for kanban columns.
Always shows core statuses; special statuses (funding_denied, on_hold,
cancelled) only appear when records exist in them."""
main = [
'need_to_schedule', 'assessment_scheduled', 'assessment_completed',
'processing_drawings', 'quote_submitted', 'awaiting_funding',
'funding_approved', 'contract_received', 'in_production',
'project_complete', 'pod_submitted', 'case_closed',
]
result = list(main)
for s in (states or []):
if s and s not in result:
result.append(s)
return result
# --- Case contacts (per-order MOD contacts) ---
x_fc_case_handler = fields.Many2one(
'res.partner',
string='MOD Case Handler',
tracking=True,
help='March of Dimes case handler / counsellor (e.g. Barrier Free Design Counsellor)',
)
x_fc_case_worker = fields.Many2one(
'res.partner',
string='Case Worker',
tracking=True,
help='Case worker assigned to this order',
)
x_fc_mod_contact_name = fields.Char(
string='MOD Contact Person',
tracking=True,
help='Legacy field - kept for backwards compatibility',
)
x_fc_mod_contact_email = fields.Char(
string='Case Worker Email',
tracking=True,
help='Case worker email - assigned after funding approval. '
'Completion photos and POD are sent to this email.',
)
x_fc_mod_contact_phone = fields.Char(
string='Case Worker Phone',
tracking=True,
)
# --- Case identifiers ---
x_fc_case_reference = fields.Char(
string='HVMP Reference Number',
tracking=True,
help='March of Dimes HVMP Reference Number (e.g. HVW38845)',
)
x_fc_mod_vendor_code = fields.Char(
string='MOD Vendor Code',
tracking=True,
help='Vendor code assigned by March of Dimes (e.g. TRD0001662)',
)
# --- Key dates ---
x_fc_case_submitted = fields.Date(
string='Quote Submitted Date',
tracking=True,
help='Legacy field - kept for backwards compatibility',
)
x_fc_case_approved = fields.Date(
string='Funding Approved Date',
tracking=True,
help='Date funding was approved by March of Dimes',
)
x_fc_estimated_completion_date = fields.Date(
string='Estimated Completion Date',
tracking=True,
help='Estimated project completion date. Auto-calculated from weeks if funding is approved.',
)
x_fc_mod_estimated_weeks = fields.Integer(
string='Est. Completion (Weeks)',
tracking=True,
help='Estimated completion time in weeks from funding approval date.',
)
@api.onchange('x_fc_mod_estimated_weeks')
def _onchange_mod_estimated_weeks(self):
"""When weeks change, compute the completion date from approval date."""
if self.x_fc_mod_estimated_weeks and self.x_fc_mod_estimated_weeks > 0:
from datetime import timedelta
base = self.x_fc_case_approved or fields.Date.today()
self.x_fc_estimated_completion_date = base + timedelta(weeks=self.x_fc_mod_estimated_weeks)
@api.onchange('x_fc_estimated_completion_date')
def _onchange_mod_estimated_completion_date(self):
"""When date changes, compute weeks from approval date."""
if self.x_fc_estimated_completion_date:
base = self.x_fc_case_approved or fields.Date.today()
delta = self.x_fc_estimated_completion_date - base
weeks = max(1, delta.days // 7)
self.x_fc_mod_estimated_weeks = weeks
# --- MOD Documents ---
x_fc_mod_drawing = fields.Binary(
string='Drawing',
attachment=True,
help='Technical drawing for the accessibility modification',
)
x_fc_mod_drawing_filename = fields.Char(string='Drawing Filename')
x_fc_mod_initial_photos = fields.Binary(
string='Initial Photos',
attachment=True,
help='Photos taken during the initial assessment',
)
x_fc_mod_initial_photos_filename = fields.Char(string='Initial Photos Filename')
x_fc_mod_pca_document = fields.Binary(
string='PCA Document',
attachment=True,
help='Payment Commitment Agreement from March of Dimes',
)
x_fc_mod_pca_filename = fields.Char(string='PCA Filename')
x_fc_mod_proof_of_delivery = fields.Binary(
string='Proof of Delivery',
attachment=True,
help='Signed proof of delivery and installation document',
)
x_fc_mod_pod_filename = fields.Char(string='POD Filename')
x_fc_mod_initial_payment_amount = fields.Monetary(
string='Initial Payment Amount',
currency_field='currency_id',
help='Amount received as initial payment from March of Dimes',
)
x_fc_mod_initial_payment_date = fields.Date(
string='Initial Payment Date',
help='Date the initial payment was received',
)
x_fc_mod_final_payment_amount = fields.Monetary(
string='Final Payment Amount',
currency_field='currency_id',
help='Final payment amount received from March of Dimes',
)
x_fc_mod_final_payment_date = fields.Date(
string='Final Payment Date',
help='Date the final payment was received',
)
x_fc_mod_completion_photos = fields.Binary(
string='Completion Photos',
attachment=True,
help='Photos of the completed installation',
)
x_fc_mod_completion_photos_filename = fields.Char(string='Completion Photos Filename')
# Trail computed fields for MOD documents
x_fc_mod_trail_has_drawing = fields.Boolean(compute='_compute_mod_trail', string='Has Drawing')
x_fc_mod_trail_has_initial_photos = fields.Boolean(compute='_compute_mod_trail', string='Has Initial Photos')
x_fc_mod_trail_has_pca = fields.Boolean(compute='_compute_mod_trail', string='Has PCA')
x_fc_mod_trail_has_pod = fields.Boolean(compute='_compute_mod_trail', string='Has POD')
x_fc_mod_trail_has_completion_photos = fields.Boolean(compute='_compute_mod_trail', string='Has Completion Photos')
@api.depends('x_fc_mod_drawing', 'x_fc_mod_initial_photos', 'x_fc_mod_pca_document',
'x_fc_mod_proof_of_delivery', 'x_fc_mod_completion_photos')
def _compute_mod_trail(self):
for order in self:
order.x_fc_mod_trail_has_drawing = bool(order.x_fc_mod_drawing)
order.x_fc_mod_trail_has_initial_photos = bool(order.x_fc_mod_initial_photos)
order.x_fc_mod_trail_has_pca = bool(order.x_fc_mod_pca_document)
order.x_fc_mod_trail_has_pod = bool(order.x_fc_mod_proof_of_delivery)
order.x_fc_mod_trail_has_completion_photos = bool(order.x_fc_mod_completion_photos)
# --- PCA terms ---
x_fc_mod_project_completion_date = fields.Date(
string='PCA Completion Deadline',
tracking=True,
help='Project Completion Date as stated in the PCA',
)
x_fc_mod_payment_commitment = fields.Monetary(
string='Payment Commitment',
tracking=True,
currency_field='currency_id',
help='Legacy field - kept for backwards compatibility',
)
# --- MOD Funding ---
x_fc_mod_approved_amount = fields.Monetary(
string='MOD Approved Amount',
currency_field='currency_id',
tracking=True,
help='Amount approved by March of Dimes',
)
x_fc_mod_approval_type = fields.Selection(
selection=[('full', 'Full Approval'), ('partial', 'Partial Approval')],
string='Approval Type',
tracking=True,
)
# --- Product type and production stage ---
x_fc_mod_product_type = fields.Selection(
selection=[
('stairlift', 'Stairlift'),
('vpl', 'Vertical Platform Lift / Porch Lift'),
('ceiling_lift', 'Ceiling Lift'),
('ramp', 'Custom Ramp'),
('bathroom', 'Bathroom Modification'),
('other', 'Other'),
],
string='Product Type',
tracking=True,
help='Type of accessibility product/modification for this project',
)
x_fc_mod_production_status = fields.Selection(
selection=[
# --- Stairlift stages ---
('sl_photo_survey_booked', 'Photo Survey Booked'),
('sl_photo_survey_done', 'Photo Survey Completed'),
('sl_sent_to_engineering', 'Sent to Engineering'),
('sl_engineering_received', 'Engineering Drawing Received'),
('sl_drawing_signing', 'Drawing Signing & Acceptance'),
('sl_in_production', 'Stairlift in Production'),
('sl_payment_processing', 'Payment Processing for Manufacturer'),
('sl_shipping', 'Stairlift Shipping'),
('sl_received', 'Stairlift Received'),
('sl_install_scheduled', 'Installation Scheduled'),
('sl_install_complete', 'Installation Complete'),
# --- VPL / Porch Lift stages ---
('vpl_survey_complete', 'Final Survey & Marking Complete'),
('vpl_lift_ordered', 'Lift Ordered'),
('vpl_concrete_poured', 'Concrete Base Poured'),
('vpl_concrete_curing', 'Concrete Curing'),
('vpl_install_complete', 'Lift & Safety Gate Installed'),
# --- Ceiling Lift stages ---
('cl_marking_done', 'Lift Marking Completed'),
('cl_anchors_installed', 'Anchors Installed'),
('cl_curing', 'Epoxy Curing (24 hrs)'),
('cl_track_installed', 'Track & Lift Installed'),
('cl_safety_tested', 'Safety & Deflection Tests Passed'),
('cl_ready_for_use', 'Ready for Use'),
# --- Ramp stages ---
('rp_permit_check', 'Checking Municipality Permit'),
('rp_permit_obtained', 'Permit Obtained'),
('rp_ordered', 'Ramp Ordered'),
('rp_received', 'Ramp Received in Warehouse'),
('rp_install_scheduled', 'Installation Scheduled'),
('rp_install_complete', 'Installation Complete'),
# --- Bathroom Modification stages ---
('br_demolition', 'Demolition of Existing Bathroom'),
('br_design_changes', 'Final Design Changes Discussed'),
('br_construction', 'Construction in Progress'),
('br_construction_done', 'Construction Finished'),
('br_safety_check', 'Safety Check Complete'),
('br_ready_for_use', 'Ready for Use'),
# --- Common ---
('completed', 'Stage Completed'),
('on_hold', 'On Hold'),
],
string='Production Stage',
tracking=True,
help='Detailed production/installation stage for the product',
)
# --- Follow-up tracking ---
x_fc_mod_last_followup_date = fields.Date(
string='Last Follow-up Date',
help='Date of the last follow-up call or email',
)
x_fc_mod_next_followup_date = fields.Date(
string='Next Follow-up Date',
help='Scheduled date for the next follow-up',
)
x_fc_mod_followup_count = fields.Integer(
string='Follow-up Count',
default=0,
help='Number of follow-up attempts made',
)
x_fc_mod_followup_escalated = fields.Boolean(
string='Follow-up Escalated',
default=False,
help='True if an automatic follow-up email was sent because activity was not completed',
)
# --- MOD Audit Trail dates ---
x_fc_mod_assessment_scheduled_date = fields.Date(string='Assessment Scheduled', tracking=True)
x_fc_mod_assessment_completed_date = fields.Date(string='Assessment Completed', tracking=True)
x_fc_mod_drawing_submitted_date = fields.Date(string='Drawing Submitted', tracking=True)
x_fc_mod_application_submitted_date = fields.Date(
string='Application Submitted to MOD',
tracking=True,
help='Date the application/proposal was submitted to March of Dimes for funding review',
)
x_fc_mod_pca_received_date = fields.Date(string='PCA Received', tracking=True)
x_fc_mod_production_started_date = fields.Date(string='Production Started', tracking=True)
x_fc_mod_project_completed_date = fields.Date(string='Project Completed', tracking=True)
x_fc_mod_pod_submitted_date = fields.Date(string='POD Submitted', tracking=True)
x_fc_mod_case_closed_date = fields.Date(string='Case Closed', tracking=True)
# Trail computed booleans
x_fc_mod_trail_assessment_done = fields.Boolean(compute='_compute_mod_audit_trail')
x_fc_mod_trail_drawing_done = fields.Boolean(compute='_compute_mod_audit_trail')
x_fc_mod_trail_app_submitted = fields.Boolean(compute='_compute_mod_audit_trail')
x_fc_mod_trail_funding_approved = fields.Boolean(compute='_compute_mod_audit_trail')
x_fc_mod_trail_pca_received = fields.Boolean(compute='_compute_mod_audit_trail')
x_fc_mod_trail_production_started = fields.Boolean(compute='_compute_mod_audit_trail')
x_fc_mod_trail_project_completed = fields.Boolean(compute='_compute_mod_audit_trail')
x_fc_mod_trail_pod_sent = fields.Boolean(compute='_compute_mod_audit_trail')
x_fc_mod_trail_case_closed = fields.Boolean(compute='_compute_mod_audit_trail')
@api.depends('x_fc_mod_assessment_completed_date', 'x_fc_mod_drawing_submitted_date',
'x_fc_mod_application_submitted_date', 'x_fc_case_approved',
'x_fc_mod_pca_received_date', 'x_fc_mod_production_started_date',
'x_fc_mod_project_completed_date', 'x_fc_mod_pod_submitted_date',
'x_fc_mod_case_closed_date')
def _compute_mod_audit_trail(self):
for order in self:
order.x_fc_mod_trail_assessment_done = bool(order.x_fc_mod_assessment_completed_date)
order.x_fc_mod_trail_drawing_done = bool(order.x_fc_mod_drawing_submitted_date)
order.x_fc_mod_trail_app_submitted = bool(order.x_fc_mod_application_submitted_date)
order.x_fc_mod_trail_funding_approved = bool(order.x_fc_case_approved)
order.x_fc_mod_trail_pca_received = bool(order.x_fc_mod_pca_received_date)
order.x_fc_mod_trail_production_started = bool(order.x_fc_mod_production_started_date)
order.x_fc_mod_trail_project_completed = bool(order.x_fc_mod_project_completed_date)
order.x_fc_mod_trail_pod_sent = bool(order.x_fc_mod_pod_submitted_date)
order.x_fc_mod_trail_case_closed = bool(order.x_fc_mod_case_closed_date)
# --- Computed helpers ---
x_fc_show_mod_fields = fields.Boolean(
compute='_compute_show_mod_fields',
string='Show MOD Fields',
)
x_fc_is_mod_sale = fields.Boolean(
compute='_compute_is_mod_sale',
string='Is MOD Sale',
)
@api.depends('x_fc_sale_type')
def _compute_show_mod_fields(self):
"""Compute whether to show March of Dimes case fields."""
for order in self:
order.x_fc_show_mod_fields = order.x_fc_sale_type == 'march_of_dimes'
@api.depends('x_fc_sale_type')
def _compute_is_mod_sale(self):
"""Compute if this is a March of Dimes sale."""
for order in self:
order.x_fc_is_mod_sale = order.x_fc_sale_type == 'march_of_dimes'
def _is_mod_sale(self):
"""Helper: check if this order is a March of Dimes sale."""
self.ensure_one()
return self.x_fc_sale_type == 'march_of_dimes'
# ==========================================================================
# ODSP (Ontario Disability Support Program) FIELDS
# ==========================================================================
x_fc_odsp_division = fields.Selection(
selection=[
('standard', 'ODSP Standard'),
('sa_mobility', 'SA Mobility'),
('ontario_works', 'Ontario Works'),
],
string='ODSP Division',
tracking=True,
help='ODSP sub-division handling this case',
)
x_fc_is_odsp_sale = fields.Boolean(
compute='_compute_is_odsp_sale',
store=True,
string='Is ODSP Sale',
help='True when sale type is ODSP or ADP-ODSP',
)
x_fc_odsp_member_id = fields.Char(
related='partner_id.x_fc_odsp_member_id',
string='ODSP Member ID',
readonly=False,
store=True,
help='ODSP Member ID from contact (editable per order)',
)
x_fc_odsp_office_id = fields.Many2one(
'res.partner',
string='ODSP Office',
tracking=True,
domain="[('x_fc_contact_type', '=', 'odsp_office')]",
help='ODSP office handling this case',
)
x_fc_odsp_case_worker_name = fields.Char(
string='ODSP Case Worker',
tracking=True,
help='Case worker name for this order',
)
# --- SA Mobility status ---
x_fc_sa_status = fields.Selection(
selection=[
('quotation', 'Quotation'),
('form_ready', 'SA Form Ready'),
('submitted_to_sa', 'Submitted to SA Mobility'),
('pre_approved', 'Pre-Approved'),
('ready_delivery', 'Ready for Delivery'),
('delivered', 'Delivered'),
('pod_submitted', 'POD Submitted'),
('payment_received', 'Payment Received'),
('case_closed', 'Case Closed'),
('on_hold', 'On Hold'),
('cancelled', 'Cancelled'),
('denied', 'Denied'),
],
string='SA Mobility Status',
default='quotation',
tracking=True,
group_expand='_expand_sa_statuses',
)
@api.model
def _expand_sa_statuses(self, states, domain):
main = [
'quotation', 'form_ready', 'submitted_to_sa',
'pre_approved', 'ready_delivery', 'delivered',
'pod_submitted', 'payment_received', 'case_closed',
]
result = list(main)
for s in (states or []):
if s and s not in result:
result.append(s)
return result
# --- Standard ODSP status ---
x_fc_odsp_std_status = fields.Selection(
selection=[
('quotation', 'Quotation'),
('submitted_to_odsp', 'Submitted to ODSP'),
('pre_approved', 'Pre-Approved'),
('ready_delivery', 'Ready for Delivery'),
('delivered', 'Delivered'),
('pod_submitted', 'POD Submitted'),
('payment_received', 'Payment Received'),
('case_closed', 'Case Closed'),
('on_hold', 'On Hold'),
('cancelled', 'Cancelled'),
('denied', 'Denied'),
],
string='ODSP Status',
default='quotation',
tracking=True,
group_expand='_expand_odsp_std_statuses',
)
@api.model
def _expand_odsp_std_statuses(self, states, domain):
main = [
'quotation', 'submitted_to_odsp',
'pre_approved', 'ready_delivery', 'delivered',
'pod_submitted', 'payment_received', 'case_closed',
]
result = list(main)
for s in (states or []):
if s and s not in result:
result.append(s)
return result
# --- Ontario Works status ---
x_fc_ow_status = fields.Selection(
selection=[
('quotation', 'Quotation'),
('documents_ready', 'Documents Ready'),
('submitted_to_ow', 'Submitted to Ontario Works'),
('payment_received', 'Payment Received'),
('ready_delivery', 'Ready for Delivery'),
('delivered', 'Delivered'),
('case_closed', 'Case Closed'),
('on_hold', 'On Hold'),
('cancelled', 'Cancelled'),
('denied', 'Denied'),
],
string='Ontario Works Status',
default='quotation',
tracking=True,
group_expand='_expand_ow_statuses',
)
@api.model
def _expand_ow_statuses(self, states, domain):
main = [
'quotation', 'documents_ready', 'submitted_to_ow',
'payment_received', 'ready_delivery', 'delivered',
'case_closed',
]
result = list(main)
for s in (states or []):
if s and s not in result:
result.append(s)
return result
# --- Division-to-status field mapping ---
_ODSP_STATUS_FIELD_MAP = {
'sa_mobility': 'x_fc_sa_status',
'standard': 'x_fc_odsp_std_status',
'ontario_works': 'x_fc_ow_status',
}
def _get_odsp_status_field(self):
"""Return the status field name for this order's division."""
self.ensure_one()
return self._ODSP_STATUS_FIELD_MAP.get(
self.x_fc_odsp_division, 'x_fc_odsp_std_status')
def _get_odsp_status(self):
"""Return the current division-specific status value."""
self.ensure_one()
return getattr(self, self._get_odsp_status_field(), '') or ''
# --- ODSP computed helpers ---
x_fc_show_odsp_fields = fields.Boolean(
compute='_compute_show_odsp_fields',
string='Show ODSP Fields',
)
@api.depends('x_fc_sale_type')
def _compute_is_odsp_sale(self):
"""Compute if this is an ODSP sale."""
for order in self:
order.x_fc_is_odsp_sale = order.x_fc_sale_type in ('odsp', 'adp_odsp')
@api.depends('x_fc_sale_type')
def _compute_show_odsp_fields(self):
"""Compute whether to show ODSP case fields."""
for order in self:
order.x_fc_show_odsp_fields = order.x_fc_sale_type in ('odsp', 'adp_odsp')
def _is_odsp_sale(self):
"""Helper: check if this order is an ODSP sale."""
self.ensure_one()
return self.x_fc_sale_type in ('odsp', 'adp_odsp')
@api.onchange('partner_id')
def _onchange_partner_odsp_case_worker(self):
"""Auto-populate ODSP case worker from partner when partner changes."""
if self.partner_id and self.partner_id.x_fc_case_worker_id:
self.x_fc_odsp_case_worker_name = self.partner_id.x_fc_case_worker_id.name
# --- SA Mobility form data (persisted for wizard reuse) ---
x_fc_sa_relationship = fields.Selection([
('self', 'Self'), ('spouse', 'Spouse'), ('dependent', 'Dependent'),
], string='SA Relationship', default='self')
x_fc_sa_device_type = fields.Selection([
('manual_wheelchair', 'Manual Wheelchair'),
('high_tech_wheelchair', 'High Technology Wheelchair'),
('mobility_scooter', 'Mobility Scooter'),
('walker', 'Walker'),
('lifting_device', 'Lifting Device'),
('other', 'Other'),
], string='SA Device Type')
x_fc_sa_device_other = fields.Char(string='SA Device Other Description')
x_fc_sa_serial_number = fields.Char(string='SA Serial Number')
x_fc_sa_year = fields.Char(string='SA Year')
x_fc_sa_make = fields.Char(string='SA Make')
x_fc_sa_model = fields.Char(string='SA Model')
x_fc_sa_warranty = fields.Boolean(string='SA Warranty in Effect')
x_fc_sa_warranty_desc = fields.Char(string='SA Warranty Description')
x_fc_sa_after_hours = fields.Boolean(string='SA After-hours Work')
x_fc_sa_request_type = fields.Selection([
('batteries', 'Batteries'), ('repair', 'Repair / Maintenance'),
], string='SA Request Type', default='repair')
x_fc_sa_notes = fields.Text(string='SA Notes / Comments')
# --- SA Mobility signature fields ---
x_fc_sa_client_name = fields.Char(
string='SA Client Name (Printed)',
help='Client printed name on SA Mobility form Page 2',
)
x_fc_sa_client_signature = fields.Binary(
string='SA Client Signature',
help='Client signature image on SA Mobility form Page 2',
)
x_fc_sa_client_signed_date = fields.Date(
string='SA Signed Date',
)
x_fc_sa_signed_form = fields.Binary(
string='SA Signed Form',
help='Final signed SA Mobility PDF',
)
x_fc_sa_signed_form_filename = fields.Char(
string='SA Signed Form Filename',
)
x_fc_sa_physical_signed_copy = fields.Binary(
string='Physical Signed Copy',
attachment=True,
help='Upload a scanned/photographed copy of the physically signed SA Mobility form. '
'Use this when the client signs a paper copy instead of the digital e-signature.',
)
x_fc_sa_physical_signed_copy_filename = fields.Char(
string='Physical Copy Filename',
)
# --- SA Mobility approval form fields ---
x_fc_sa_approval_form = fields.Binary(
string='SA Approval Form',
help='ODSP approval PDF uploaded during pre-approval',
)
x_fc_sa_approval_form_filename = fields.Char(
string='SA Approval Form Filename',
)
x_fc_sa_signature_page = fields.Integer(
string='Signature Page',
default=2,
help='Page number in approval form where signature should be placed (1-indexed)',
)
# --- Ontario Works document fields ---
x_fc_ow_discretionary_form = fields.Binary(
string='Discretionary Benefits Form',
attachment=True,
help='Auto-populated when the Discretionary Benefits form is generated via wizard.',
)
x_fc_ow_discretionary_form_filename = fields.Char(
string='Discretionary Form Filename',
)
x_fc_ow_authorizer_letter = fields.Binary(
string='Authorizer Letter',
attachment=True,
help='Optional authorizer letter for this Ontario Works case.',
)
x_fc_ow_authorizer_letter_filename = fields.Char(
string='Authorizer Letter Filename',
)
# --- Standard ODSP document fields ---
x_fc_odsp_approval_document = fields.Binary(
string='ODSP Approval Document',
attachment=True,
help='Upload the approval document received from ODSP.',
)
x_fc_odsp_approval_document_filename = fields.Char(
string='Approval Document Filename',
)
x_fc_odsp_authorizer_letter = fields.Binary(
string='Authorizer Letter',
attachment=True,
help='Optional authorizer letter for this ODSP case.',
)
x_fc_odsp_authorizer_letter_filename = fields.Char(
string='Authorizer Letter Filename',
)
def action_open_sa_mobility_wizard(self):
"""Open the SA Mobility form filling wizard."""
self.ensure_one()
return {
'name': 'SA Mobility Form',
'type': 'ir.actions.act_window',
'res_model': 'fusion_claims.sa.mobility.wizard',
'view_mode': 'form',
'target': 'new',
'context': {'active_id': self.id},
}
def action_open_discretionary_wizard(self):
"""Open the Discretionary Benefits form filling wizard."""
self.ensure_one()
return {
'name': 'Discretionary Benefits Form',
'type': 'ir.actions.act_window',
'res_model': 'fusion_claims.discretionary.benefit.wizard',
'view_mode': 'form',
'target': 'new',
'context': {'active_id': self.id},
}
def action_open_submit_to_odsp_wizard(self):
"""Open the Submit to ODSP wizard (quotation + authorizer letter)."""
self.ensure_one()
return {
'name': 'Submit to ODSP',
'type': 'ir.actions.act_window',
'res_model': 'fusion_claims.submit.to.odsp.wizard',
'view_mode': 'form',
'target': 'new',
'context': {'active_id': self.id},
}
# --- ODSP workflow step actions ---
def _odsp_advance_status(self, new_status, log_message):
"""Advance the division-specific ODSP status and log to chatter."""
self.ensure_one()
field = self._get_odsp_status_field()
setattr(self, field, new_status)
self.message_post(body=log_message, message_type='comment')
def action_odsp_submitted(self):
self.ensure_one()
self._odsp_advance_status('submitted_to_odsp', "Application submitted to ODSP.")
def action_odsp_submitted_ow(self):
self.ensure_one()
self._odsp_advance_status('submitted_to_ow', "Application submitted to Ontario Works.")
def action_odsp_pre_approved(self):
self.ensure_one()
if self.x_fc_odsp_division in ('sa_mobility', 'standard'):
return {
'type': 'ir.actions.act_window',
'name': 'Upload ODSP Approval Document',
'res_model': 'fusion_claims.odsp.pre.approved.wizard',
'view_mode': 'form',
'target': 'new',
'context': {'active_id': self.id},
}
self._odsp_advance_status('pre_approved', "ODSP pre-approval received.")
def action_odsp_ready_delivery(self):
self.ensure_one()
if self.x_fc_odsp_division == 'sa_mobility' and self.x_fc_sa_approval_form:
return {
'type': 'ir.actions.act_window',
'name': 'Ready for Delivery - Signature Setup',
'res_model': 'fusion_claims.odsp.ready.delivery.wizard',
'view_mode': 'form',
'target': 'new',
'context': {'active_id': self.id},
}
if self.x_fc_odsp_division in ('ontario_works', 'standard'):
return {
'name': 'Schedule Delivery Task',
'type': 'ir.actions.act_window',
'res_model': 'fusion.technician.task',
'view_mode': 'form',
'target': 'new',
'context': {
'default_task_type': 'delivery',
'default_sale_order_id': self.id,
'default_partner_id': self.partner_id.id,
'default_pod_required': True,
'mark_odsp_ready_for_delivery': True,
},
}
self._odsp_advance_status('ready_delivery', "Order is ready for delivery.")
def action_odsp_delivered(self):
self.ensure_one()
if self.x_fc_odsp_division == 'sa_mobility':
has_signed_form = self.x_fc_sa_signed_form or self.x_fc_sa_physical_signed_copy
if has_signed_form:
self._odsp_advance_status('delivered',
"Delivery completed. SA form is signed.")
else:
self._odsp_advance_status('delivered', "Delivery completed.")
else:
self._odsp_advance_status('delivered', "Delivery completed.")
def action_odsp_pod_submitted(self):
self.ensure_one()
if self.x_fc_odsp_division == 'sa_mobility':
self._sa_mobility_submit_documents()
elif self.x_fc_odsp_division == 'standard':
self._odsp_std_submit_documents()
self._odsp_advance_status('pod_submitted', "Proof of Delivery submitted to ODSP.")
def _sa_mobility_submit_documents(self):
"""Collect signed SA form, internal POD, and invoice, then email to SA Mobility."""
self.ensure_one()
import base64
Attachment = self.env['ir.attachment'].sudo()
att_ids = []
att_names = []
signed_field = 'x_fc_sa_signed_form' if self.x_fc_sa_signed_form else (
'x_fc_sa_physical_signed_copy' if self.x_fc_sa_physical_signed_copy else None)
if signed_field:
att_id = self._get_and_prepare_field_attachment(signed_field, 'Signed SA Form')
if att_id:
att_ids.append(att_id)
att_names.append('Signed SA Form')
# 2. Internal POD -- generate on-the-fly from the standard report
try:
pod_pdf, pod_fname = self._get_sa_pod_pdf()
att = Attachment.create({
'name': pod_fname,
'type': 'binary',
'datas': base64.b64encode(pod_pdf),
'res_model': 'sale.order',
'res_id': self.id,
})
att_ids.append(att.id)
att_names.append('Proof of Delivery')
except Exception as e:
_logger.warning("Could not generate POD PDF for %s: %s", self.name, e)
# 3. Invoice PDF -- generate from the latest posted invoice
invoices = self.invoice_ids.filtered(lambda inv: inv.state == 'posted')
if invoices:
invoice = invoices[0]
try:
report = self.env.ref('account.account_invoices')
pdf_content, _ct = report._render_qweb_pdf(report.id, [invoice.id])
first, last = self._get_client_name_parts()
att = Attachment.create({
'name': f'{first}_{last}_Invoice_{invoice.name}.pdf',
'type': 'binary',
'datas': base64.b64encode(pdf_content),
'res_model': 'sale.order',
'res_id': self.id,
'mimetype': 'application/pdf',
})
att_ids.append(att.id)
att_names.append(f'Invoice ({invoice.name})')
except Exception as e:
_logger.warning("Could not generate invoice PDF for %s: %s", self.name, e)
self._send_sa_mobility_completion_email(attachment_ids=att_ids)
if att_names:
self.message_post(
body=Markup(
'<div class="alert alert-success">'
'<strong>Documents submitted to SA Mobility</strong>'
'<ul class="mb-0 mt-1">'
+ ''.join(f'<li>{n}</li>' for n in att_names)
+ '</ul></div>'
),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
def _odsp_std_submit_documents(self):
"""Standard ODSP: collect approval doc, POD, and invoice, then email to ODSP office."""
self.ensure_one()
import base64
Attachment = self.env['ir.attachment'].sudo()
att_ids = []
att_names = []
att_id = self._get_and_prepare_field_attachment('x_fc_odsp_approval_document', 'ODSP Approval Document')
if att_id:
att_ids.append(att_id)
att_names.append('ODSP Approval Document')
# 2. Internal POD
try:
pod_pdf, pod_fname = self._get_sa_pod_pdf()
att = Attachment.create({
'name': pod_fname,
'type': 'binary',
'datas': base64.b64encode(pod_pdf),
'res_model': 'sale.order',
'res_id': self.id,
})
att_ids.append(att.id)
att_names.append('Proof of Delivery')
except Exception as e:
_logger.warning("Could not generate POD PDF for %s: %s", self.name, e)
# 3. Invoice PDF
invoices = self.invoice_ids.filtered(lambda inv: inv.state == 'posted')
if not invoices:
if self.state != 'sale':
self.action_confirm()
invoices = self._create_invoices()
invoices.write({'x_fc_source_sale_order_id': self.id})
if invoices:
invoice = invoices[0]
try:
report = self.env.ref('account.account_invoices')
pdf_content, _ct = report._render_qweb_pdf(report.id, [invoice.id])
first, last = self._get_client_name_parts()
att = Attachment.create({
'name': f'{first}_{last}_Invoice_{invoice.name}.pdf',
'type': 'binary',
'datas': base64.b64encode(pdf_content),
'res_model': 'sale.order',
'res_id': self.id,
'mimetype': 'application/pdf',
})
att_ids.append(att.id)
att_names.append(f'Invoice ({invoice.name})')
except Exception as e:
_logger.warning("Could not generate invoice PDF for %s: %s", self.name, e)
self._send_odsp_submission_email(attachment_ids=att_ids)
if att_names:
self.message_post(
body=Markup(
'<div class="alert alert-success">'
'<strong>Documents submitted to ODSP</strong>'
'<ul class="mb-0 mt-1">'
+ ''.join(f'<li>{n}</li>' for n in att_names)
+ '</ul></div>'
),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
def action_odsp_payment_received(self):
self.ensure_one()
if self.x_fc_odsp_division == 'ontario_works':
return self._ow_payment_create_invoice()
self._odsp_advance_status('payment_received', "Payment received from ODSP.")
def _ow_payment_create_invoice(self):
"""Ontario Works: create invoice from SO, advance status, open invoice."""
self.ensure_one()
if self.state != 'sale':
self.action_confirm()
invoice = self._create_invoices()
invoice.write({'x_fc_source_sale_order_id': self.id})
self._odsp_advance_status('payment_received',
"Ontario Works payment confirmed. Invoice %s created." % invoice.name)
return {
'type': 'ir.actions.act_window',
'name': 'Invoice',
'res_model': 'account.move',
'view_mode': 'form',
'res_id': invoice.id,
'target': 'current',
}
def action_odsp_close_case(self):
self.ensure_one()
self._odsp_advance_status('case_closed', "ODSP case closed.")
def action_odsp_on_hold(self):
self.ensure_one()
self._odsp_advance_status('on_hold', "ODSP case placed on hold.")
def action_odsp_resume(self):
"""Resume from on_hold -- go back to the previous logical status."""
self.ensure_one()
self._odsp_advance_status('quotation', "ODSP case resumed.")
def action_odsp_denied(self):
self.ensure_one()
self._odsp_advance_status('denied', "ODSP application denied.")
def action_sign_sa_mobility_form(self):
"""Overlay client signature onto Page 2 of the approved SA Mobility form.
Uses the PDFTemplateFiller overlay approach:
- Reads the last attached SA Mobility form
- Overlays client printed name, signature image, and date on Page 2
- Stores result in x_fc_sa_signed_form
"""
self.ensure_one()
if not self.x_fc_sa_client_signature:
from odoo.exceptions import UserError
raise UserError("Client signature is required to sign the SA Mobility form.")
# Find the most recent SA Mobility form attachment
attachment = self.env['ir.attachment'].search([
('res_model', '=', 'sale.order'),
('res_id', '=', self.id),
('name', 'like', 'SA_Mobility_Form_'),
], order='create_date desc', limit=1)
if not attachment:
from odoo.exceptions import UserError
raise UserError("No SA Mobility form found. Please fill the form first.")
import base64
from io import BytesIO
try:
from reportlab.pdfgen import canvas as rl_canvas
from reportlab.lib.utils import ImageReader
from odoo.tools.pdf import PdfFileReader, PdfFileWriter
except ImportError:
from odoo.exceptions import UserError
raise UserError("Required PDF libraries not available.")
# Read the existing filled form
pdf_bytes = base64.b64decode(attachment.datas)
original = PdfFileReader(BytesIO(pdf_bytes))
output = PdfFileWriter()
num_pages = original.getNumPages()
for page_idx in range(num_pages):
page = original.getPage(page_idx)
if page_idx == 1: # Page 2 (0-based index)
page_w = float(page.mediaBox.getWidth())
page_h = float(page.mediaBox.getHeight())
overlay_buf = BytesIO()
c = rl_canvas.Canvas(overlay_buf, pagesize=(page_w, page_h))
# Text103 area - client printed name (upper confirmation line)
if self.x_fc_sa_client_name:
c.setFont('Helvetica', 11)
c.drawString(180, page_h - 180, self.x_fc_sa_client_name)
# Text104 area - printed name on lower line
if self.x_fc_sa_client_name:
c.setFont('Helvetica', 11)
c.drawString(72, page_h - 560, self.x_fc_sa_client_name)
# Text105 area - date
if self.x_fc_sa_client_signed_date:
from odoo import fields as odoo_fields
date_str = odoo_fields.Date.to_string(self.x_fc_sa_client_signed_date)
c.setFont('Helvetica', 11)
c.drawString(350, page_h - 560, date_str)
# Signature image overlay on the signature line
if self.x_fc_sa_client_signature:
sig_data = base64.b64decode(self.x_fc_sa_client_signature)
sig_image = ImageReader(BytesIO(sig_data))
c.drawImage(sig_image, 72, page_h - 540, width=200, height=50,
preserveAspectRatio=True, mask='auto')
c.save()
overlay_buf.seek(0)
overlay_pdf = PdfFileReader(overlay_buf)
page.mergePage(overlay_pdf.getPage(0))
output.addPage(page)
result_buf = BytesIO()
output.write(result_buf)
signed_pdf = result_buf.getvalue()
filename = f'SA_Mobility_Signed_{self.name}.pdf'
self.write({
'x_fc_sa_signed_form': base64.b64encode(signed_pdf),
'x_fc_sa_signed_form_filename': filename,
})
self.message_post(
body="SA Mobility form signed by client: %s" % self.x_fc_sa_client_name,
message_type='comment',
)
return {'type': 'ir.actions.act_window_close'}
def _apply_pod_signature_to_approval_form(self):
"""Auto-overlay POD signature onto the ODSP approval form.
Uses the ODSP PDF Template (fusion.pdf.template, category=odsp) for
field positions, and the per-case signature page number.
"""
self.ensure_one()
if not all([
self.x_fc_odsp_division == 'sa_mobility',
self.x_fc_sa_approval_form,
self.x_fc_sa_signature_page,
self.x_fc_pod_signature,
]):
return
import base64
from odoo.addons.fusion_authorizer_portal.utils.pdf_filler import PDFTemplateFiller
tpl = self.env['fusion.pdf.template'].search([
('category', '=', 'odsp'), ('state', '=', 'active'),
], limit=1)
if not tpl:
_logger.warning("No active ODSP PDF template found for signing %s", self.name)
return
sig_page = self.x_fc_sa_signature_page or 2
fields_by_page = {}
for field in tpl.field_ids.filtered(lambda f: f.is_active):
page = sig_page
if page not in fields_by_page:
fields_by_page[page] = []
fields_by_page[page].append({
'field_name': field.name,
'field_key': field.field_key or field.name,
'pos_x': field.pos_x,
'pos_y': field.pos_y,
'width': field.width,
'height': field.height,
'field_type': field.field_type,
'font_size': field.font_size,
'font_name': field.font_name or 'Helvetica',
'text_align': field.text_align or 'left',
})
client_name = self.x_fc_pod_client_name or self.x_fc_sa_client_name or self.partner_id.name or ''
sign_date = self.x_fc_pod_signature_date or self.x_fc_sa_client_signed_date
context_data = {
'sa_client_name': client_name,
'sa_sign_date': sign_date.strftime('%b %d, %Y') if sign_date else '',
}
signatures = {
'sa_signature': base64.b64decode(self.x_fc_pod_signature),
}
pdf_bytes = base64.b64decode(self.x_fc_sa_approval_form)
try:
signed_pdf = PDFTemplateFiller.fill_template(
pdf_bytes, fields_by_page, context_data, signatures,
)
except Exception as e:
_logger.error("Failed to apply signature to approval form for %s: %s", self.name, e)
return
filename = f'SA_Approval_Signed_{self.name}.pdf'
self.with_context(skip_pod_signature_hook=True).write({
'x_fc_sa_signed_form': base64.b64encode(signed_pdf),
'x_fc_sa_signed_form_filename': filename,
})
att = self.env['ir.attachment'].create({
'name': filename,
'type': 'binary',
'datas': base64.b64encode(signed_pdf),
'res_model': 'sale.order',
'res_id': self.id,
'mimetype': 'application/pdf',
})
self.message_post(
body="POD signature applied to ODSP approval form (page %s)." % sig_page,
message_type='comment',
attachment_ids=[att.id],
)
_logger.info("POD signature applied to approval form for %s", self.name)
# ==========================================================================
# ADP CLAIM FIELDS
# ==========================================================================
x_fc_claim_number = fields.Char(
string='Claim Number',
tracking=True,
copy=False,
help='ADP Claim Number assigned after submission',
)
x_fc_client_ref_1 = fields.Char(
string='Client Reference 1',
help='First two letters of the client\'s first name and last two letters of their last name. Example: John Doe = JODO',
)
x_fc_client_ref_2 = fields.Char(
string='Client Reference 2',
help='Last four digits of the client\'s health card number. Example: 1234',
)
x_fc_adp_delivery_date = fields.Date(
string='ADP Delivery Date',
help='Date the product was delivered to the client (for ADP billing)',
)
x_fc_service_start_date = fields.Date(
string='Service Start Date',
help='Service period start date (optional, for rentals/services)',
)
x_fc_service_end_date = fields.Date(
string='Service End Date',
help='Service period end date (optional, for rentals/services)',
)
x_fc_authorizer_id = fields.Many2one(
'res.partner',
string='Authorizer',
help='Authorizer contact for this order',
domain="[('is_company', '=', False)]",
)
x_fc_primary_serial = fields.Char(
string='Primary Serial Number',
help='Primary serial number for the order (header level). '
'Line-level serials are tracked on individual order lines.',
copy=False,
)
# ==========================================================================
# ADP WORKFLOW STATUS (Legacy - keeping for backward compatibility)
# ==========================================================================
x_fc_adp_status = fields.Selection(
selection=[
('quote', 'Quote'),
('submitted', 'Submitted to ADP'),
('approved', 'ADP Approved'),
('client_paid', 'Client Paid (25%)'),
('delivered', 'Delivered'),
('billed', 'Billed to ADP (75%)'),
('closed', 'Closed'),
],
string='ADP Status (Legacy)',
default='quote',
tracking=True,
help='Legacy status field - use x_fc_adp_application_status instead',
)
# ==========================================================================
# ADP APPLICATION STATUS (New comprehensive status field)
# ==========================================================================
x_fc_adp_application_status = fields.Selection(
selection=[
('quotation', 'Quotation Stage'),
('assessment_scheduled', 'Assessment Scheduled'),
('assessment_completed', 'Assessment Completed'),
('waiting_for_application', 'Waiting for Application'),
('application_received', 'Application Received'),
('ready_submission', 'Ready for Submission'),
('submitted', 'Application Submitted'),
('accepted', 'Accepted by ADP'), # New: ADP accepted submission (within 24 hours)
('rejected', 'Rejected by ADP'), # New: ADP rejected submission (errors, need correction)
('resubmitted', 'Application Resubmitted'),
('needs_correction', 'Application Needs Correction'),
('approved', 'Application Approved'),
('approved_deduction', 'Approved with Deduction'),
('ready_delivery', 'Ready for Delivery'), # After approved OR when early delivery
('denied', 'Application Denied'),
('withdrawn', 'Application Withdrawn'),
('ready_bill', 'Ready to Bill'),
('billed', 'Billed to ADP'),
('case_closed', 'Case Closed'),
('on_hold', 'On Hold'),
('cancelled', 'Cancelled'),
('expired', 'Application Expired'),
],
string='ADP Application Status',
default='quotation',
tracking=True,
copy=False,
group_expand='_expand_adp_application_statuses',
help='Comprehensive ADP application workflow status',
)
@api.model
def _expand_adp_application_statuses(self, states, domain):
"""Return the main workflow statuses for kanban columns.
Always shows core statuses; special statuses (on_hold, denied, etc.)
only appear when records exist in them."""
main = [
'quotation', 'assessment_scheduled', 'waiting_for_application',
'application_received', 'ready_submission', 'submitted',
'needs_correction', 'approved', 'ready_delivery',
'ready_bill', 'billed', 'case_closed',
]
# Also include any special status that currently has records
result = list(main)
for s in (states or []):
if s and s not in result:
result.append(s)
return result
x_fc_status_sequence = fields.Integer(
string='Status Sequence',
compute='_compute_status_sequence',
store=True,
index=True,
help='Numeric workflow order for sorting when grouping by status',
)
_STATUS_ORDER = {
'quotation': 10,
'assessment_scheduled': 20,
'assessment_completed': 30,
'waiting_for_application': 40,
'application_received': 50,
'ready_submission': 60,
'submitted': 70,
'accepted': 80,
'rejected': 85,
'resubmitted': 75,
'needs_correction': 65,
'approved': 90,
'approved_deduction': 91,
'ready_delivery': 95,
'ready_bill': 100,
'billed': 110,
'case_closed': 120,
'on_hold': 130,
'denied': 140,
'withdrawn': 150,
'cancelled': 160,
'expired': 170,
}
@api.depends('x_fc_adp_application_status')
def _compute_status_sequence(self):
for order in self:
order.x_fc_status_sequence = self._STATUS_ORDER.get(
order.x_fc_adp_application_status, 999
)
@api.model
def _read_group(self, domain, groupby=(), aggregates=(), having=(), offset=0, limit=None, order=None):
"""Override to sort groups by workflow order when grouping by ADP status."""
result = super()._read_group(
domain, groupby=groupby, aggregates=aggregates,
having=having, offset=offset, limit=limit, order=order,
)
if groupby and groupby[0] == 'x_fc_adp_application_status':
status_order = self._STATUS_ORDER
result = sorted(result, key=lambda r: status_order.get(r[0], 999))
return result
# ==========================================================================
# SERVICE FLAG (for service start/end date visibility)
# ==========================================================================
x_fc_has_service = fields.Boolean(
string='Is Service?',
default=False,
help='Check if this order includes a service component (shows service date fields)',
)
# ==========================================================================
# ON HOLD TRACKING
# ==========================================================================
x_fc_on_hold_date = fields.Date(
string='On Hold Since',
tracking=True,
help='Date when the application was put on hold',
)
x_fc_previous_status_before_hold = fields.Char(
string='Previous Status Before Hold',
help='Status before the application was put on hold (for resuming)',
)
x_fc_previous_status_before_withdrawal = fields.Char(
string='Status Before Withdrawal',
help='Records the status before withdrawal for audit trail.',
)
x_fc_status_before_delivery = fields.Char(
string='Status Before Delivery',
help='Status before the order was marked Ready for Delivery (for reverting if task cancelled)',
)
# ==========================================================================
# DELIVERY TECHNICIAN TRACKING
# ==========================================================================
x_fc_early_delivery = fields.Boolean(
string='Early Delivery',
default=False,
tracking=True,
help='Check if delivery will occur before ADP approval (client pays their portion upfront)',
)
x_fc_delivery_technician_ids = fields.Many2many(
'res.users',
'sale_order_delivery_technician_rel',
'sale_order_id',
'user_id',
string='Delivery Technicians',
tracking=True,
help='Field technicians assigned to deliver this order',
)
x_fc_ready_for_delivery_date = fields.Datetime(
string='Ready for Delivery Date',
tracking=True,
help='Date/time when the order was marked ready for delivery',
)
x_fc_scheduled_delivery_datetime = fields.Datetime(
string='Scheduled Delivery',
tracking=True,
help='Scheduled date and time for delivery',
)
# ==========================================================================
# REJECTION REASON TRACKING (Initial rejection by ADP - within 24 hours)
# ==========================================================================
x_fc_rejection_reason = fields.Selection(
selection=[
('name_correction', 'Name Correction Needed'),
('healthcard_correction', 'Health Card Correction Needed'),
('duplicate_claim', 'Duplicate Claim Exists'),
('xml_format_error', 'XML Format/Validation Error'),
('missing_info', 'Missing Required Information'),
('other', 'Other'),
],
string='Rejection Reason',
tracking=True,
help='Reason for initial rejection by ADP (within 24 hours of submission)',
)
x_fc_rejection_reason_other = fields.Text(
string='Rejection Details',
tracking=True,
help='Additional details when rejection reason is "Other"',
)
x_fc_rejection_date = fields.Date(
string='Rejection Date',
tracking=True,
help='Date when ADP rejected the submission',
)
x_fc_rejection_count = fields.Integer(
string='Rejection Count',
default=0,
help='Number of times this application has been rejected by ADP',
)
# ==========================================================================
# DENIAL REASON TRACKING (Funding denied after review - 2-3 weeks)
# ==========================================================================
x_fc_denial_reason = fields.Selection(
selection=[
('eligibility', 'Client Eligibility Issues'),
('recent_funding', 'Previous Funding Within 5 Years'),
('medical_justification', 'Insufficient Medical Justification'),
('equipment_not_covered', 'Equipment Not Covered by ADP'),
('documentation_incomplete', 'Documentation Incomplete'),
('other', 'Other'),
],
string='Denial Reason',
tracking=True,
help='Reason for denial of funding by ADP (after 2-3 week review)',
)
x_fc_denial_reason_other = fields.Text(
string='Denial Details',
tracking=True,
help='Additional details when denial reason is "Other"',
)
x_fc_denial_date = fields.Date(
string='Denial Date',
tracking=True,
help='Date when ADP denied the funding',
)
# ==========================================================================
# EMAIL NOTIFICATION TRACKING
# ==========================================================================
x_fc_application_reminder_sent = fields.Boolean(
string='Application Reminder Sent',
default=False,
copy=False,
help='Whether the first application reminder email has been sent',
)
x_fc_application_reminder_2_sent = fields.Boolean(
string='Application Reminder 2 Sent',
default=False,
copy=False,
help='Whether the second application reminder email has been sent',
)
x_fc_acceptance_reminder_sent = fields.Boolean(
string='Acceptance Reminder Sent',
default=False,
copy=False,
help='Whether the acceptance reminder email has been sent for submitted orders',
)
# ==========================================================================
# VALIDITY & EXPIRY TRACKING
# ==========================================================================
x_fc_assessment_validity_days = fields.Integer(
string='Assessment Validity (Days)',
compute='_compute_validity_expiry',
help='Days remaining before assessment expires (valid for 3 months)',
)
x_fc_assessment_expired = fields.Boolean(
string='Assessment Expired',
compute='_compute_validity_expiry',
help='True if assessment is more than 3 months old',
)
x_fc_approval_expiry_days = fields.Integer(
string='Approval Expiry (Days)',
compute='_compute_validity_expiry',
help='Days remaining before approval expires (valid for 6 months)',
)
x_fc_approval_expired = fields.Boolean(
string='Approval Expired',
compute='_compute_validity_expiry',
help='True if approval is more than 6 months old',
)
x_fc_billing_warning = fields.Boolean(
string='Billing Warning',
compute='_compute_validity_expiry',
help='True if more than 1 year since approval (verbal ADP permission needed)',
)
x_fc_show_expiry_card = fields.Boolean(
string='Show Expiry Card',
compute='_compute_validity_expiry',
help='True if expiry card should be shown',
)
@api.depends('x_fc_assessment_end_date', 'x_fc_claim_approval_date', 'x_fc_adp_application_status', 'x_fc_claim_number')
def _compute_validity_expiry(self):
"""Compute validity and expiry information for assessments and approvals."""
from datetime import date as date_class
today = date_class.today()
# Statuses that show expiry card
expiry_card_statuses = ['approved', 'approved_deduction', 'on_hold']
for order in self:
# Assessment validity (3 months = 90 days)
if order.x_fc_assessment_end_date:
days_since_assessment = (today - order.x_fc_assessment_end_date).days
order.x_fc_assessment_validity_days = max(0, 90 - days_since_assessment)
order.x_fc_assessment_expired = days_since_assessment > 90
else:
order.x_fc_assessment_validity_days = 0
order.x_fc_assessment_expired = False
# Approval expiry (6 months = 180 days)
if order.x_fc_claim_approval_date:
days_since_approval = (today - order.x_fc_claim_approval_date).days
order.x_fc_approval_expiry_days = max(0, 180 - days_since_approval)
order.x_fc_approval_expired = days_since_approval > 180
# Billing warning (1 year = 365 days)
order.x_fc_billing_warning = days_since_approval > 365
else:
order.x_fc_approval_expiry_days = 0
order.x_fc_approval_expired = False
order.x_fc_billing_warning = False
# Show expiry card for approved/approved_deduction/on_hold (with claim number)
status = order.x_fc_adp_application_status
if status in expiry_card_statuses and order.x_fc_claim_approval_date:
# For on_hold, only show if has claim number
if status == 'on_hold':
order.x_fc_show_expiry_card = bool(order.x_fc_claim_number)
else:
order.x_fc_show_expiry_card = True
else:
order.x_fc_show_expiry_card = False
# ==========================================================================
# WORKFLOW STAGE FLAGS (computed for view visibility)
# ==========================================================================
x_fc_stage_after_assessment_initiated = fields.Boolean(
compute='_compute_workflow_stages',
string='After Assessment Initiated Stage',
)
x_fc_stage_after_assessment_completed = fields.Boolean(
compute='_compute_workflow_stages',
string='After Assessment Completed Stage',
)
x_fc_stage_after_application_received = fields.Boolean(
compute='_compute_workflow_stages',
string='After Application Received Stage',
)
x_fc_stage_after_ready_submission = fields.Boolean(
compute='_compute_workflow_stages',
string='After Ready Submission Stage',
)
x_fc_stage_after_submitted = fields.Boolean(
compute='_compute_workflow_stages',
string='After Submitted Stage',
)
x_fc_stage_after_accepted = fields.Boolean(
compute='_compute_workflow_stages',
string='After Accepted Stage',
)
x_fc_stage_after_approved = fields.Boolean(
compute='_compute_workflow_stages',
string='After Approved Stage',
)
x_fc_stage_after_ready_bill = fields.Boolean(
compute='_compute_workflow_stages',
string='After Ready Bill Stage',
)
x_fc_stage_after_billed = fields.Boolean(
compute='_compute_workflow_stages',
string='After Billed Stage',
)
x_fc_requires_previous_funding = fields.Boolean(
compute='_compute_workflow_stages',
string='Requires Previous Funding Date',
)
@api.depends('x_fc_adp_application_status', 'x_fc_reason_for_application')
def _compute_workflow_stages(self):
"""Compute workflow stage flags for conditional visibility in views.
Terminal statuses (cancelled, denied, withdrawn, expired) should NOT make
later-stage fields required - only 'on_hold' preserves field requirements
since the case can resume.
"""
# Terminal statuses - these end the workflow, no further fields required
terminal_statuses = ['cancelled', 'denied', 'withdrawn', 'expired']
# On-hold preserves visibility but we handle it specially
# so fields remain visible but not required
# Define status groups - each list includes the starting status and all subsequent
after_assessment_initiated_statuses = [
'assessment_scheduled', 'assessment_completed', 'waiting_for_application',
'application_received',
'ready_submission', 'submitted', 'accepted', 'rejected', 'resubmitted', 'needs_correction',
'approved', 'approved_deduction',
'ready_bill', 'billed', 'case_closed', 'on_hold',
]
after_assessment_completed_statuses = [
'assessment_completed', 'waiting_for_application', 'application_received',
'ready_submission',
'submitted', 'accepted', 'rejected', 'resubmitted', 'needs_correction',
'approved', 'approved_deduction',
'ready_bill', 'billed', 'case_closed', 'on_hold',
]
after_application_received_statuses = [
'application_received', 'ready_submission', 'submitted', 'accepted', 'rejected',
'resubmitted', 'needs_correction', 'approved', 'approved_deduction',
'ready_bill', 'billed', 'case_closed', 'on_hold',
]
after_ready_submission_statuses = [
'ready_submission', 'submitted', 'accepted', 'rejected', 'resubmitted', 'needs_correction',
'approved', 'approved_deduction',
'ready_bill', 'billed', 'case_closed', 'on_hold',
]
after_submitted_statuses = [
'submitted', 'accepted', 'rejected', 'resubmitted', 'needs_correction',
'approved', 'approved_deduction',
'ready_bill', 'billed', 'case_closed', 'on_hold',
]
# New: After accepted by ADP (waiting for approval decision)
after_accepted_statuses = [
'accepted', 'approved', 'approved_deduction',
'ready_bill', 'billed', 'case_closed', 'on_hold',
]
after_approved_statuses = [
'approved', 'approved_deduction', 'ready_bill', 'billed', 'case_closed',
'on_hold',
]
after_ready_bill_statuses = [
'ready_bill', 'billed', 'case_closed',
# NOT on_hold here - if on_hold before ready_bill, these shouldn't be required
]
after_billed_statuses = [
'billed', 'case_closed',
]
# Reasons that DON'T require previous funding date
no_prev_funding_reasons = ['first_access', 'mod_non_adp']
for order in self:
status = order.x_fc_adp_application_status or ''
reason = order.x_fc_reason_for_application or ''
order.x_fc_stage_after_assessment_initiated = status in after_assessment_initiated_statuses
order.x_fc_stage_after_assessment_completed = status in after_assessment_completed_statuses
order.x_fc_stage_after_application_received = status in after_application_received_statuses
order.x_fc_stage_after_ready_submission = status in after_ready_submission_statuses
order.x_fc_stage_after_submitted = status in after_submitted_statuses
order.x_fc_stage_after_accepted = status in after_accepted_statuses
order.x_fc_stage_after_approved = status in after_approved_statuses
order.x_fc_stage_after_ready_bill = status in after_ready_bill_statuses
order.x_fc_stage_after_billed = status in after_billed_statuses
# Previous funding required if reason is set AND not in exempt list
order.x_fc_requires_previous_funding = bool(reason) and reason not in no_prev_funding_reasons
# ==========================================================================
# REASON FOR APPLICATION
# ==========================================================================
x_fc_reason_for_application = fields.Selection(
selection=[
('first_access', 'First Time Access - NO previous ADP'),
('additions', 'Additions'),
('mod_non_adp', 'Modification/Upgrade - Original NOT through ADP'),
('mod_adp', 'Modification/Upgrade - Original through ADP'),
('replace_status', 'Replacement - Change in Status'),
('replace_size', 'Replacement - Change in Body Size'),
('replace_worn', 'Replacement - Worn out (past useful life)'),
('replace_lost', 'Replacement - Lost'),
('replace_stolen', 'Replacement - Stolen'),
('replace_damaged', 'Replacement - Damaged beyond repair'),
('replace_no_longer_meets', 'Replacement - No longer meets needs'),
('growth', 'Growth/Change in condition'),
],
string='Reason for Application',
tracking=True,
help='Reason for the ADP application - affects invoice creation rules',
)
x_fc_previous_funding_date = fields.Date(
string='Previous Funding Date',
tracking=True,
help='Date of previous ADP funding for replacement applications',
)
x_fc_years_since_funding = fields.Float(
string='Years Since Funding',
compute='_compute_years_since_funding',
store=True,
help='Number of years since previous funding',
)
x_fc_under_5_years = fields.Boolean(
string='Under 5 Years',
compute='_compute_years_since_funding',
store=True,
help='True if less than 5 years since previous funding (may have deductions)',
)
# ==========================================================================
# ADP DATE CLASSIFICATIONS (6 dates)
# ==========================================================================
x_fc_assessment_start_date = fields.Date(
string='Assessment Start Date',
tracking=True,
help='Date when the assessment started',
)
x_fc_assessment_end_date = fields.Date(
string='Assessment End Date',
tracking=True,
help='Date when the assessment was completed',
)
x_fc_claim_authorization_date = fields.Date(
string='Claim Authorization Date',
tracking=True,
help='Date when the claim was authorized by the OT/Authorizer',
)
x_fc_claim_submission_date = fields.Date(
string='Claim Submission Date',
tracking=True,
help='Date when the claim was submitted to ADP',
)
x_fc_claim_acceptance_date = fields.Date(
string='ADP Acceptance Date',
tracking=True,
help='Date when ADP accepted the submission (within 24 hours of submission)',
)
x_fc_claim_approval_date = fields.Date(
string='Claim Approval Date',
tracking=True,
help='Date when ADP approved the claim',
)
x_fc_billing_date = fields.Date(
string='Billing Date',
tracking=True,
help='Date when the ADP invoice was created/billed',
)
# ==========================================================================
# ADP DOCUMENT ATTACHMENTS
# ==========================================================================
x_fc_original_application = fields.Binary(
string='Original ADP Application',
attachment=True,
help='The original ADP application document received from the authorizer',
)
x_fc_original_application_filename = fields.Char(
string='Original Application Filename',
)
x_fc_signed_pages_11_12 = fields.Binary(
string='Page 11 & 12 (Signed)',
attachment=True,
help='Signed pages 11 and 12 of the ADP application',
)
x_fc_signed_pages_filename = fields.Char(
string='Signed Pages Filename',
)
# ==========================================================================
# PAGE 11 SIGNATURE TRACKING (Client/Agent Signature)
# Page 11 must be signed by: Client, Spouse, Parent, Legal Guardian, POA, or Public Trustee
# ==========================================================================
x_fc_page11_signer_type = fields.Selection(
selection=[
('client', 'Client (Self)'),
('spouse', 'Spouse'),
('parent', 'Parent'),
('legal_guardian', 'Legal Guardian'),
('poa', 'Power of Attorney'),
('public_trustee', 'Public Trustee'),
],
string='Page 11 Signed By',
tracking=True,
help='Who signed Page 11 of the ADP application (client consent page)',
)
x_fc_page11_signer_name = fields.Char(
string='Page 11 Signer Name',
tracking=True,
help='Name of the person who signed Page 11',
)
x_fc_page11_signer_relationship = fields.Char(
string='Relationship to Client',
help='Relationship of the signer to the client (if not client self)',
)
x_fc_page11_signed_date = fields.Date(
string='Page 11 Signed Date',
tracking=True,
help='Date when Page 11 was signed',
)
page11_sign_request_ids = fields.One2many(
'fusion.page11.sign.request', 'sale_order_id',
string='Page 11 Signing Requests',
)
page11_sign_request_count = fields.Integer(
compute='_compute_page11_sign_request_count',
string='Signing Requests',
)
page11_sign_status = fields.Selection([
('none', 'Not Requested'),
('sent', 'Pending Signature'),
('signed', 'Signed'),
], compute='_compute_page11_sign_request_count', string='Page 11 Remote Status')
# ==========================================================================
# PAGE 12 SIGNATURE TRACKING (Authorizer + Vendor Signature)
# Page 12 must be signed by: Authorizer (OT) and Vendor (our company)
# ==========================================================================
x_fc_page12_authorizer_signed = fields.Boolean(
string='Authorizer Signed Page 12',
default=False,
tracking=True,
help='Whether the authorizer/OT has signed Page 12',
)
x_fc_page12_authorizer_signed_date = fields.Date(
string='Authorizer Signed Date',
tracking=True,
help='Date when the authorizer signed Page 12',
)
x_fc_page12_vendor_signed = fields.Boolean(
string='Vendor Signed Page 12',
default=False,
tracking=True,
help='Whether the vendor (our company) has signed Page 12',
)
x_fc_page12_vendor_signer_id = fields.Many2one(
'res.users',
string='Vendor Signer',
tracking=True,
help='The user who signed Page 12 on behalf of the company',
)
x_fc_page12_vendor_signed_date = fields.Date(
string='Vendor Signed Date',
tracking=True,
help='Date when the vendor signed Page 12',
)
x_fc_final_submitted_application = fields.Binary(
string='Final Submitted Application',
attachment=True,
help='The final ADP application as submitted to ADP',
)
x_fc_final_application_filename = fields.Char(
string='Final Application Filename',
)
x_fc_xml_file = fields.Binary(
string='XML File',
attachment=True,
help='The XML data file submitted to ADP',
)
x_fc_xml_filename = fields.Char(
string='XML Filename',
)
x_fc_approval_letter = fields.Binary(
string='ADP Approval Letter',
attachment=True,
help='ADP approval letter document',
)
x_fc_approval_letter_filename = fields.Char(
string='Approval Letter Filename',
)
x_fc_approval_photo_ids = fields.Many2many(
'ir.attachment',
'sale_order_approval_photo_rel',
'sale_order_id',
'attachment_id',
string='Approval Screenshots',
help='Upload approval screenshots/photos from ADP portal',
)
x_fc_approval_photo_count = fields.Integer(
string='Approval Photos',
compute='_compute_approval_photo_count',
)
@api.depends('x_fc_approval_photo_ids')
def _compute_approval_photo_count(self):
"""Count approval photos."""
for order in self:
order.x_fc_approval_photo_count = len(order.x_fc_approval_photo_ids)
x_fc_proof_of_delivery = fields.Binary(
string='Proof of Delivery',
attachment=True,
help='Proof of delivery document - required before creating ADP invoice',
)
x_fc_proof_of_delivery_filename = fields.Char(
string='Proof of Delivery Filename',
)
# POD Digital Signature Fields (captured via portal)
x_fc_pod_signature = fields.Binary(
string='POD Client Signature',
attachment=True,
help='Digital signature captured from client via portal',
)
x_fc_pod_client_name = fields.Char(
string='POD Client Name',
help='Name of the person who signed the Proof of Delivery',
)
x_fc_pod_signature_date = fields.Date(
string='POD Signature Date',
help='Date specified on the Proof of Delivery (optional)',
)
x_fc_pod_signed_by_user_id = fields.Many2one(
'res.users',
string='POD Collected By',
help='The sales rep or technician who collected the POD signature',
)
x_fc_pod_signed_datetime = fields.Datetime(
string='POD Collection Timestamp',
help='When the POD signature was collected',
)
# ==========================================================================
# VERIFICATION TRACKING
# ==========================================================================
x_fc_submission_verified = fields.Boolean(
string='Submission Verified',
default=False,
copy=False,
help='True when user has verified device types for submission via the wizard',
)
x_fc_submitted_device_types = fields.Text(
string='Submitted Device Types (JSON)',
copy=False,
help='JSON storing which device types were selected for submission',
)
# ==========================================================================
# COMPUTED TOTALS FOR ADP PORTIONS
# ==========================================================================
x_fc_adp_portion_total = fields.Monetary(
string='Total ADP Portion',
compute='_compute_adp_totals',
store=True,
currency_field='currency_id',
help='Total ADP portion for all lines',
)
x_fc_client_portion_total = fields.Monetary(
string='Total Client Portion',
compute='_compute_adp_totals',
store=True,
currency_field='currency_id',
help='Total client portion for all lines',
)
# ==========================================================================
# COMPUTED FIELDS FOR SPLIT INVOICE TRACKING
# ==========================================================================
x_fc_has_client_invoice = fields.Boolean(
string='Has Client Invoice',
compute='_compute_adp_invoice_status',
help='Whether a client portion (25%) invoice has been created',
)
x_fc_has_adp_invoice = fields.Boolean(
string='Has ADP Invoice',
compute='_compute_adp_invoice_status',
help='Whether an ADP portion (75%/100%) invoice has been created',
)
# ==========================================================================
# COMPUTED FIELD FOR PRODUCT-ONLY LINES (for ADP Summary)
# ==========================================================================
x_fc_product_lines = fields.One2many(
'sale.order.line',
compute='_compute_product_lines',
string='Product Lines Only',
help='Only product lines (excludes sections, notes, and empty lines)',
)
# ==========================================================================
# DEVICE APPROVAL TRACKING
# ==========================================================================
x_fc_has_unapproved_devices = fields.Boolean(
string='Has Unapproved Devices',
compute='_compute_device_approval_status',
help='True if there are devices that have not been marked as approved by ADP',
)
x_fc_device_verification_complete = fields.Boolean(
string='Verification Complete',
default=False,
copy=False,
help='True if the user has completed device verification via the wizard. '
'Set when user clicks Confirm in the Device Approval wizard.',
)
x_fc_device_approval_done = fields.Boolean(
string='All Devices Approved',
compute='_compute_device_approval_status',
help='True if ALL ADP devices have been approved. For display purposes only.',
)
x_fc_approved_device_count = fields.Integer(
string='Approved Device Count',
compute='_compute_device_approval_status',
)
x_fc_total_device_count = fields.Integer(
string='Total Device Count',
compute='_compute_device_approval_status',
)
# ==========================================================================
# CASE LOCK
# ==========================================================================
x_fc_case_locked = fields.Boolean(
string='Case Locked',
default=False,
copy=False,
tracking=True,
help='When enabled, all ADP-related fields become read-only and cannot be modified.',
)
# ==========================================================================
# INVOICE MAPPING (for linking legacy invoices)
# ==========================================================================
x_fc_adp_invoice_id = fields.Many2one(
'account.move',
string='ADP Invoice',
domain="[('move_type', 'in', ['out_invoice', 'out_refund'])]",
copy=False,
help='Link to the ADP invoice for this order',
)
x_fc_client_invoice_id = fields.Many2one(
'account.move',
string='Client Invoice',
domain="[('move_type', 'in', ['out_invoice', 'out_refund'])]",
copy=False,
help='Link to the client portion invoice for this order',
)
# ==========================================================================
# (Legacy studio fields removed - all data migrated to x_fc_* fields)
# ==========================================================================
# ==========================================================================
# ORDER TRAIL CHECKLIST (computed for display)
# ==========================================================================
x_fc_trail_has_assessment_dates = fields.Boolean(
string='Assessment Dates Set',
compute='_compute_order_trail',
)
x_fc_trail_has_authorization = fields.Boolean(
string='Authorization Date Set',
compute='_compute_order_trail',
)
x_fc_trail_has_original_app = fields.Boolean(
string='Original Application Uploaded',
compute='_compute_order_trail',
)
x_fc_trail_has_signed_pages = fields.Boolean(
string='Signed Pages 11 & 12 Uploaded',
compute='_compute_order_trail',
)
x_fc_trail_has_final_app = fields.Boolean(
string='Final Application Uploaded',
compute='_compute_order_trail',
)
x_fc_trail_has_xml = fields.Boolean(
string='XML File Uploaded',
compute='_compute_order_trail',
)
x_fc_trail_has_approval_letter = fields.Boolean(
string='Approval Letter Uploaded',
compute='_compute_order_trail',
)
x_fc_trail_has_pod = fields.Boolean(
string='Proof of Delivery Uploaded',
compute='_compute_order_trail',
)
x_fc_trail_has_vendor_bills = fields.Boolean(
string='Vendor Bills Linked',
compute='_compute_order_trail',
)
x_fc_trail_invoices_posted = fields.Boolean(
string='Invoices Posted',
compute='_compute_order_trail',
)
@api.depends(
'x_fc_assessment_start_date', 'x_fc_assessment_end_date',
'x_fc_claim_authorization_date', 'x_fc_original_application',
'x_fc_signed_pages_11_12', 'x_fc_final_submitted_application',
'x_fc_xml_file', 'x_fc_approval_letter', 'x_fc_proof_of_delivery',
'x_fc_vendor_bill_ids', 'invoice_ids', 'invoice_ids.state'
)
def _compute_order_trail(self):
for order in self:
order.x_fc_trail_has_assessment_dates = bool(
order.x_fc_assessment_start_date and order.x_fc_assessment_end_date
)
order.x_fc_trail_has_authorization = bool(order.x_fc_claim_authorization_date)
order.x_fc_trail_has_original_app = bool(order.x_fc_original_application)
order.x_fc_trail_has_signed_pages = bool(order.x_fc_signed_pages_11_12)
order.x_fc_trail_has_final_app = bool(order.x_fc_final_submitted_application)
order.x_fc_trail_has_xml = bool(order.x_fc_xml_file)
order.x_fc_trail_has_approval_letter = bool(order.x_fc_approval_letter)
order.x_fc_trail_has_pod = bool(order.x_fc_proof_of_delivery)
order.x_fc_trail_has_vendor_bills = bool(order.x_fc_vendor_bill_ids)
# Check if there are posted invoices
order.x_fc_trail_invoices_posted = any(
inv.state == 'posted' for inv in order.invoice_ids
)
# ==========================================================================
# DEDUCTION TRACKING
# ==========================================================================
x_fc_has_deductions = fields.Boolean(
string='Has Deductions',
compute='_compute_has_deductions',
help='True if any line has a deduction applied',
)
x_fc_total_deduction_amount = fields.Monetary(
string='Total Deduction Amount',
compute='_compute_has_deductions',
currency_field='currency_id',
help='Total amount of deductions applied to ADP portion',
)
# ==========================================================================
# COMPUTED METHODS
# ==========================================================================
@api.depends('order_line.x_fc_adp_portion', 'order_line.x_fc_client_portion')
def _compute_adp_totals(self):
for order in self:
order.x_fc_adp_portion_total = sum(order.order_line.mapped('x_fc_adp_portion'))
order.x_fc_client_portion_total = sum(order.order_line.mapped('x_fc_client_portion'))
def _compute_adp_invoice_status(self):
"""Check if client/ADP split invoices have already been created."""
for order in self:
client_invoice_exists = False
adp_invoice_exists = False
# Check linked invoices for the portion type
invoices = order.invoice_ids.filtered(lambda inv: inv.state != 'cancel')
for invoice in invoices:
if hasattr(invoice, 'x_fc_adp_invoice_portion'):
if invoice.x_fc_adp_invoice_portion == 'client':
client_invoice_exists = True
elif invoice.x_fc_adp_invoice_portion == 'adp':
adp_invoice_exists = True
order.x_fc_has_client_invoice = client_invoice_exists
order.x_fc_has_adp_invoice = adp_invoice_exists
@api.depends('order_line', 'order_line.product_id', 'order_line.product_uom_qty', 'order_line.display_type')
def _compute_product_lines(self):
"""Compute filtered list of only actual product lines (no sections, notes, or empty lines)."""
for order in self:
order.x_fc_product_lines = order.order_line.filtered(
lambda l: not l.display_type and l.product_id and l.product_uom_qty > 0
)
@api.depends('order_line.x_fc_adp_approved', 'order_line.product_id', 'order_line.display_type')
def _compute_device_approval_status(self):
"""Compute device approval status for ADP orders.
Only counts lines with valid ADP device codes in the database.
Non-ADP items are ignored for verification purposes.
"""
ADPDevice = self.env['fusion.adp.device.code'].sudo()
for order in self:
# Get lines with device codes (actual ADP billable products)
product_lines = order.order_line.filtered(
lambda l: not l.display_type
and l.product_id
and l.product_uom_qty > 0
)
# Filter to only lines with valid ADP device codes in the database
device_lines = self.env['sale.order.line']
for line in product_lines:
device_code = line._get_adp_device_code()
if device_code:
# Check if this code exists in ADP database
if ADPDevice.search_count([('device_code', '=', device_code), ('active', '=', True)]) > 0:
device_lines |= line
total_count = len(device_lines)
approved_count = len(device_lines.filtered(lambda l: l.x_fc_adp_approved))
order.x_fc_total_device_count = total_count
order.x_fc_approved_device_count = approved_count
order.x_fc_has_unapproved_devices = approved_count < total_count and total_count > 0
# Verification is "done" only if ALL ADP devices have been approved
# If there are no ADP devices, verification is automatically done
order.x_fc_device_approval_done = (approved_count == total_count) or total_count == 0
@api.depends('order_line.x_fc_deduction_type', 'order_line.x_fc_deduction_value',
'order_line.x_fc_adp_portion', 'order_line.product_id')
def _compute_has_deductions(self):
"""Compute if order has any deductions and total deduction amount."""
for order in self:
product_lines = order.order_line.filtered(
lambda l: not l.display_type
and l.product_id
and l.product_uom_qty > 0
)
# Check if any line has a deduction
has_deductions = any(
line.x_fc_deduction_type and line.x_fc_deduction_type != 'none'
for line in product_lines
)
# Calculate total deduction impact (difference from full ADP coverage)
total_deduction = 0.0
if has_deductions:
for line in product_lines:
if line.x_fc_deduction_type == 'amt' and line.x_fc_deduction_value:
total_deduction += line.x_fc_deduction_value
elif line.x_fc_deduction_type == 'pct' and line.x_fc_deduction_value:
# For percentage, calculate the reduction from normal
# If normally 75% ADP, and now 50%, the deduction is 25% of total
client_type = order._get_client_type()
base_pct = 0.75 if client_type == 'REG' else 1.0
adp_price = line.x_fc_adp_max_price or line.price_unit
normal_adp = adp_price * line.product_uom_qty * base_pct
actual_adp = line.x_fc_adp_portion
total_deduction += max(0, normal_adp - actual_adp)
order.x_fc_has_deductions = has_deductions
order.x_fc_total_deduction_amount = total_deduction
@api.depends('x_fc_previous_funding_date')
def _compute_years_since_funding(self):
"""Compute years since previous funding and 5-year flag."""
from datetime import date
today = date.today()
for order in self:
if order.x_fc_previous_funding_date:
delta = today - order.x_fc_previous_funding_date
years = delta.days / 365.25
order.x_fc_years_since_funding = round(years, 2)
order.x_fc_under_5_years = years < 5
else:
order.x_fc_years_since_funding = 0
order.x_fc_under_5_years = False
# ==========================================================================
# PREVIOUS FUNDING WARNING MESSAGE
# ==========================================================================
x_fc_funding_warning_message = fields.Char(
string='Funding Warning',
compute='_compute_funding_warning_message',
help='Warning message for recent previous funding',
)
x_fc_funding_warning_level = fields.Selection(
selection=[
('none', 'None'),
('warning', 'Warning'),
('danger', 'Danger'),
],
string='Warning Level',
compute='_compute_funding_warning_message',
help='Level of warning for previous funding',
)
@api.depends('x_fc_previous_funding_date')
def _compute_funding_warning_message(self):
"""Compute warning message for previous funding with time elapsed."""
from datetime import date
today = date.today()
for order in self:
if order.x_fc_previous_funding_date:
delta = today - order.x_fc_previous_funding_date
total_months = delta.days / 30.44 # Average days per month
years = int(total_months // 12)
months = int(total_months % 12)
if years == 0:
time_str = f"{months} month{'s' if months != 1 else ''}"
elif months == 0:
time_str = f"{years} year{'s' if years != 1 else ''}"
else:
time_str = f"{years} year{'s' if years != 1 else ''} and {months} month{'s' if months != 1 else ''}"
order.x_fc_funding_warning_message = f"Previous funding was {time_str} ago ({order.x_fc_previous_funding_date.strftime('%B %d, %Y')})"
# Set warning level - red if within 1 year
if total_months < 12:
order.x_fc_funding_warning_level = 'danger'
elif total_months < 60: # Less than 5 years
order.x_fc_funding_warning_level = 'warning'
else:
order.x_fc_funding_warning_level = 'none'
else:
order.x_fc_funding_warning_message = False
order.x_fc_funding_warning_level = 'none'
# ==========================================================================
# FIELD VALIDATION CONSTRAINTS
# ==========================================================================
@api.constrains('x_fc_claim_number')
def _check_claim_number(self):
"""Validate claim number: 10 digits only, numbers only."""
for order in self:
if order.x_fc_claim_number:
# Remove any whitespace
claim = order.x_fc_claim_number.strip()
if not re.match(r'^\d{10}$', claim):
raise ValidationError(
"Claim Number must be exactly 10 digits (numbers only).\n"
f"Current value: '{order.x_fc_claim_number}'"
)
@api.constrains('x_fc_client_ref_1')
def _check_client_ref_1(self):
"""Validate client reference 1: up to 4 letters, comma allowed."""
for order in self:
if order.x_fc_client_ref_1:
# Allow letters and comma only, max 4 characters (excluding comma)
ref = order.x_fc_client_ref_1.strip().upper()
# Remove commas for letter count
letters_only = ref.replace(',', '')
if len(letters_only) > 4:
raise ValidationError(
"Client Reference 1 can only have up to 4 letters.\n"
f"Current value: '{order.x_fc_client_ref_1}'"
)
if not re.match(r'^[A-Za-z,]+$', ref):
raise ValidationError(
"Client Reference 1 can only contain letters and comma.\n"
f"Current value: '{order.x_fc_client_ref_1}'"
)
@api.constrains('x_fc_client_ref_2')
def _check_client_ref_2(self):
"""Validate client reference 2: exactly 4 digits, numbers only."""
for order in self:
if order.x_fc_client_ref_2:
ref = order.x_fc_client_ref_2.strip()
if not re.match(r'^\d{4}$', ref):
raise ValidationError(
"Client Reference 2 must be exactly 4 digits (numbers only).\n"
f"Current value: '{order.x_fc_client_ref_2}'"
)
@api.constrains('x_fc_original_application_filename')
def _check_original_application_pdf(self):
"""Validate that Original ADP Application is a PDF file."""
for order in self:
if order.x_fc_original_application_filename:
if not order.x_fc_original_application_filename.lower().endswith('.pdf'):
raise ValidationError(
"Original ADP Application must be a PDF file.\n"
f"Uploaded file: '{order.x_fc_original_application_filename}'"
)
@api.constrains('x_fc_signed_pages_filename')
def _check_signed_pages_pdf(self):
"""Validate that Page 11 & 12 is a PDF file."""
for order in self:
if order.x_fc_signed_pages_filename:
if not order.x_fc_signed_pages_filename.lower().endswith('.pdf'):
raise ValidationError(
"Page 11 & 12 (Signed) must be a PDF file.\n"
f"Uploaded file: '{order.x_fc_signed_pages_filename}'"
)
@api.constrains('x_fc_final_application_filename')
def _check_final_application_pdf(self):
"""Validate that Final Submitted Application is a PDF file."""
for order in self:
if order.x_fc_final_application_filename:
if not order.x_fc_final_application_filename.lower().endswith('.pdf'):
raise ValidationError(
"Final Submitted Application must be a PDF file.\n"
f"Uploaded file: '{order.x_fc_final_application_filename}'"
)
@api.constrains('x_fc_xml_filename')
def _check_xml_file(self):
"""Validate that XML File is an XML file."""
for order in self:
if order.x_fc_xml_filename:
if not order.x_fc_xml_filename.lower().endswith('.xml'):
raise ValidationError(
"XML File must be an XML file (.xml).\n"
f"Uploaded file: '{order.x_fc_xml_filename}'"
)
@api.constrains('x_fc_proof_of_delivery_filename')
def _check_proof_of_delivery_pdf(self):
"""Validate that Proof of Delivery is a PDF file."""
for order in self:
if order.x_fc_proof_of_delivery_filename:
if not order.x_fc_proof_of_delivery_filename.lower().endswith('.pdf'):
raise ValidationError(
"Proof of Delivery must be a PDF file.\n"
f"Uploaded file: '{order.x_fc_proof_of_delivery_filename}'"
)
@api.constrains('x_fc_adp_delivery_date', 'x_fc_claim_approval_date')
def _check_delivery_date_after_approval(self):
"""Validate that delivery date is not before approval date.
Per business rule: The delivery date on POD cannot be before the approval date.
If client takes delivery before approval (early delivery case), the POD
should show the approval date, not the actual delivery date.
"""
for order in self:
if order.x_fc_adp_delivery_date and order.x_fc_claim_approval_date:
if order.x_fc_adp_delivery_date < order.x_fc_claim_approval_date:
raise ValidationError(
"Delivery Date cannot be before Approval Date.\n\n"
f"Delivery Date: {order.x_fc_adp_delivery_date.strftime('%B %d, %Y')}\n"
f"Approval Date: {order.x_fc_claim_approval_date.strftime('%B %d, %Y')}\n\n"
"For early delivery cases (delivery before approval), the Proof of Delivery "
"document should show the approval date, not the actual delivery date.\n\n"
"Please correct the delivery date and re-upload the Proof of Delivery."
)
# ==========================================================================
# PDF DOCUMENT PREVIEW ACTIONS (opens in new tab using browser/system PDF handler)
# ==========================================================================
MIME_TO_EXT = {
'application/pdf': '.pdf',
'application/xml': '.xml',
'text/xml': '.xml',
'text/plain': '.txt',
'application/vnd.openxmlformats-officedocument.wordprocessingml.document': '.docx',
'application/msword': '.doc',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': '.xlsx',
'application/vnd.ms-excel': '.xls',
'image/jpeg': '.jpg',
'image/png': '.png',
'image/gif': '.gif',
'image/webp': '.webp',
'application/zip': '.zip',
'application/octet-stream': '',
}
FIELD_NAME_TEMPLATE = {
'x_fc_final_submitted_application': '{first}_{last}.pdf',
'x_fc_xml_file': '{first}_{last}_data.xml',
'x_fc_original_application': '{first}_{last}_Original_Application.pdf',
'x_fc_signed_pages_11_12': '{first}_{last}_Signed_Pages.pdf',
'x_fc_proof_of_delivery': '{first}_{last}_Proof_of_Delivery.pdf',
'x_fc_approval_letter': '{first}_{last}_Approval_Letter.pdf',
'x_fc_sa_signed_form': '{first}_{last}_SA_Form_Signed.pdf',
'x_fc_sa_physical_signed_copy': '{first}_{last}_SA_Form_Signed.pdf',
'x_fc_sa_approval_form': '{first}_{last}_SA_Approval.pdf',
'x_fc_odsp_approval_document': '{first}_{last}_ODSP_Approval.pdf',
'x_fc_odsp_authorizer_letter': '{first}_{last}_ODSP_Authorizer_Letter.pdf',
'x_fc_ow_discretionary_form': '{first}_{last}_OW_Discretionary_Form.pdf',
'x_fc_ow_authorizer_letter': '{first}_{last}_OW_Authorizer_Letter.pdf',
'x_fc_mod_drawing': '{first}_{last}_Drawing.pdf',
'x_fc_mod_initial_photos': '{first}_{last}_Initial_Photos.pdf',
'x_fc_mod_pca_document': '{first}_{last}_PCA_Document.pdf',
'x_fc_mod_proof_of_delivery': '{first}_{last}_Proof_of_Delivery.pdf',
'x_fc_mod_completion_photos': '{first}_{last}_Completion_Photos.pdf',
}
FIELD_FILENAME_MAP = {
'x_fc_original_application': 'x_fc_original_application_filename',
'x_fc_signed_pages_11_12': 'x_fc_signed_pages_filename',
'x_fc_final_submitted_application': 'x_fc_final_application_filename',
'x_fc_xml_file': 'x_fc_xml_filename',
'x_fc_proof_of_delivery': 'x_fc_proof_of_delivery_filename',
'x_fc_approval_letter': 'x_fc_approval_letter_filename',
'x_fc_sa_signed_form': 'x_fc_sa_signed_form_filename',
'x_fc_sa_physical_signed_copy': 'x_fc_sa_physical_signed_copy_filename',
'x_fc_sa_approval_form': 'x_fc_sa_approval_form_filename',
'x_fc_odsp_approval_document': 'x_fc_odsp_approval_document_filename',
'x_fc_odsp_authorizer_letter': 'x_fc_odsp_authorizer_letter_filename',
'x_fc_ow_discretionary_form': 'x_fc_ow_discretionary_form_filename',
'x_fc_ow_authorizer_letter': 'x_fc_ow_authorizer_letter_filename',
'x_fc_mod_drawing': 'x_fc_mod_drawing_filename',
'x_fc_mod_initial_photos': 'x_fc_mod_initial_photos_filename',
'x_fc_mod_pca_document': 'x_fc_mod_pca_filename',
'x_fc_mod_proof_of_delivery': 'x_fc_mod_pod_filename',
'x_fc_mod_completion_photos': 'x_fc_mod_completion_photos_filename',
}
def _get_ext_from_mime(self, mimetype):
"""Return a file extension (with dot) for a MIME type."""
return self.MIME_TO_EXT.get(mimetype or '', '')
def _get_client_name_parts(self):
"""Return (first_name, last_name) cleaned for filenames."""
full_name = (self.partner_id.name or 'Client').strip()
parts = full_name.split()
first = parts[0] if parts else 'Client'
last = parts[-1] if len(parts) > 1 else ''
clean = lambda s: s.replace(',', '').replace("'", '').replace('"', '')
return clean(first), clean(last)
def _build_attachment_name(self, field_name, mimetype=None):
"""Build the proper filename for a field attachment.
Uses FIELD_NAME_TEMPLATE for known fields with Firstname_Lastname convention.
For the XML file, respects the actual mimetype (could be .xml, .docx, .txt).
"""
first, last = self._get_client_name_parts()
template = self.FIELD_NAME_TEMPLATE.get(field_name)
if template:
name = template.format(first=first, last=last)
if field_name == 'x_fc_xml_file' and mimetype:
ext = self._get_ext_from_mime(mimetype)
if ext and ext != '.xml':
name = f'{first}_{last}_data{ext}'
return name
ext = self._get_ext_from_mime(mimetype) if mimetype else '.pdf'
return f'{first}_{last}_Document{ext}'
def _prepare_attachment_for_email(self, attachment, field_name=None, label=None):
"""Rename an attachment to a clean, professional filename.
Always renames to the standard convention (Firstname_Lastname pattern)
so recipients get consistently named files regardless of what was uploaded.
"""
if not attachment:
return None
new_name = self._build_attachment_name(field_name, attachment.mimetype)
if attachment.name == new_name:
return attachment.id
try:
attachment.sudo().write({'name': new_name})
except Exception:
_logger.warning("Could not rename attachment %s to %s", attachment.id, new_name)
return attachment.id
def _get_and_prepare_field_attachment(self, field_name, label=None):
"""Find the ir.attachment for a binary field, rename it properly, return its id.
Convenience wrapper combining _get_document_attachment + _prepare_attachment_for_email.
Returns None if the field has no data or attachment is not found.
"""
self.ensure_one()
if not getattr(self, field_name, None):
return None
attachment = self._get_document_attachment(field_name)
if not attachment:
return None
return self._prepare_attachment_for_email(attachment, field_name=field_name, label=label)
def _get_document_attachment(self, field_name):
"""Get the ir.attachment record for a binary field stored as attachment."""
self.ensure_one()
attachment = self.env['ir.attachment'].sudo().search([
('res_model', '=', 'sale.order'),
('res_id', '=', self.id),
('res_field', '=', field_name),
], order='create_date desc', limit=1)
return attachment
def _get_or_create_attachment(self, field_name, document_label):
"""Get the current attachment for a binary field (attachment=True).
For attachment=True fields, Odoo creates attachments automatically.
We find the one with res_field set, ensure it has a proper name, and return it.
"""
self.ensure_one()
data = getattr(self, field_name)
if not data:
return None
attachment = self.env['ir.attachment'].sudo().search([
('res_model', '=', 'sale.order'),
('res_id', '=', self.id),
('res_field', '=', field_name),
], order='id desc', limit=1)
if attachment:
self._prepare_attachment_for_email(attachment, field_name=field_name, label=document_label)
return attachment
filename = self._build_attachment_name(field_name)
attachment = self.env['ir.attachment'].sudo().create({
'name': filename,
'datas': data,
'res_model': 'sale.order',
'res_id': self.id,
'res_field': field_name,
'type': 'binary',
})
return attachment
def action_open_original_application(self):
"""Open the Original ADP Application PDF."""
self.ensure_one()
return self._action_open_document('x_fc_original_application', 'Original ADP Application')
@api.depends('page11_sign_request_ids', 'page11_sign_request_ids.state')
def _compute_page11_sign_request_count(self):
for order in self:
requests = order.page11_sign_request_ids
order.page11_sign_request_count = len(requests)
signed = requests.filtered(lambda r: r.state == 'signed')
pending = requests.filtered(lambda r: r.state == 'sent')
if signed:
order.page11_sign_status = 'signed'
elif pending:
order.page11_sign_status = 'sent'
else:
order.page11_sign_status = 'none'
def action_open_signed_pages(self):
"""Open the Page 11 & 12 PDF."""
self.ensure_one()
return self._action_open_document('x_fc_signed_pages_11_12', 'Page 11 & 12 (Signed)')
def action_request_page11_signature(self):
"""Open the wizard to send Page 11 for remote signing."""
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.id},
}
def action_view_page11_requests(self):
"""Open the list of Page 11 signing requests."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Page 11 Signing Requests',
'res_model': 'fusion.page11.sign.request',
'view_mode': 'list,form',
'domain': [('sale_order_id', '=', self.id)],
'context': {'default_sale_order_id': self.id},
}
def action_open_final_application(self):
"""Open the Final Submitted Application PDF."""
self.ensure_one()
return self._action_open_document('x_fc_final_submitted_application', 'Final Submitted Application')
def action_open_xml_file(self):
"""Open the XML File in viewer."""
self.ensure_one()
return self._action_open_document('x_fc_xml_file', 'XML File', is_xml=True)
def action_open_proof_of_delivery(self):
"""Open the Proof of Delivery PDF."""
self.ensure_one()
return self._action_open_document('x_fc_proof_of_delivery', 'Proof of Delivery')
def action_open_approval_letter(self):
"""Open the ADP Approval Letter PDF."""
self.ensure_one()
return self._action_open_document('x_fc_approval_letter', 'ADP Approval Letter')
def action_open_sa_approval_form(self):
"""Open the SA Mobility ODSP Approval Form PDF."""
self.ensure_one()
return self._action_open_document('x_fc_sa_approval_form', 'ODSP Approval Form')
def action_open_sa_signed_form(self):
"""Open the signed SA Mobility form PDF."""
self.ensure_one()
return self._action_open_document('x_fc_sa_signed_form', 'SA Signed Form')
def action_open_sa_physical_copy(self):
"""Open the physically signed SA Mobility copy."""
self.ensure_one()
return self._action_open_document('x_fc_sa_physical_signed_copy', 'Physical Signed Copy')
def action_open_sa_internal_pod(self):
"""Generate and open the internal POD report on-the-fly."""
self.ensure_one()
report = self.env.ref('fusion_claims.action_report_proof_of_delivery_standard')
return report.report_action(self)
def action_open_ow_discretionary_form(self):
"""Open the Ontario Works Discretionary Benefits form PDF."""
self.ensure_one()
return self._action_open_document('x_fc_ow_discretionary_form', 'Discretionary Benefits Form')
def action_open_ow_authorizer_letter(self):
"""Open the Ontario Works Authorizer Letter."""
self.ensure_one()
return self._action_open_document('x_fc_ow_authorizer_letter', 'Authorizer Letter')
def action_open_ow_internal_pod(self):
"""Generate and open the internal POD report on-the-fly (Ontario Works)."""
self.ensure_one()
report = self.env.ref('fusion_claims.action_report_proof_of_delivery_standard')
return report.report_action(self)
def action_open_odsp_std_approval_document(self):
"""Open the Standard ODSP Approval Document."""
self.ensure_one()
return self._action_open_document('x_fc_odsp_approval_document', 'ODSP Approval Document')
def action_open_odsp_std_authorizer_letter(self):
"""Open the Standard ODSP Authorizer Letter."""
self.ensure_one()
return self._action_open_document('x_fc_odsp_authorizer_letter', 'Authorizer Letter')
def action_open_odsp_std_internal_pod(self):
"""Generate and open the internal POD report on-the-fly (Standard ODSP)."""
self.ensure_one()
report = self.env.ref('fusion_claims.action_report_proof_of_delivery_standard')
return report.report_action(self)
def _get_sa_pod_pdf(self):
"""Generate the standard POD report PDF and return (pdf_bytes, filename)."""
self.ensure_one()
report = self.env.ref('fusion_claims.action_report_proof_of_delivery_standard')
pdf_content, _ct = report._render_qweb_pdf(report.id, [self.id])
return pdf_content, f'POD_{self.name}.pdf'
def action_view_approval_photos(self):
"""Open approval photos using Odoo's native attachment viewer."""
self.ensure_one()
attachments = self.x_fc_approval_photo_ids
if not attachments:
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'No Photos',
'message': 'No approval screenshots have been uploaded yet.',
'type': 'warning',
'sticky': False,
}
}
# Use Odoo's native attachment viewer (same as chatter)
return {
'type': 'ir.actions.act_url',
'url': f'/web/image/{attachments[0].id}',
'target': 'new',
}
def _action_open_document(self, field_name, document_label, download=False, is_xml=False):
"""Open a document in a preview dialog (PDF or XML viewer)."""
self.ensure_one()
# Check if the field has data
if not getattr(self, field_name):
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'No Document',
'message': f'No {document_label} has been uploaded yet.',
'type': 'warning',
'sticky': False,
}
}
# Get or create attachment
attachment = self._get_or_create_attachment(field_name, document_label)
if attachment:
if download:
# Open in new tab for download
return {
'type': 'ir.actions.act_url',
'url': f'/web/content/{attachment.id}?download=true',
'target': 'new',
}
elif is_xml:
# For XML files, open in XML viewer dialog
return {
'type': 'ir.actions.client',
'tag': 'fusion_claims.preview_xml',
'params': {
'attachment_id': attachment.id,
'title': f'{document_label} - {self.name}',
}
}
else:
# For PDF files, open in PDF preview dialog
return {
'type': 'ir.actions.client',
'tag': 'fusion_claims.preview_document',
'params': {
'attachment_id': attachment.id,
'title': f'{document_label} - {self.name}',
}
}
else:
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Error',
'message': f'Failed to load {document_label}.',
'type': 'danger',
'sticky': False,
}
}
@api.onchange('x_fc_sale_type', 'x_fc_client_type')
def _onchange_sale_type_client_type(self):
"""Trigger recalculation when sale type or client type changes."""
for line in self.order_line:
line._compute_adp_portions()
# ==========================================================================
# GETTER METHODS
# ==========================================================================
def _get_sale_type(self):
"""Get sale type from x_fc_sale_type."""
self.ensure_one()
return self.x_fc_sale_type or ''
def _get_client_type(self):
"""Get client type from x_fc_client_type."""
self.ensure_one()
return self.x_fc_client_type or ''
def _get_authorizer(self):
"""Get authorizer from mapped field or built-in field. Returns name as string."""
self.ensure_one()
ICP = self.env['ir.config_parameter'].sudo()
field_name = ICP.get_param('fusion_claims.field_so_authorizer', 'x_fc_authorizer_id')
value = getattr(self, field_name, None) if hasattr(self, field_name) else None
if not value and field_name != 'x_fc_authorizer_id':
value = self.x_fc_authorizer_id
# Return name if it's a record, otherwise return string value
if hasattr(value, 'name'):
return value.name or ''
return str(value) if value else ''
def _get_claim_number(self):
"""Get claim number."""
self.ensure_one()
return self.x_fc_claim_number or ''
def _get_client_ref_1(self):
"""Get client reference 1."""
self.ensure_one()
return self.x_fc_client_ref_1 or ''
def _get_client_ref_2(self):
"""Get client reference 2."""
self.ensure_one()
return self.x_fc_client_ref_2 or ''
def _get_adp_delivery_date(self):
"""Get ADP delivery date."""
self.ensure_one()
return self.x_fc_adp_delivery_date
def _is_adp_sale(self):
"""Check if this is an ADP sale type.
Returns True only for ADP-related sale types.
"""
self.ensure_one()
sale_type = self.x_fc_sale_type or ''
if not sale_type:
return False
sale_type_lower = str(sale_type).lower().strip()
adp_keywords = ('adp',)
return any(keyword in sale_type_lower for keyword in adp_keywords)
def _get_serial_numbers(self):
"""Get all serial numbers from order lines."""
self.ensure_one()
serial_lines = []
for line in self.order_line:
serial = line._get_serial_number()
if serial:
serial_lines.append({
'product': line.product_id.name,
'serial': serial,
'adp_code': line._get_adp_device_code(),
})
return serial_lines
# ==========================================================================
# ACTION METHODS
# ==========================================================================
def action_recalculate_adp_portions(self):
"""Manually recalculate ADP and Client portions for all lines."""
for order in self:
for line in order.order_line:
line._compute_adp_portions()
order._compute_adp_totals()
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'ADP Portions Recalculated',
'message': 'All line portions have been recalculated.',
'type': 'success',
'sticky': False,
}
}
def action_submit_to_adp(self):
"""Mark order as submitted to ADP."""
for order in self:
if order._is_adp_sale():
order.x_fc_adp_status = 'submitted'
return True
def action_mark_adp_approved(self):
"""Mark order as approved by ADP."""
for order in self:
if order._is_adp_sale():
order.x_fc_adp_status = 'approved'
return True
def action_open_device_approval_wizard(self):
"""Open the device approval wizard to verify which devices were approved by ADP."""
self.ensure_one()
return {
'name': 'Verify Device Approval',
'type': 'ir.actions.act_window',
'res_model': 'fusion_claims.device.approval.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'active_id': self.id,
'active_model': 'sale.order',
},
}
def action_open_submission_verification_wizard(self):
"""Open the submission verification wizard to confirm which device types are being submitted."""
self.ensure_one()
return {
'name': 'Verify Submission Device Types',
'type': 'ir.actions.act_window',
'res_model': 'fusion_claims.submission.verification.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'active_id': self.id,
'active_model': 'sale.order',
},
}
# ==========================================================================
# EARLY WORKFLOW STAGE ACTIONS (No wizard required - simple status updates)
# ==========================================================================
def action_schedule_assessment(self):
"""Open wizard to schedule assessment with date/time and calendar event."""
self.ensure_one()
if self.x_fc_adp_application_status != 'quotation':
raise UserError("Can only schedule assessment from 'Quotation' status.")
return {
'name': 'Schedule Assessment',
'type': 'ir.actions.act_window',
'res_model': 'fusion_claims.schedule.assessment.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'active_id': self.id,
'active_model': 'sale.order',
},
}
def action_complete_assessment(self):
"""Open wizard to mark assessment as completed with date.
Allowed from 'quotation' (override) or 'assessment_scheduled' (normal flow)."""
self.ensure_one()
if self.x_fc_adp_application_status not in ('quotation', 'assessment_scheduled'):
raise UserError(
_("Can only complete assessment from 'Quotation' or 'Assessment Scheduled' status.")
)
return {
'name': 'Assessment Completed',
'type': 'ir.actions.act_window',
'res_model': 'fusion_claims.assessment.completed.wizard',
'view_mode': 'form',
'views': [(False, 'form')],
'target': 'new',
'context': {
'active_id': self.id,
'active_model': 'sale.order',
},
}
def action_application_received(self):
"""Open wizard to upload ADP application and pages 11 & 12."""
self.ensure_one()
if self.x_fc_adp_application_status not in ('assessment_completed', 'waiting_for_application'):
raise UserError("Can only mark application received from 'Waiting for Application' status.")
return {
'name': 'Application Received',
'type': 'ir.actions.act_window',
'res_model': 'fusion_claims.application.received.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'active_id': self.id,
'active_model': 'sale.order',
},
}
def action_ready_for_submission(self):
"""Open wizard to collect required fields and mark as ready for submission."""
self.ensure_one()
if self.x_fc_adp_application_status != 'application_received':
raise UserError("Can only mark ready for submission from 'Application Received' status.")
return {
'name': 'Ready for Submission',
'type': 'ir.actions.act_window',
'res_model': 'fusion_claims.ready.for.submission.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'active_id': self.id,
'active_model': 'sale.order',
},
}
# ==========================================================================
# SUBMISSION WORKFLOW ACTIONS
# ==========================================================================
def action_submit_application(self):
"""Open submission verification wizard and submit the application.
This forces verification of device types before changing status to 'submitted'.
"""
self.ensure_one()
# Validate we're in a status that can be submitted
if self.x_fc_adp_application_status not in ('ready_submission', 'needs_correction'):
raise UserError(
"Application can only be submitted from 'Ready for Submission' or 'Needs Correction' status."
)
return {
'name': 'Submit Application - Verify Device Types',
'type': 'ir.actions.act_window',
'res_model': 'fusion_claims.submission.verification.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'active_id': self.id,
'active_model': 'sale.order',
'submit_application': True, # Flag to set status after verification
},
}
def action_close_case(self):
"""Open case close verification wizard to verify audit trail before closing.
This forces verification of:
- Signed Pages 11 & 12
- Final Application
- Proof of Delivery
- Vendor Bills
"""
self.ensure_one()
# Validate we're in a status that can be closed
if self.x_fc_adp_application_status != 'billed':
raise UserError(
"Case can only be closed from 'Billed' status."
)
return {
'name': 'Close Case - Audit Trail Verification',
'type': 'ir.actions.act_window',
'res_model': 'fusion_claims.case.close.verification.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'active_id': self.id,
'active_model': 'sale.order',
},
}
def action_mark_accepted(self):
"""Mark the application as accepted by ADP.
This is called when ADP accepts the submission (within 24 hours).
This is a simple status change - no wizard needed.
Submission history is updated in the write() method.
"""
self.ensure_one()
# Validate we're in a status that can be accepted
if self.x_fc_adp_application_status not in ('submitted', 'resubmitted'):
raise UserError(
"Application can only be marked as accepted from 'Submitted' or 'Resubmitted' status."
)
# Update status - this will trigger the write() method which updates submission history
self.with_context(skip_status_validation=True).write({
'x_fc_adp_application_status': 'accepted',
})
# Post to chatter
self.message_post(
body=Markup(
'<div class="alert alert-success" role="alert">'
'<h5 class="alert-heading"><i class="fa fa-check"/> Submission Accepted by ADP</h5>'
f'<p class="mb-0">The application has been accepted by ADP on {fields.Date.today().strftime("%B %d, %Y")}. '
'Awaiting approval decision (2-3 weeks).</p>'
'</div>'
),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
return True
def action_mark_approved(self):
"""Open device approval wizard and mark as approved.
This is called when ADP approval letter is received.
The wizard allows verifying which devices were approved.
"""
self.ensure_one()
# Validate we're in a status that can be approved
if self.x_fc_adp_application_status not in ('submitted', 'resubmitted', 'accepted'):
raise UserError(
"Application can only be marked as approved from 'Submitted', 'Resubmitted', or 'Accepted' status."
)
return {
'name': 'Mark as Approved - Verify Device Approval',
'type': 'ir.actions.act_window',
'res_model': 'fusion_claims.device.approval.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'active_id': self.id,
'active_model': 'sale.order',
'mark_as_approved': True, # Flag to set status after verification
},
}
def action_resume_from_hold(self):
"""Resume the application from on-hold status.
Returns the application to its previous status before being put on hold.
"""
self.ensure_one()
if self.x_fc_adp_application_status != 'on_hold':
raise UserError("This action is only available for applications that are On Hold.")
# Get the previous status
previous_status = self.x_fc_previous_status_before_hold
# If no previous status recorded, default to 'approved'
if not previous_status:
previous_status = 'approved'
# Get status labels for message
status_labels = dict(self._fields['x_fc_adp_application_status'].selection)
prev_label = status_labels.get(previous_status, previous_status)
# Update the status
self.with_context(skip_status_validation=True).write({
'x_fc_adp_application_status': previous_status,
'x_fc_on_hold_date': False,
'x_fc_previous_status_before_hold': False,
})
# Post to chatter
user_name = self.env.user.name
resume_date = fields.Date.today().strftime('%B %d, %Y')
message_body = f'''
<div class="alert alert-success" role="alert">
<h5 class="alert-heading"><i class="fa fa-play-circle"></i> Application Resumed</h5>
<ul>
<li><strong>Resumed By:</strong> {user_name}</li>
<li><strong>Date:</strong> {resume_date}</li>
<li><strong>Restored To:</strong> {prev_label}</li>
</ul>
</div>
'''
self.message_post(
body=Markup(message_body),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
return True
def action_resubmit_from_withdrawn(self):
"""Return a withdrawn application to Ready for Submission for correction and resubmission."""
self.ensure_one()
if self.x_fc_adp_application_status != 'withdrawn':
raise UserError("This action is only available for withdrawn applications.")
self.with_context(skip_status_validation=True).write({
'x_fc_adp_application_status': 'ready_submission',
})
user_name = self.env.user.name
resubmit_date = fields.Date.today().strftime('%B %d, %Y')
message_body = f'''
<div class="alert alert-info" role="alert">
<h5 class="alert-heading"><i class="fa fa-repeat"></i> Application Returned for Resubmission</h5>
<ul>
<li><strong>Returned By:</strong> {user_name}</li>
<li><strong>Date:</strong> {resubmit_date}</li>
<li><strong>Status Returned To:</strong> Ready for Submission</li>
</ul>
<hr>
<p class="mb-0"><i class="fa fa-info-circle"></i> Make corrections and click <strong>Submit Application</strong> to resubmit.</p>
</div>
'''
self.message_post(
body=Markup(message_body),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
return True
def action_set_ready_to_bill(self):
"""Open the Ready to Bill wizard to collect POD and delivery date.
The wizard will:
- Collect Proof of Delivery document
- Set the delivery date
- Validate device verification is complete
- Mark the order as Ready to Bill
"""
self.ensure_one()
# Validate we're in a status that can move to ready_bill
if self.x_fc_adp_application_status not in ('approved', 'approved_deduction'):
raise UserError(
"Order can only be marked as 'Ready to Bill' from 'Approved' status."
)
# Check device verification first (this can't be done in wizard)
if not self.x_fc_device_verification_complete:
raise UserError(
"Device approval verification must be completed before marking as Ready to Bill.\n\n"
"Please verify which devices were approved by ADP using the 'Mark as Approved' button first."
)
# Open the wizard to collect POD and delivery date
return {
'name': 'Ready to Bill',
'type': 'ir.actions.act_window',
'res_model': 'fusion_claims.ready.to.bill.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'active_id': self.id,
'active_model': 'sale.order',
},
}
def action_set_ready_to_bill_direct(self):
"""Direct method to mark as ready to bill (used when POD already uploaded).
This is kept for backward compatibility and for cases where POD is already uploaded.
"""
self.ensure_one()
# Validate we're in a status that can move to ready_bill
if self.x_fc_adp_application_status not in ('approved', 'approved_deduction'):
raise UserError(
"Order can only be marked as 'Ready to Bill' from 'Approved' status."
)
# Check POD
if not self.x_fc_proof_of_delivery:
# Redirect to wizard
return self.action_set_ready_to_bill()
# Check delivery date
if not self.x_fc_adp_delivery_date:
# Redirect to wizard
return self.action_set_ready_to_bill()
# Check device verification
if not self.x_fc_device_verification_complete:
raise UserError(
"Device approval verification must be completed before marking as Ready to Bill.\n\n"
"Please verify which devices were approved by ADP using the 'Mark as Approved' button first."
)
# All validations passed - set status
from markupsafe import Markup
from datetime import date
self.with_context(skip_status_validation=True).write({
'x_fc_adp_application_status': 'ready_bill',
})
# Post to chatter with nice card style using Bootstrap classes for dark/light mode
self.message_post(
body=Markup(
'<div class="alert alert-success" role="alert">'
'<h5 class="alert-heading"><i class="fa fa-dollar"/> Ready to Bill</h5>'
f'<p class="mb-1"><strong>Date:</strong> {date.today().strftime("%B %d, %Y")}</p>'
'<p class="mb-1"><strong>Prerequisites Verified:</strong></p>'
'<ul class="mb-0">'
'<li>Proof of Delivery uploaded</li>'
'<li>ADP Delivery Date set</li>'
'<li>Device verification complete</li>'
'</ul>'
'</div>'
),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
return True
def action_mark_as_billed(self):
"""Mark order as billed after validating invoices are posted.
Validates:
- ADP invoice exists and is posted
- For REG clients: Client invoice exists and is posted
"""
self.ensure_one()
# Validate we're in ready_bill status
if self.x_fc_adp_application_status != 'ready_bill':
raise UserError(
"Order can only be marked as 'Billed' from 'Ready to Bill' status."
)
# Check ADP invoice
AccountMove = self.env['account.move'].sudo()
adp_invoices = AccountMove.search([
('x_fc_source_sale_order_id', '=', self.id),
('x_fc_adp_invoice_portion', '=', 'adp'),
('state', '=', 'posted'),
])
if not adp_invoices:
raise UserError(
"ADP invoice must be created and posted before marking as Billed.\n\n"
"Please create and post the ADP invoice first."
)
# For REG clients, check client invoice
if self.x_fc_client_type == 'REG':
client_invoices = AccountMove.search([
('x_fc_source_sale_order_id', '=', self.id),
('x_fc_adp_invoice_portion', '=', 'client'),
('state', '=', 'posted'),
])
if not client_invoices:
raise UserError(
"Client invoice must be created and posted before marking as Billed.\n\n"
"For REG clients, both the ADP invoice (75%) and Client invoice (25%) must be posted."
)
# All validations passed - set status and billing date
from markupsafe import Markup
from datetime import date
self.with_context(skip_status_validation=True).write({
'x_fc_adp_application_status': 'billed',
'x_fc_billing_date': date.today(),
})
# Update ADP invoice billing status to 'submitted'
for adp_inv in adp_invoices:
if adp_inv.x_fc_adp_billing_status in ('waiting', 'not_applicable'):
adp_inv.write({'x_fc_adp_billing_status': 'submitted'})
_logger.info(f"Updated ADP invoice {adp_inv.name} billing status to 'submitted'")
# Build invoice list
invoice_list = ', '.join(adp_invoices.mapped('name'))
if self.x_fc_client_type == 'REG':
invoice_list += ', ' + ', '.join(client_invoices.mapped('name'))
# Calculate total billed
total_billed = sum(adp_invoices.mapped('amount_total'))
if self.x_fc_client_type == 'REG':
total_billed += sum(client_invoices.mapped('amount_total'))
# Post to chatter with nice card style using Bootstrap classes for dark/light mode
self.message_post(
body=Markup(
'<div class="alert alert-info" role="alert">'
'<h5 class="alert-heading"><i class="fa fa-file-text"/> Marked as Billed</h5>'
f'<p class="mb-1"><strong>Billing Date:</strong> {date.today().strftime("%B %d, %Y")}</p>'
f'<p class="mb-1"><strong>Posted Invoices:</strong> {invoice_list}</p>'
f'<p class="mb-0"><strong>Total Billed:</strong> ${total_billed:,.2f}</p>'
'</div>'
),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
return True
def _check_unapproved_devices(self):
"""Check if there are any unapproved ADP devices in the order.
Only checks lines with valid ADP device codes in the database.
Non-ADP items are ignored.
"""
self.ensure_one()
ADPDevice = self.env['fusion.adp.device.code'].sudo()
unapproved = self.env['sale.order.line']
for line in self.order_line:
if line.display_type:
continue
if not line.product_id or line.product_uom_qty <= 0:
continue
if line.x_fc_adp_approved:
continue
# Check if this has a valid ADP device code
device_code = line._get_adp_device_code()
if device_code and ADPDevice.search_count([('device_code', '=', device_code), ('active', '=', True)]) > 0:
unapproved |= line
return unapproved
def _get_approved_devices_summary(self):
"""Get a summary of approved vs unapproved devices."""
self.ensure_one()
lines_with_codes = self.order_line.filtered(
lambda l: not l.display_type
and l.product_id
and l.product_uom_qty > 0
and l._get_adp_device_code()
)
approved = lines_with_codes.filtered(lambda l: l.x_fc_adp_approved)
unapproved = lines_with_codes - approved
return {
'total': len(lines_with_codes),
'approved': len(approved),
'unapproved': len(unapproved),
'unapproved_lines': unapproved,
}
def action_mark_client_paid(self):
"""Mark order as client paid (25%)."""
for order in self:
if order._is_adp_sale():
order.x_fc_adp_status = 'client_paid'
return True
def action_mark_delivered(self):
"""Mark order as delivered."""
for order in self:
if order._is_adp_sale():
order.x_fc_adp_status = 'delivered'
return True
def action_mark_billed(self):
"""Mark order as billed to ADP (75%)."""
for order in self:
if order._is_adp_sale():
order.x_fc_adp_status = 'billed'
return True
def action_mark_closed(self):
"""Mark order as closed."""
for order in self:
if order._is_adp_sale():
order.x_fc_adp_status = 'closed'
return True
# ==========================================================================
# VIEW INVOICES BY TYPE (Smart button actions)
# ==========================================================================
def action_view_adp_invoices(self):
"""Open list of ADP portion invoices for this order."""
self.ensure_one()
adp_invoices = self.env['account.move'].sudo().search([
('x_fc_source_sale_order_id', '=', self.id),
('x_fc_adp_invoice_portion', '=', 'adp'),
('state', '!=', 'cancel'),
])
# Include manually mapped ADP invoice
if self.x_fc_adp_invoice_id and self.x_fc_adp_invoice_id.state != 'cancel':
adp_invoices |= self.x_fc_adp_invoice_id
action = {
'name': 'ADP Invoices',
'type': 'ir.actions.act_window',
'res_model': 'account.move',
'view_mode': 'list,form',
'domain': [('id', 'in', adp_invoices.ids)],
'context': {'default_move_type': 'out_invoice'},
}
if len(adp_invoices) == 1:
action['view_mode'] = 'form'
action['res_id'] = adp_invoices.id
return action
def action_view_client_invoices(self):
"""Open list of Client portion invoices for this order."""
self.ensure_one()
client_invoices = self.env['account.move'].sudo().search([
('x_fc_source_sale_order_id', '=', self.id),
('x_fc_adp_invoice_portion', '=', 'client'),
('state', '!=', 'cancel'),
])
# Include manually mapped Client invoice
if self.x_fc_client_invoice_id and self.x_fc_client_invoice_id.state != 'cancel':
client_invoices |= self.x_fc_client_invoice_id
action = {
'name': 'Client Invoices',
'type': 'ir.actions.act_window',
'res_model': 'account.move',
'view_mode': 'list,form',
'domain': [('id', 'in', client_invoices.ids)],
'context': {'default_move_type': 'out_invoice'},
}
if len(client_invoices) == 1:
action['view_mode'] = 'form'
action['res_id'] = client_invoices.id
return action
# ==========================================================================
# SPLIT INVOICE CREATION (Client 25% and ADP 75%)
# ==========================================================================
def action_create_client_invoice(self):
"""Create invoice for client portion (25% for REG clients, 0% for others).
NOTE: Client invoice can be created WITHOUT device verification.
This allows clients to pay their portion and receive products before ADP approval.
Device verification is only required for ADP invoice creation.
BLOCKING for Modification reasons:
- Modifications to NON-ADP Equipment and Modifications to ADP Equipment
require ADP approval before client invoice can be created.
WARNING for Replacement reasons:
- If previous funding was less than 5 years ago, show warning about possible deductions.
"""
self.ensure_one()
if not self._is_adp_sale():
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Not an ADP Sale',
'message': 'Client invoices are only for ADP sales.',
'type': 'warning',
}
}
client_type = self._get_client_type()
if client_type != 'REG':
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'No Client Portion',
'message': f'{client_type} clients have 100% ADP funding. No client invoice needed.',
'type': 'info',
}
}
# =================================================================
# BLOCKING: Modification reasons require ADP approval first
# =================================================================
reason = self.x_fc_reason_for_application
status = self.x_fc_adp_application_status
if reason in ('mod_non_adp', 'mod_adp'):
if status not in ('approved', 'approved_deduction'):
reason_label = dict(self._fields['x_fc_reason_for_application'].selection or []).get(
reason, reason
)
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'ADP Approval Required',
'message': f'Cannot create client invoice for "{reason_label}".\n\n'
f'ADP application must be approved before creating invoices for modification requests.\n\n'
f'Current status: {status or "Not set"}',
'type': 'danger',
'sticky': True,
}
}
# =================================================================
# WARNING: Replacement reasons with <5 years funding
# =================================================================
if reason in ('replace_status', 'replace_size', 'replace_worn') and self.x_fc_under_5_years:
years = self.x_fc_years_since_funding
# Show warning but allow proceeding
self.message_post(
body=Markup(
'<div class="alert alert-warning" role="alert">'
'<strong><i class="fa fa-exclamation-triangle"/> Replacement Warning</strong><br/>'
f'Previous funding was only <strong>{years:.1f} years ago</strong> (less than 5 years).<br/>'
'ADP may apply deductions to this replacement claim. Please verify the approval letter.'
'</div>'
),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
# NO VERIFICATION CHECK - Client invoice can be created before ADP approval
# User will need to complete verification before creating ADP invoice
# Create client invoice (25% portion)
invoice = self._create_adp_split_invoice(invoice_type='client')
if invoice:
result = {
'name': 'Client Invoice (25%)',
'type': 'ir.actions.act_window',
'res_model': 'account.move',
'res_id': invoice.id,
'view_mode': 'form',
'target': 'current',
}
return result
return True
def action_create_adp_invoice(self):
"""Create invoice for ADP portion (75% for REG clients, 100% for others).
NOTE: Device verification MUST be completed before creating ADP invoice.
Verification can be done from the Sales Order or from the Client Invoice (if created first).
Proof of Delivery is REQUIRED before ADP invoice creation.
"""
self.ensure_one()
if not self._is_adp_sale():
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Not an ADP Sale',
'message': 'ADP invoices are only for ADP sales.',
'type': 'warning',
}
}
# =================================================================
# REQUIREMENT: Proof of Delivery must be uploaded
# =================================================================
if not self.x_fc_proof_of_delivery:
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Proof of Delivery Required',
'message': 'Please upload the Proof of Delivery document in the ADP Documents tab before creating the ADP invoice.',
'type': 'danger',
'sticky': True,
}
}
# =================================================================
# REQUIREMENT: Device verification MUST be complete for ADP invoice
# =================================================================
if not self.x_fc_device_verification_complete:
# Check if there's a client invoice - provide appropriate message
client_invoice = self.env['account.move'].sudo().search([
('x_fc_source_sale_order_id', '=', self.id),
('x_fc_adp_invoice_portion', '=', 'client'),
('state', '!=', 'cancel'),
], limit=1)
device_count = self.x_fc_total_device_count
approved_count = self.x_fc_approved_device_count
if device_count > 0:
device_info = f'{approved_count}/{device_count} devices verified.'
else:
device_info = 'No ADP devices detected on this order.'
if client_invoice:
message = (
f'Cannot create ADP invoice: Device verification is not complete.\n\n'
f'{device_info}\n\n'
f'Please complete verification from either:\n'
f'• This Sales Order: Click "Verify Device Approval"\n'
f'• Client Invoice ({client_invoice.name}): Click "Verify Device Approval"'
)
else:
message = (
f'Cannot create ADP invoice: Device verification is not complete.\n\n'
f'{device_info}\n\n'
f'Click "Verify Device Approval" to review which devices were approved by ADP.'
)
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Device Verification Required',
'message': message,
'type': 'danger',
'sticky': True,
}
}
# =================================================================
# REQUIREMENT: At least one device must be approved for ADP invoice
# =================================================================
approved_count = self.x_fc_approved_device_count
total_count = self.x_fc_total_device_count
if approved_count == 0:
if total_count > 0:
message = (
f'Cannot create ADP invoice: No devices are approved.\n\n'
f'All {total_count} device(s) on this order were marked as NOT approved by ADP.\n\n'
f'If this is incorrect, click "Verify Device Approval" to update the approval status.'
)
else:
message = (
'Cannot create ADP invoice: No ADP-funded devices found.\n\n'
'This order has no products with ADP device codes. '
'ADP invoices can only be created for orders with approved ADP devices.'
)
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'No Approved Devices',
'message': message,
'type': 'danger',
'sticky': True,
}
}
# Check for unapproved devices - show info message but allow creation
# Unapproved items will be excluded from the ADP invoice
unapproved = self._check_unapproved_devices()
unapproved_message = None
if unapproved:
device_names = ', '.join(unapproved.mapped('product_id.name')[:3])
if len(unapproved) > 3:
device_names += f' and {len(unapproved) - 3} more'
unapproved_message = f"Note: {len(unapproved)} unapproved item(s) will be excluded: {device_names}"
# Create ADP invoice
invoice = self._create_adp_split_invoice(invoice_type='adp')
if invoice:
return {
'name': 'ADP Invoice',
'type': 'ir.actions.act_window',
'res_model': 'account.move',
'res_id': invoice.id,
'view_mode': 'form',
'target': 'current',
}
return True
def _create_adp_split_invoice(self, invoice_type='client'):
"""
Create a split invoice for ADP sales.
Args:
invoice_type: 'client' for client portion invoice, 'adp' for ADP portion invoice
Returns:
account.move record
"""
self.ensure_one()
# Re-read fresh data from the database to get current values
self.invalidate_recordset()
# Get client type to determine percentages
client_type = self._get_client_type()
if client_type == 'REG':
client_pct = 0.25
adp_pct = 0.75
else:
# ODS, OWP, ACS, etc. - 100% ADP, 0% client
client_pct = 0.0
adp_pct = 1.0
if invoice_type == 'client' and client_pct == 0:
return False # No client invoice for non-REG clients
# Determine invoice label
if invoice_type == 'client':
invoice_name_suffix = f' (Client {int(client_pct*100)}%)'
else:
invoice_name_suffix = f' (ADP {int(adp_pct*100)}%)'
# Prepare base invoice values
invoice_vals = self._prepare_invoice()
invoice_vals['invoice_origin'] = f"{self.name}{invoice_name_suffix}"
# Add marker for invoice type and link to source sale order
invoice_vals['x_fc_adp_invoice_portion'] = invoice_type
invoice_vals['x_fc_source_sale_order_id'] = self.id
# Copy Studio fields if they exist on the invoice model
# Use helper function to safely set values
AccountMove = self.env['account.move']
def safe_set_field(field_name, value, target_dict):
"""Safely set a field value, checking field type and valid options."""
if field_name not in AccountMove._fields:
return
field = AccountMove._fields[field_name]
try:
if field.type == 'boolean':
# Convert to boolean
target_dict[field_name] = bool(value)
elif field.type == 'selection':
# Check if value is valid
selection = field.selection
if callable(selection):
selection = selection(AccountMove)
valid_values = [s[0] for s in selection] if selection else []
if value in valid_values:
target_dict[field_name] = value
elif str(value).lower() in [v.lower() for v in valid_values if isinstance(v, str)]:
# Find the matching case
for v in valid_values:
if isinstance(v, str) and v.lower() == str(value).lower():
target_dict[field_name] = v
break
elif field.type == 'many2one':
# Handle Many2one - pass record id or False
if hasattr(value, 'id'):
target_dict[field_name] = value.id
elif value:
target_dict[field_name] = value
else:
# Char, Text, etc. - just set directly
target_dict[field_name] = value
except Exception:
pass # Skip if any error
# Invoice type will be set based on invoice_type parameter ('adp' or 'client')
# and the sale type - handled below when setting x_fc_invoice_type
# Copy primary serial to invoice
primary_serial = self.x_fc_primary_serial
if primary_serial:
invoice_vals['x_fc_primary_serial'] = primary_serial
# Set invoice type based on invoice_type parameter and sale type
if invoice_type == 'client':
# Client portion invoice - set to 'adp_client'
fc_invoice_type = 'adp_client'
else:
# ADP/Funder portion invoice - use the sale type directly
# This preserves the sale type context (ADP, ADP/ODSP, ODSP, WSIB, etc.)
sale_type = self.x_fc_sale_type or 'adp'
# For ADP-related sale types, use the sale type as invoice type
fc_invoice_type = sale_type
# For ADP invoices: Change customer to ADP contact, keep original client as delivery address
original_partner = self.partner_id
original_delivery = self.partner_shipping_id or self.partner_id
# Find the ADP contact (search by name)
adp_partner = self.env['res.partner'].sudo().search([
'|', '|', '|',
('name', 'ilike', 'ADP (Assistive Device Program)'),
('name', 'ilike', 'Assistive Device Program'),
('name', '=', 'ADP'),
('name', 'ilike', 'ADP -'),
], limit=1)
if adp_partner:
# Set ADP as the invoice customer
invoice_vals['partner_id'] = adp_partner.id
# Keep original client as delivery address
invoice_vals['partner_shipping_id'] = original_partner.id
invoice_vals.update({
'x_fc_invoice_type': fc_invoice_type,
'x_fc_client_type': self._get_client_type(),
'x_fc_claim_number': self.x_fc_claim_number,
'x_fc_client_ref_1': self.x_fc_client_ref_1,
'x_fc_client_ref_2': self.x_fc_client_ref_2,
'x_fc_adp_delivery_date': self.x_fc_adp_delivery_date,
'x_fc_service_start_date': self.x_fc_service_start_date,
'x_fc_service_end_date': self.x_fc_service_end_date,
'x_fc_authorizer_id': self.x_fc_authorizer_id.id if self.x_fc_authorizer_id else False,
# Set ADP billing status to 'waiting' by default for ADP invoices
'x_fc_adp_billing_status': 'waiting' if invoice_type == 'adp' else 'not_applicable',
})
# Create invoice
invoice = self.env['account.move'].sudo().create(invoice_vals)
# Create invoice lines - include sections, notes, AND products
#
# PORTION CALCULATION:
# - Get ADP price from device codes database (priority) or product field
# - Calculate portions based on client type: REG = 75%/25%, Others = 100%/0%
# - Client Invoice: price_unit = client portion per unit
# - ADP Invoice: price_unit = ADP portion per unit
# - Both portions stored on line for reference
#
ADPDevice = self.env['fusion.adp.device.code'].sudo()
invoice_lines = []
price_mismatches = [] # Track products with price mismatches
for line in self.order_line:
# For section and note lines, create minimal line
if line.display_type in ('line_section', 'line_note'):
invoice_lines.append({
'move_id': invoice.id,
'display_type': line.display_type,
'name': line.name,
'sequence': line.sequence,
})
continue
# Skip lines without products or zero quantity
if not line.product_id or line.product_uom_qty <= 0:
continue
# =================================================================
# CHECK 1: Is this a NON-ADP funded product?
# =================================================================
is_non_adp_funded = line.product_id.is_non_adp_funded()
# =================================================================
# CHECK 2: Get ADP device info from database
# =================================================================
device_code = line._get_adp_device_code()
adp_device = None
is_adp_device = False
db_adp_price = 0
if device_code and not is_non_adp_funded:
adp_device = ADPDevice.search([
('device_code', '=', device_code),
('active', '=', True)
], limit=1)
is_adp_device = bool(adp_device)
if adp_device:
db_adp_price = adp_device.adp_price or 0
# Determine if item is approved
is_approved = line.x_fc_adp_approved
# =================================================================
# GET ADP PRICE - Priority: DB > Product Field > Line Price
# =================================================================
product_tmpl = line.product_id.product_tmpl_id
product_adp_price = 0
# Try product fields
if hasattr(product_tmpl, 'x_fc_adp_price'):
product_adp_price = getattr(product_tmpl, 'x_fc_adp_price', 0) or 0
# Determine final ADP price to use
if db_adp_price > 0:
adp_max_price = db_adp_price
# Check for price mismatch
if product_adp_price > 0 and abs(db_adp_price - product_adp_price) > 0.01:
price_mismatches.append({
'product': line.product_id,
'device_code': device_code,
'db_price': db_adp_price,
'product_price': product_adp_price,
})
elif product_adp_price > 0:
adp_max_price = product_adp_price
else:
# Fallback to selling price
adp_max_price = line.price_unit
# =================================================================
# CALCULATE PORTIONS based on client type
# =================================================================
qty = line.product_uom_qty
total_adp_base = adp_max_price * qty
if is_non_adp_funded or not is_adp_device:
# NON-ADP item: Client pays 100%
adp_portion = 0
client_portion = line.price_subtotal
is_full_client = True
elif is_adp_device and not is_approved:
# ADP device NOT approved: Client pays 100%
adp_portion = 0
client_portion = line.price_subtotal
is_full_client = True
else:
# ADP device APPROVED: Calculate based on client type
is_full_client = False
if client_type == 'REG':
adp_portion = total_adp_base * 0.75
client_portion = total_adp_base * 0.25
else:
# ODS, OWP, ACS, etc. = 100% ADP
adp_portion = total_adp_base
client_portion = 0
# Apply deductions if any
if line.x_fc_deduction_type == 'pct' and line.x_fc_deduction_value:
# PCT deduction: ADP pays X% of their normal portion
effective_pct = line.x_fc_deduction_value / 100
if client_type == 'REG':
adp_portion = total_adp_base * 0.75 * effective_pct
else:
adp_portion = total_adp_base * effective_pct
client_portion = total_adp_base - adp_portion
elif line.x_fc_deduction_type == 'amt' and line.x_fc_deduction_value:
# AMT deduction: Fixed amount deducted from ADP
adp_portion = max(0, adp_portion - line.x_fc_deduction_value)
client_portion = total_adp_base - adp_portion
# =================================================================
# DETERMINE INVOICE LINE AMOUNT
# =================================================================
if invoice_type == 'client':
if is_full_client:
portion_amount = client_portion
line_name = line.name if not (is_adp_device and not is_approved) else f"{line.name} [NOT APPROVED - 100% Client]"
else:
portion_amount = client_portion
line_name = line.name
else: # ADP invoice
if is_non_adp_funded or not is_adp_device or (is_adp_device and not is_approved):
# Skip from ADP invoice
continue
portion_amount = adp_portion
line_name = line.name
# Calculate adjusted price per unit
adjusted_price = portion_amount / qty if qty else 0
# Build invoice line vals
line_vals = {
'move_id': invoice.id,
'product_id': line.product_id.id,
'name': line_name,
'quantity': qty,
'product_uom_id': line.product_uom_id.id,
'price_unit': adjusted_price,
'discount': line.discount,
'tax_ids': [(6, 0, line.tax_ids.ids)],
'sale_line_ids': [(6, 0, [line.id])],
'sequence': line.sequence,
}
# Copy serial number and other fields
if 'x_fc_serial_number' in self.env['account.move.line']._fields:
line_vals['x_fc_serial_number'] = line.x_fc_serial_number
if 'x_fc_device_placement' in self.env['account.move.line']._fields:
line_vals['x_fc_device_placement'] = line.x_fc_device_placement
# Copy deduction fields so export verification can recalculate correctly
if 'x_fc_deduction_type' in self.env['account.move.line']._fields:
line_vals['x_fc_deduction_type'] = line.x_fc_deduction_type or 'none'
if 'x_fc_deduction_value' in self.env['account.move.line']._fields:
line_vals['x_fc_deduction_value'] = line.x_fc_deduction_value or 0
# Store BOTH portions on invoice line (for display)
if 'x_fc_adp_portion' in self.env['account.move.line']._fields:
line_vals['x_fc_adp_portion'] = adp_portion
if 'x_fc_client_portion' in self.env['account.move.line']._fields:
line_vals['x_fc_client_portion'] = client_portion
if 'x_fc_adp_max_price' in self.env['account.move.line']._fields:
line_vals['x_fc_adp_max_price'] = adp_max_price
if 'x_fc_adp_approved' in self.env['account.move.line']._fields:
line_vals['x_fc_adp_approved'] = is_approved
if 'x_fc_adp_device_type' in self.env['account.move.line']._fields and adp_device:
line_vals['x_fc_adp_device_type'] = adp_device.device_type or ''
invoice_lines.append(line_vals)
if invoice_lines:
self.env['account.move.line'].sudo().create(invoice_lines)
# =================================================================
# POST PRICE MISMATCH WARNINGS
# =================================================================
if price_mismatches:
mismatch_msg = '<p><strong>⚠️ ADP Price Mismatches Detected</strong></p><ul>'
for pm in price_mismatches:
mismatch_msg += (
f'<li><b>{pm["product"].name}</b> ({pm["device_code"]}): '
f'Product price ${pm["product_price"]:.2f} vs Database ${pm["db_price"]:.2f}</li>'
)
mismatch_msg += '</ul><p>Database prices were used. Consider updating product prices.</p>'
self.message_post(body=mismatch_msg, message_type='notification', subtype_xmlid='mail.mt_note')
# Auto-update product prices from database
for pm in price_mismatches:
product_tmpl = pm['product'].product_tmpl_id
if hasattr(product_tmpl, 'x_fc_adp_price'):
product_tmpl.sudo().write({'x_fc_adp_price': pm['db_price']})
# =================================================================
# POST INVOICE CREATION MESSAGE TO CHATTER
# =================================================================
if invoice and invoice_lines:
# Calculate totals from the created invoice
invoice.invalidate_recordset()
invoice_total = invoice.amount_total
# Calculate portion totals from the lines we just created
total_adp_portion = sum(
line_vals.get('x_fc_adp_portion', 0)
for line_vals in invoice_lines
if isinstance(line_vals, dict) and 'x_fc_adp_portion' in line_vals
)
total_client_portion = sum(
line_vals.get('x_fc_client_portion', 0)
for line_vals in invoice_lines
if isinstance(line_vals, dict) and 'x_fc_client_portion' in line_vals
)
user_name = self.env.user.name
create_date = fields.Date.today().strftime('%B %d, %Y')
client_name = self.partner_id.name or 'N/A'
if invoice_type == 'client':
# Client Invoice - Blue theme using Bootstrap
pct_label = f'{int(client_pct*100)}%'
invoice_msg = Markup(f'''<div class="alert alert-primary" role="alert">
<h5 class="alert-heading"><i class="fa fa-file-text-o"></i> Client Invoice Created</h5>
<table class="table table-sm table-borderless mb-2">
<tr><td><strong>Client:</strong></td><td>{client_name}</td></tr>
<tr><td><strong>Created By:</strong></td><td>{user_name}</td></tr>
<tr><td><strong>Date:</strong></td><td>{create_date}</td></tr>
</table>
<hr class="my-2">
<table class="table table-sm table-borderless mb-2">
<tr><td><strong>Client Portion ({pct_label}):</strong></td><td><strong>${total_client_portion:,.2f}</strong></td></tr>
<tr><td class="text-muted">Invoice Total:</td><td><strong>${invoice_total:,.2f}</strong></td></tr>
</table>
<a href="/web#id={invoice.id}&amp;model=account.move&amp;view_type=form" class="alert-link"><i class="fa fa-external-link"></i> View Invoice</a>
</div>''')
else:
# ADP Invoice - Green theme using Bootstrap
pct_label = f'{int(adp_pct*100)}%'
invoice_msg = Markup(f'''<div class="alert alert-success" role="alert">
<h5 class="alert-heading"><i class="fa fa-institution"></i> ADP Invoice Created</h5>
<table class="table table-sm table-borderless mb-2">
<tr><td><strong>Client:</strong></td><td>{client_name}</td></tr>
<tr><td><strong>Created By:</strong></td><td>{user_name}</td></tr>
<tr><td><strong>Date:</strong></td><td>{create_date}</td></tr>
</table>
<hr class="my-2">
<table class="table table-sm table-borderless mb-2">
<tr><td><strong>ADP Portion ({pct_label}):</strong></td><td><strong>${total_adp_portion:,.2f}</strong></td></tr>
<tr><td class="text-muted">Invoice Total:</td><td><strong>${invoice_total:,.2f}</strong></td></tr>
</table>
<a href="/web#id={invoice.id}&amp;model=account.move&amp;view_type=form" class="alert-link"><i class="fa fa-external-link"></i> View Invoice</a>
</div>''')
self.message_post(
body=invoice_msg,
message_type='notification',
subtype_xmlid='mail.mt_note',
)
return invoice
# ==========================================================================
# OVERRIDE _get_invoiceable_lines TO INCLUDE ALL SECTIONS AND NOTES
# ==========================================================================
def _get_invoiceable_lines(self, final=False):
"""Override to ensure ALL sections and notes are included in invoices.
Standard Odoo behavior only includes sections/notes if they have invoiceable
product lines AFTER them. This causes warranty, refund policy, and other
important sections at the end of the order to be dropped from invoices.
This override includes all sections and notes regardless of position.
"""
# Get the standard invoiceable lines first
invoiceable_lines = super()._get_invoiceable_lines(final)
# Collect all section and note lines from the order
all_display_lines = self.order_line.filtered(
lambda l: l.display_type in ('line_section', 'line_subsection', 'line_note')
)
# Add any sections/notes that weren't included by the standard method
missing_display_lines = all_display_lines - invoiceable_lines
if missing_display_lines:
# Combine and sort by sequence to maintain order
combined = invoiceable_lines | missing_display_lines
return combined.sorted(key=lambda l: l.sequence)
return invoiceable_lines
# ==========================================================================
# INVOICE PREPARATION (Copy ADP fields to Invoice)
# ==========================================================================
def _prepare_invoice(self):
"""Override to copy ADP fields to the invoice."""
vals = super()._prepare_invoice()
if self._is_adp_sale():
# Normalize sale type to match x_fc_invoice_type selection (lowercase)
sale_type_raw = self.x_fc_sale_type or ''
sale_type_normalized = str(sale_type_raw).lower() if sale_type_raw else 'adp'
valid_types = ('adp', 'adp_odsp', 'odsp', 'wsib', 'direct_private', 'insurance',
'march_of_dimes', 'muscular_dystrophy', 'other', 'rental')
if sale_type_normalized not in valid_types:
if 'adp' in sale_type_normalized:
sale_type_normalized = 'adp'
else:
sale_type_normalized = 'other'
vals.update({
'x_fc_invoice_type': sale_type_normalized,
'x_fc_client_type': self.x_fc_client_type,
'x_fc_claim_number': self.x_fc_claim_number,
'x_fc_client_ref_1': self.x_fc_client_ref_1,
'x_fc_client_ref_2': self.x_fc_client_ref_2,
'x_fc_adp_delivery_date': self.x_fc_adp_delivery_date,
'x_fc_service_start_date': self.x_fc_service_start_date,
'x_fc_service_end_date': self.x_fc_service_end_date,
'x_fc_authorizer_id': self.x_fc_authorizer_id.id if self.x_fc_authorizer_id else False,
})
return vals
# ==========================================================================
# DOCUMENT CHATTER POSTING
# ==========================================================================
def _post_document_to_chatter(self, field_name, document_label=None, preserve_copy=False):
"""Post a document to the chatter, reusing the existing field attachment.
By default, references the existing ir.attachment (created by Odoo for
attachment=True fields) instead of creating a duplicate.
Args:
field_name: The binary field name (e.g., 'x_fc_final_submitted_application')
document_label: Optional label for the document (defaults to field string)
preserve_copy: If True, creates a separate copy (used when the original
is about to be deleted/replaced and we need to keep a snapshot).
"""
self.ensure_one()
data = getattr(self, field_name, None)
if not data:
return
if not document_label:
field_obj = self._fields.get(field_name)
document_label = field_obj.string if field_obj else field_name
if preserve_copy:
proper_name = self._build_attachment_name(field_name)
base, _, ext = proper_name.rpartition('.')
if base:
copy_name = f"{base}_archived.{ext}"
else:
copy_name = f"{proper_name}_archived"
attachment = self.env['ir.attachment'].sudo().create({
'name': copy_name,
'datas': data,
'res_model': 'sale.order',
'res_id': self.id,
})
else:
attachment = self._get_document_attachment(field_name)
if not attachment:
return
self._prepare_attachment_for_email(attachment, field_name=field_name)
user_name = self.env.user.name
now = fields.Datetime.now()
body = Markup(
'<p><strong>{label}</strong> uploaded by <b>{user}</b></p>'
'<p class="text-muted small">{timestamp}</p>'
).format(
label=document_label,
user=user_name,
timestamp=now.strftime('%Y-%m-%d %H:%M:%S'),
)
self.message_post(
body=body,
message_type='notification',
subtype_xmlid='mail.mt_note',
attachment_ids=[attachment.id],
)
return attachment
# ==========================================================================
# AUTOMATIC EMAIL SENDING
# ==========================================================================
def _is_email_notifications_enabled(self):
"""Check if email notifications are enabled in settings."""
ICP = self.env['ir.config_parameter'].sudo()
return ICP.get_param('fusion_claims.enable_email_notifications', 'True').lower() in ('true', '1', 'yes')
def _get_office_cc_emails(self):
"""Get office notification emails from company settings."""
company = self.company_id or self.env.company
partners = company.sudo().x_fc_office_notification_ids
return [p.email for p in partners if p.email]
def _get_email_recipients(self, include_client=False, include_authorizer=True, include_sales_rep=True):
"""Get standard email recipients for ADP notifications.
Returns dict with:
- 'to': List of primary recipient emails
- 'cc': List of CC recipient emails
- 'office_cc': List of office CC emails from settings
"""
self.ensure_one()
to_emails = []
cc_emails = []
# Get authorizer
authorizer = self.x_fc_authorizer_id
# Get sales rep
sales_rep = self.user_id
# Get client
client = self.partner_id
# Build recipient lists
if include_client and client and client.email:
to_emails.append(client.email)
if include_authorizer and authorizer and authorizer.email:
if to_emails:
cc_emails.append(authorizer.email)
else:
to_emails.append(authorizer.email)
if include_sales_rep and sales_rep and sales_rep.email:
cc_emails.append(sales_rep.email)
# Get office CC emails
office_cc = self._get_office_cc_emails()
return {
'to': to_emails,
'cc': cc_emails,
'office_cc': office_cc,
'authorizer': authorizer,
'sales_rep': sales_rep,
'client': client,
}
def _check_authorizer_portal_access(self):
"""Check if authorizer has logged into portal.
Returns True if authorizer has a portal user with a password set.
"""
self.ensure_one()
authorizer = self.x_fc_authorizer_id
if not authorizer:
return False
# Find portal user for this partner
portal_user = self.env['res.users'].sudo().search([
('partner_id', '=', authorizer.id),
('share', '=', True), # Portal users have share=True
], limit=1)
if not portal_user:
return False
# Check if user has logged in (has password and has login date)
return bool(portal_user.login_date)
def _build_case_detail_rows(self, include_amounts=False):
"""Build standard case detail rows for email templates."""
self.ensure_one()
def fmt(d):
return d.strftime('%B %d, %Y') if d else None
rows = [
('Case', self.name),
('Client', self.partner_id.name or 'N/A'),
('Claim Number', self.x_fc_claim_number or None),
('Client Ref 1', self.x_fc_client_ref_1 or None),
('Client Ref 2', self.x_fc_client_ref_2 or None),
('Assessment Date', fmt(self.x_fc_assessment_end_date)),
('Submission Date', fmt(self.x_fc_claim_submission_date)),
('Approval Date', fmt(self.x_fc_claim_approval_date)),
('Delivery Date', fmt(self.x_fc_adp_delivery_date)),
]
if include_amounts:
rows.extend([
('ADP Portion', f'${self.x_fc_adp_portion_total or 0:,.2f}'),
('Client Portion', f'${self.x_fc_client_portion_total or 0:,.2f}'),
('Total', f'${self.amount_total:,.2f}'),
])
# Filter out None values
return [(l, v) for l, v in rows if v is not None]
def _email_chatter_log(self, label, email_to, email_cc=None, extra_lines=None):
"""Post a concise chatter note confirming an email was sent."""
lines = [f'<li><strong>To:</strong> {email_to}</li>']
if email_cc:
lines.append(f'<li><strong>CC:</strong> {email_cc}</li>')
if extra_lines:
for line in extra_lines:
lines.append(f'<li>{line}</li>')
body = Markup(
'<div class="alert alert-info" role="alert">'
f'<strong>{label}</strong>'
f'<ul class="mb-0 mt-1">{"".join(lines)}</ul>'
'</div>'
)
self.message_post(body=body, message_type='notification', subtype_xmlid='mail.mt_note')
def _send_submission_email(self):
"""Send email when application is submitted with PDF and XML attachments."""
self.ensure_one()
if not self._is_email_notifications_enabled():
return False
recipients = self._get_email_recipients(include_client=True, include_authorizer=True, include_sales_rep=True)
to_emails = recipients.get('to', [])
cc_emails = recipients.get('cc', []) + recipients.get('office_cc', [])
if not to_emails and not cc_emails:
return False
attachments = []
attachment_names = []
att_id = self._get_and_prepare_field_attachment('x_fc_final_submitted_application', 'ADP Application')
if att_id:
attachments.append(att_id)
attachment_names.append('Final ADP Application (PDF)')
att_id = self._get_and_prepare_field_attachment('x_fc_xml_file', 'ADP XML Data')
if att_id:
attachments.append(att_id)
attachment_names.append('XML Data File')
client_name = recipients.get('client', self.partner_id).name or 'Client'
sales_rep_name = (recipients.get('sales_rep') or self.env.user).name
submission_date = self.x_fc_claim_submission_date.strftime('%B %d, %Y') if self.x_fc_claim_submission_date else 'Today'
body_html = self._email_build(
title='Application Submitted',
summary=f'The ADP application for <strong>{client_name}</strong> has been submitted on {submission_date}.',
email_type='info',
sections=[('Case Details', self._build_case_detail_rows())],
note='<strong>What happens next:</strong> The Assistive Devices Program will review the application. '
'This typically takes 2-4 weeks. We will notify you as soon as we receive a decision.',
attachments_note=', '.join(attachment_names) if attachment_names else None,
button_url=f'{self.get_base_url()}/web#id={self.id}&model=sale.order&view_type=form',
sender_name=sales_rep_name,
)
email_to = ', '.join(to_emails) if to_emails else ', '.join(cc_emails[:1])
email_cc = ', '.join(cc_emails) if to_emails else ', '.join(cc_emails[1:])
try:
mail = self.env['mail.mail'].sudo().create({
'subject': f'Application Submitted - {client_name} - {self.name}',
'body_html': body_html,
'email_to': email_to, 'email_cc': email_cc,
'model': 'sale.order', 'res_id': self.id,
'attachment_ids': [(6, 0, attachments)] if attachments else False,
})
mail.send()
self._email_chatter_log('Application Submitted email sent', email_to, email_cc,
[f'Attachments: {", ".join(attachment_names)}'] if attachment_names else None)
_logger.info(f"Submission email sent for {self.name}")
return True
except Exception as e:
_logger.error(f"Failed to send submission email for {self.name}: {e}")
return False
@api.model
def _cron_send_application_reminders(self):
"""Cron job: Find assessments completed X days ago without application and send reminders."""
from datetime import timedelta
if not self._is_email_notifications_enabled():
_logger.info("Email notifications disabled, skipping application reminders")
return
# Get reminder days from settings (default 4)
ICP = self.env['ir.config_parameter'].sudo()
reminder_days = int(ICP.get_param('fusion_claims.application_reminder_days', '4'))
# Calculate target date (X days ago)
target_date = fields.Date.today() - timedelta(days=reminder_days)
# Find orders where:
# - Assessment completed on target date (x_fc_assessment_end_date = target_date)
# - Status is still 'waiting_for_application' (no application received yet)
# - Not already reminded (we'll track this with x_fc_application_reminder_sent)
orders = self.search([
('x_fc_assessment_end_date', '=', target_date),
('x_fc_adp_application_status', 'in', ['waiting_for_application', 'assessment_completed']),
('x_fc_application_reminder_sent', '=', False),
])
_logger.info(f"Application reminder cron: Found {len(orders)} orders to remind (assessed on {target_date})")
for order in orders:
try:
order._send_application_reminder_email()
except Exception as e:
_logger.error(f"Failed to send application reminder for {order.name}: {e}")
def _send_application_reminder_email(self):
"""Send reminder to therapist to submit ADP application."""
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:
return False
client_name = self.partner_id.name or 'the client'
assessment_date = self.x_fc_assessment_end_date.strftime('%B %d, %Y') if self.x_fc_assessment_end_date else 'recently'
sales_rep_name = self.user_id.name if self.user_id else 'The Sales Team'
body_html = self._email_build(
title='Application Reminder',
summary=f'The assessment for <strong>{client_name}</strong> was completed on {assessment_date}. '
f'We have not yet received the ADP application documents.',
email_type='attention',
sections=[('Case Details', self._build_case_detail_rows())],
note='<strong>Action needed:</strong> Please submit the completed ADP application '
'(including pages 11-12 signed by the client) so we can proceed with the claim submission.',
note_color='#d69e2e',
button_url=f'{self.get_base_url()}/web#id={self.id}&model=sale.order&view_type=form',
sender_name=sales_rep_name,
)
recipients = self._get_email_recipients(include_client=False, include_authorizer=True, include_sales_rep=True)
all_cc = recipients.get('cc', []) + recipients.get('office_cc', [])
email_cc = ', '.join(all_cc) if all_cc else ''
try:
mail = self.env['mail.mail'].sudo().create({
'subject': f'Application Reminder - {client_name} - {self.name}',
'body_html': body_html,
'email_to': authorizer.email, 'email_cc': email_cc,
'model': 'sale.order', 'res_id': self.id,
})
mail.send()
self.with_context(skip_all_validations=True).write({'x_fc_application_reminder_sent': True})
self._email_chatter_log('Application Reminder sent', authorizer.email, email_cc)
return True
except Exception as e:
_logger.error(f"Failed to send application reminder for {self.name}: {e}")
return False
@api.model
def _cron_send_application_reminders_2(self):
"""Cron job: Send second reminder X days after first reminder was sent."""
from datetime import timedelta
if not self._is_email_notifications_enabled():
_logger.info("Email notifications disabled, skipping second application reminders")
return
# Get reminder days from settings
ICP = self.env['ir.config_parameter'].sudo()
first_reminder_days = int(ICP.get_param('fusion_claims.application_reminder_days', '4'))
second_reminder_days = int(ICP.get_param('fusion_claims.application_reminder_2_days', '4'))
# Calculate target date: assessment_end_date + first_reminder_days + second_reminder_days
total_days = first_reminder_days + second_reminder_days
target_date = fields.Date.today() - timedelta(days=total_days)
# Find orders where:
# - Assessment completed on target date
# - First reminder was sent
# - Second reminder not yet sent
# - Status still waiting for application
orders = self.search([
('x_fc_assessment_end_date', '=', target_date),
('x_fc_adp_application_status', 'in', ['waiting_for_application', 'assessment_completed']),
('x_fc_application_reminder_sent', '=', True),
('x_fc_application_reminder_2_sent', '=', False),
])
_logger.info(f"Second application reminder cron: Found {len(orders)} orders to remind (assessed on {target_date})")
for order in orders:
try:
order._send_application_reminder_2_email()
except Exception as e:
_logger.error(f"Failed to send second application reminder for {order.name}: {e}")
def _send_application_reminder_2_email(self):
"""Send second/follow-up reminder to therapist to submit ADP application."""
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:
return False
client_name = self.partner_id.name or 'the client'
assessment_date = self.x_fc_assessment_end_date.strftime('%B %d, %Y') if self.x_fc_assessment_end_date else 'recently'
days_since = (fields.Date.today() - self.x_fc_assessment_end_date).days if self.x_fc_assessment_end_date else 'several'
sales_rep_name = self.user_id.name if self.user_id else 'The Sales Team'
body_html = self._email_build(
title='Follow-up: Application Needed',
summary=f'It has been <strong>{days_since} days</strong> since the assessment for '
f'<strong>{client_name}</strong> was completed on {assessment_date}. '
f'We have not yet received the ADP application.',
email_type='attention',
sections=[('Case Details', self._build_case_detail_rows())],
note='<strong>Assessment validity:</strong> ADP assessments are valid for 90 days. '
'To avoid delays or the need for a new assessment, please submit the application '
'as soon as possible.',
note_color='#d69e2e',
button_url=f'{self.get_base_url()}/web#id={self.id}&model=sale.order&view_type=form',
sender_name=sales_rep_name,
)
all_cc = []
if self.user_id and self.user_id.email:
all_cc.append(self.user_id.email)
all_cc.extend(self._get_office_cc_emails())
email_cc = ', '.join(all_cc) if all_cc else ''
try:
mail = self.env['mail.mail'].sudo().create({
'subject': f'Follow-up: Application Needed - {client_name} - {self.name}',
'body_html': body_html,
'email_to': authorizer.email, 'email_cc': email_cc,
'model': 'sale.order', 'res_id': self.id,
})
mail.send()
self.with_context(skip_all_validations=True).write({'x_fc_application_reminder_2_sent': True})
self._email_chatter_log('Follow-up Reminder sent', authorizer.email, email_cc,
[f'Days since assessment: {days_since}'])
return True
except Exception as e:
_logger.error(f"Failed to send second reminder for {self.name}: {e}")
return False
def _send_billed_summary_email(self):
"""Send summary email when order is billed to ADP."""
self.ensure_one()
authorizer = self.x_fc_authorizer_id
sales_rep = self.user_id
email_list = []
if authorizer and authorizer.email:
email_list.append(authorizer.email)
if sales_rep and sales_rep.email:
email_list.append(sales_rep.email)
if not email_list:
return False
client_name = self.partner_id.name or 'Client'
body_html = self._email_build(
title='Billing Complete',
summary=f'The ADP claim for <strong>{client_name}</strong> has been successfully billed.',
email_type='success',
sections=[
('Case Details', self._build_case_detail_rows(include_amounts=True)),
],
note='This case has been billed. Thank you for your collaboration.',
note_color='#38a169',
button_url=f'{self.get_base_url()}/web#id={self.id}&model=sale.order&view_type=form',
sender_name=(sales_rep.name if sales_rep else None),
)
email_to = ', '.join(email_list)
try:
mail = self.env['mail.mail'].sudo().create({
'subject': f'Billing Complete - {client_name} - {self.name}',
'body_html': body_html, 'email_to': email_to,
'model': 'sale.order', 'res_id': self.id,
})
mail.send()
self._email_chatter_log('Billing Complete email sent', email_to)
return True
except Exception as e:
_logger.error(f"Failed to send billed email for {self.name}: {e}")
return False
def _build_approved_items_html(self, for_pdf=False):
"""Build an HTML table of approved order line items.
Columns: S/N, ADP Code, Device Type, Product Name, Qty,
ADP Portion, Client Portion, Deduction.
"""
self.ensure_one()
lines = self.order_line.filtered(
lambda l: l.product_id and l.display_type not in ('line_section', 'line_note')
)
if not lines:
return ''
font = "font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,Arial,sans-serif;"
if for_pdf:
font = "font-family:Arial,Helvetica,sans-serif;"
hdr_style = (
f'style="background:#2d3748;color:#fff;padding:8px 10px;'
f'font-size:11px;font-weight:600;text-align:left;'
f'border-bottom:2px solid #4a5568;{font}"'
)
cell_style = (
'style="padding:7px 10px;font-size:12px;'
'border-bottom:1px solid rgba(128,128,128,0.15);"'
)
alt_row = 'style="background:rgba(128,128,128,0.06);"'
amt_style = (
'style="padding:7px 10px;font-size:12px;'
'border-bottom:1px solid rgba(128,128,128,0.15);text-align:right;"'
)
hdr_r = hdr_style.replace('text-align:left', 'text-align:right')
has_deduction = any(
l.x_fc_deduction_type and l.x_fc_deduction_type != 'none'
for l in lines
)
html = (
'<div style="margin:20px 0;">'
f'<h3 style="font-size:15px;font-weight:700;'
f'margin:0 0 10px 0;{font}">Approved Items</h3>'
'<table style="width:100%;border-collapse:collapse;border:1px solid rgba(128,128,128,0.25);">'
'<thead><tr>'
f'<th {hdr_style}>S/N</th>'
f'<th {hdr_style}>ADP Code</th>'
f'<th {hdr_style}>Device Type</th>'
f'<th {hdr_style}>Product</th>'
f'<th {hdr_r}>Qty</th>'
f'<th {hdr_r}>ADP Portion</th>'
f'<th {hdr_r}>Client Portion</th>'
)
if has_deduction:
html += f'<th {hdr_r}>Deduction</th>'
html += '</tr></thead><tbody>'
total_adp = 0.0
total_client = 0.0
for idx, line in enumerate(lines, 1):
row_attr = alt_row if idx % 2 == 0 else ''
adp_code = line._get_adp_code_for_report()
device_type = line._get_adp_device_type()
product_name = line.product_id.name or '-'
if len(product_name) > 40 and not for_pdf:
product_name = product_name[:37] + '...'
qty = int(line.product_uom_qty) if line.product_uom_qty == int(line.product_uom_qty) else line.product_uom_qty
adp_portion = line.x_fc_adp_portion or 0.0
client_portion = line.x_fc_client_portion or 0.0
total_adp += adp_portion
total_client += client_portion
deduction_str = '-'
if line.x_fc_deduction_type == 'pct' and line.x_fc_deduction_value:
deduction_str = f'{line.x_fc_deduction_value:.0f}%'
elif line.x_fc_deduction_type == 'amt' and line.x_fc_deduction_value:
deduction_str = f'${line.x_fc_deduction_value:,.2f}'
html += f'<tr {row_attr}>'
html += f'<td {cell_style}>{idx}</td>'
html += f'<td {cell_style}>{adp_code}</td>'
html += f'<td {cell_style}>{device_type}</td>'
html += f'<td {cell_style}>{product_name}</td>'
html += f'<td {amt_style}>{qty}</td>'
html += f'<td {amt_style}>${adp_portion:,.2f}</td>'
html += f'<td {amt_style}>${client_portion:,.2f}</td>'
if has_deduction:
html += f'<td {amt_style}>{deduction_str}</td>'
html += '</tr>'
# Totals row
colspan = 5
total_style = (
'style="padding:8px 10px;font-size:12px;font-weight:700;'
'border-top:2px solid rgba(128,128,128,0.3);text-align:right;"'
)
total_label_style = (
'style="padding:8px 10px;font-size:12px;font-weight:700;'
'border-top:2px solid rgba(128,128,128,0.3);text-align:right;"'
)
html += '<tr style="background:rgba(128,128,128,0.08);">'
html += f'<td colspan="{colspan}" {total_label_style}>Total</td>'
html += f'<td {total_style}>${total_adp:,.2f}</td>'
html += f'<td {total_style}>${total_client:,.2f}</td>'
if has_deduction:
html += f'<td {total_style}></td>'
html += '</tr>'
html += '</tbody></table></div>'
return html
def _generate_approved_items_pdf(self):
"""Generate the Approved Items PDF using the QWeb report and return an ir.attachment id."""
self.ensure_one()
import base64
first, last = self._get_client_name_parts()
try:
report = self.env.ref('fusion_claims.action_report_approved_items')
pdf_content, _ = report._render_qweb_pdf(report.id, [self.id])
except Exception as e:
_logger.error("Failed to generate approved items PDF for %s: %s", self.name, e)
return None
filename = f'{first}_{last}_Approved_Items.pdf'
att = self.env['ir.attachment'].sudo().create({
'name': filename,
'type': 'binary',
'datas': base64.b64encode(pdf_content) if isinstance(pdf_content, bytes) else pdf_content,
'res_model': 'sale.order',
'res_id': self.id,
'mimetype': 'application/pdf',
})
return att.id
def _send_approval_email(self):
"""Send notification when ADP application is approved, with approved items report."""
self.ensure_one()
if not self._is_email_notifications_enabled():
return False
recipients = self._get_email_recipients(include_client=True, include_authorizer=True, include_sales_rep=True)
to_emails = recipients.get('to', [])
cc_emails = recipients.get('cc', []) + recipients.get('office_cc', [])
if not to_emails:
return False
client_name = (recipients.get('client') or self.partner_id).name or 'Client'
sales_rep_name = (recipients.get('sales_rep') or self.env.user).name
is_deduction = self.x_fc_adp_application_status == 'approved_deduction'
status_label = 'Approved with Deduction' if is_deduction else 'Approved'
note_text = (
'<strong>Next steps:</strong> Our team will be in touch shortly to schedule '
'the delivery of your equipment.'
)
if is_deduction:
note_text = (
'<strong>Note:</strong> This application was approved with a deduction. '
'The final amounts may differ from the original request. Our team will '
'contact you with the details and next steps for delivery.'
)
items_html = self._build_approved_items_html()
body_html = self._email_build(
title='Application Approved',
summary=f'The ADP application for <strong>{client_name}</strong> has been '
f'<strong>{status_label.lower()}</strong>.',
email_type='success',
sections=[('Case Details', self._build_case_detail_rows(include_amounts=True))],
extra_html=items_html,
note=note_text,
note_color='#38a169',
attachments_note='Approved Items Report (PDF)' if items_html else None,
button_url=f'{self.get_base_url()}/web#id={self.id}&model=sale.order&view_type=form',
sender_name=sales_rep_name,
)
attachment_ids = []
try:
att_id = self._generate_approved_items_pdf()
if att_id:
attachment_ids.append(att_id)
except Exception as e:
_logger.warning("Could not generate approved items PDF for %s: %s", self.name, e)
email_to = ', '.join(to_emails)
email_cc = ', '.join(cc_emails) if cc_emails else ''
try:
mail_vals = {
'subject': f'Application {status_label} - {client_name} - {self.name}',
'body_html': body_html,
'email_to': email_to, 'email_cc': email_cc,
'model': 'sale.order', 'res_id': self.id,
}
if attachment_ids:
mail_vals['attachment_ids'] = [(6, 0, attachment_ids)]
self.env['mail.mail'].sudo().create(mail_vals).send()
self._email_chatter_log(
f'Application {status_label} email sent', email_to, email_cc,
['Attached: Approved Items Report'] if attachment_ids else None,
)
return True
except Exception as e:
_logger.error(f"Failed to send approval email for {self.name}: {e}")
return False
def _send_denial_email(self):
"""Send notification when ADP application is denied (funding denied)."""
self.ensure_one()
if not self._is_email_notifications_enabled():
return False
recipients = self._get_email_recipients(include_client=True, include_authorizer=True, include_sales_rep=True)
to_emails = recipients.get('to', [])
cc_emails = recipients.get('cc', []) + recipients.get('office_cc', [])
if not to_emails:
return False
client_name = (recipients.get('client') or self.partner_id).name or 'Client'
sales_rep_name = (recipients.get('sales_rep') or self.env.user).name
body_html = self._email_build(
title='Application Update',
summary=f'The ADP application for <strong>{client_name}</strong> was not approved at this time.',
email_type='urgent',
sections=[('Case Details', self._build_case_detail_rows())],
note='<strong>Your options:</strong> You may request a detailed explanation, '
'submit an appeal if you believe the decision was made in error, or explore '
'alternative funding options. Our team is here to help.',
note_color='#c53030',
button_url=f'{self.get_base_url()}/web#id={self.id}&model=sale.order&view_type=form',
sender_name=sales_rep_name,
)
email_to = ', '.join(to_emails)
email_cc = ', '.join(cc_emails) if cc_emails else ''
try:
self.env['mail.mail'].sudo().create({
'subject': f'Application Update - {client_name} - {self.name}',
'body_html': body_html,
'email_to': email_to, 'email_cc': email_cc,
'model': 'sale.order', 'res_id': self.id,
}).send()
self._email_chatter_log('Application Denied email sent', email_to, email_cc)
return True
except Exception as e:
_logger.error(f"Failed to send denial email for {self.name}: {e}")
return False
def _send_rejection_email(self):
"""Send notification when ADP rejects the submission (data errors, not funding denial)."""
self.ensure_one()
if not self._is_email_notifications_enabled():
return False
recipients = self._get_email_recipients(include_client=False, include_authorizer=True, include_sales_rep=True)
to_emails = recipients.get('to', [])
cc_emails = recipients.get('cc', []) + recipients.get('office_cc', [])
if not to_emails:
return False
client_name = self.partner_id.name or 'the client'
sales_rep_name = (recipients.get('sales_rep') or self.env.user).name
rejection_reason_labels = {
'name_correction': 'Name Correction Needed',
'healthcard_correction': 'Health Card Correction Needed',
'duplicate_claim': 'Duplicate Claim Exists',
'xml_format_error': 'XML Format/Validation Error',
'missing_info': 'Missing Required Information',
'other': 'Other',
}
rejection_reason = self.x_fc_rejection_reason or 'other'
rejection_label = rejection_reason_labels.get(rejection_reason, rejection_reason)
rejection_details = self.x_fc_rejection_reason_other or ''
note_text = f'<strong>Reason:</strong> {rejection_label}'
# PLACEHOLDER_REJECTION_START -- marker removed
if rejection_details:
note_text += f'<br/>{rejection_details}'
body_html = self._email_build(
title='Action Required: Submission Returned',
summary=f'The ADP submission for <strong>{client_name}</strong> has been returned and needs correction.',
email_type='urgent',
sections=[('Case Details', self._build_case_detail_rows())],
note=note_text,
note_color='#c53030',
button_url=f'{self.get_base_url()}/web#id={self.id}&model=sale.order&view_type=form',
sender_name=sales_rep_name,
)
email_to = ', '.join(to_emails)
email_cc = ', '.join(cc_emails) if cc_emails else ''
try:
self.env['mail.mail'].sudo().create({
'subject': f'Action Required: Submission Returned - {client_name} - {self.name}',
'body_html': body_html,
'email_to': email_to, 'email_cc': email_cc,
'model': 'sale.order', 'res_id': self.id,
}).send()
self._email_chatter_log('Submission Returned email sent', email_to, email_cc,
[f'Reason: {rejection_label}'])
return True
except Exception as e:
_logger.error(f"Failed to send rejection email for {self.name}: {e}")
return False
def _send_correction_needed_email(self, reason=None):
"""Send notification when ADP application needs correction."""
self.ensure_one()
if not self._is_email_notifications_enabled():
return False
recipients = self._get_email_recipients(include_client=True, include_authorizer=True, include_sales_rep=True)
to_emails = recipients.get('to', [])
cc_emails = recipients.get('cc', []) + recipients.get('office_cc', [])
if not to_emails:
return False
client_name = self.partner_id.name or 'the client'
sales_rep_name = (recipients.get('sales_rep') or self.env.user).name
note_text = '<strong>Action needed:</strong> Please review the application, make the necessary corrections, and resubmit.'
if reason:
note_text = f'<strong>Reason for correction:</strong> {reason}<br/><br/>{note_text}'
body_html = self._email_build(
title='Correction Needed',
summary=f'The ADP application for <strong>{client_name}</strong> requires corrections before resubmission.',
email_type='attention',
sections=[('Case Details', self._build_case_detail_rows())],
note=note_text,
note_color='#d69e2e',
button_url=f'{self.get_base_url()}/web#id={self.id}&model=sale.order&view_type=form',
sender_name=sales_rep_name,
)
email_to = ', '.join(to_emails)
email_cc = ', '.join(cc_emails) if cc_emails else ''
try:
self.env['mail.mail'].sudo().create({
'subject': f'Correction Needed - {client_name} - {self.name}',
'body_html': body_html,
'email_to': email_to, 'email_cc': email_cc,
'model': 'sale.order', 'res_id': self.id,
}).send()
self._email_chatter_log('Correction Needed email sent', email_to, email_cc)
return True
except Exception as e:
_logger.error(f"Failed to send correction email for {self.name}: {e}")
return False
def _send_case_closed_email(self):
"""Send summary email when case is closed."""
self.ensure_one()
if not self._is_email_notifications_enabled():
return False
recipients = self._get_email_recipients(include_client=True, include_authorizer=True, include_sales_rep=True)
to_emails = recipients.get('to', [])
cc_emails = recipients.get('cc', []) + recipients.get('office_cc', [])
if not to_emails and not cc_emails:
return False
client_name = (recipients.get('client') or self.partner_id).name or 'Client'
sales_rep_name = (recipients.get('sales_rep') or self.env.user).name
body_html = self._email_build(
title='Case Closed',
summary=f'The ADP case for <strong>{client_name}</strong> has been completed and closed.',
email_type='success',
sections=[('Case Summary', self._build_case_detail_rows(include_amounts=True))],
note='This case is now closed. All equipment has been delivered and billing is complete. '
'Thank you for your collaboration throughout the process.',
note_color='#38a169',
button_url=f'{self.get_base_url()}/web#id={self.id}&model=sale.order&view_type=form',
sender_name=sales_rep_name,
)
email_to = ', '.join(to_emails) if to_emails else ', '.join(cc_emails[:1])
email_cc = ', '.join(cc_emails) if to_emails else ', '.join(cc_emails[1:])
try:
self.env['mail.mail'].sudo().create({
'subject': f'Case Closed - {client_name} - {self.name}',
'body_html': body_html,
'email_to': email_to, 'email_cc': email_cc,
'model': 'sale.order', 'res_id': self.id,
}).send()
self._email_chatter_log('Case Closed email sent', email_to, email_cc)
return True
except Exception as e:
_logger.error(f"Failed to send case closed email for {self.name}: {e}")
return False
def _send_withdrawal_email(self, reason=None, intent=None):
"""Send notification when application is withdrawn.
Args:
reason: Free-text reason for withdrawal.
intent: 'cancel' or 'resubmit' — determines email wording.
"""
self.ensure_one()
if not self._is_email_notifications_enabled():
return False
recipients = self._get_email_recipients(include_client=True, include_authorizer=True, include_sales_rep=True)
to_emails = recipients.get('to', [])
cc_emails = recipients.get('cc', []) + recipients.get('office_cc', [])
if not to_emails and not cc_emails:
return False
client_name = (recipients.get('client') or self.partner_id).name or 'Client'
sales_rep_name = (recipients.get('sales_rep') or self.env.user).name
if intent == 'cancel':
note_text = ('This application has been permanently withdrawn and cancelled. '
'The sale order and all related invoices have been cancelled.')
title = 'Application Withdrawn & Cancelled'
subject_suffix = 'Withdrawn & Cancelled'
note_color = '#dc3545'
elif intent == 'resubmit':
note_text = ('This application has been withdrawn for correction and will be resubmitted. '
'The application has been returned to Ready for Submission status.')
title = 'Application Withdrawn for Correction'
subject_suffix = 'Withdrawn for Correction'
note_color = '#d69e2e'
else:
note_text = 'This application has been withdrawn from the Assistive Devices Program.'
title = 'Application Withdrawn'
subject_suffix = 'Withdrawn'
note_color = '#d69e2e'
if reason:
note_text += f'<br/><strong>Reason:</strong> {reason}'
body_html = self._email_build(
title=title,
summary=f'The ADP application for <strong>{client_name}</strong> has been withdrawn.',
email_type='attention',
sections=[('Case Details', self._build_case_detail_rows())],
note=note_text,
note_color=note_color,
button_url=f'{self.get_base_url()}/web#id={self.id}&model=sale.order&view_type=form',
sender_name=sales_rep_name,
)
email_to = ', '.join(to_emails) if to_emails else ', '.join(cc_emails[:1])
email_cc = ', '.join(cc_emails) if to_emails else ', '.join(cc_emails[1:])
try:
self.env['mail.mail'].sudo().create({
'subject': f'Application {subject_suffix} - {client_name} - {self.name}',
'body_html': body_html,
'email_to': email_to, 'email_cc': email_cc,
'model': 'sale.order', 'res_id': self.id,
}).send()
self._email_chatter_log(f'{title} email sent', email_to, email_cc)
return True
except Exception as e:
_logger.error(f"Failed to send withdrawal email for {self.name}: {e}")
return False
def _send_ready_for_delivery_email(self, technicians=None, scheduled_datetime=None, notes=None):
"""Send notification when application is marked Ready for Delivery."""
self.ensure_one()
if not self._is_email_notifications_enabled():
return False
recipients = self._get_email_recipients(include_client=True, include_authorizer=True, include_sales_rep=True)
to_emails = recipients.get('to', [])
cc_emails = recipients.get('cc', []) + recipients.get('office_cc', [])
# Add technician emails to CC
if technicians:
for tech in technicians:
if hasattr(tech, 'email') and tech.email:
cc_emails.append(tech.email)
if not to_emails and not cc_emails:
return False
client_name = (recipients.get('client') or self.partner_id).name or 'Client'
sales_rep_name = (recipients.get('sales_rep') or self.env.user).name
# Build extra rows for delivery details
detail_rows = self._build_case_detail_rows()
if self.partner_shipping_id:
addr = self.partner_shipping_id.contact_address or ''
detail_rows.append(('Delivery Address', addr.replace('\n', ', ')))
if technicians:
tech_names = ', '.join(t.name for t in technicians if hasattr(t, 'name'))
if tech_names:
detail_rows.append(('Technician(s)', tech_names))
if scheduled_datetime:
detail_rows.append(('Scheduled', str(scheduled_datetime)))
note_text = '<strong>Next steps:</strong> Our delivery team will contact you to confirm the delivery schedule.'
if self.x_fc_early_delivery:
note_text = ('<strong>Note:</strong> This is an early delivery (before final ADP approval). '
'Our team will contact you to schedule.')
if notes:
note_text += f'<br/><strong>Notes:</strong> {notes}'
body_html = self._email_build(
title='Ready for Delivery',
summary=f'The equipment for <strong>{client_name}</strong> is ready for delivery.',
email_type='success',
sections=[('Delivery Details', detail_rows)],
note=note_text,
note_color='#38a169',
button_url=f'{self.get_base_url()}/web#id={self.id}&model=sale.order&view_type=form',
sender_name=sales_rep_name,
)
email_to = ', '.join(to_emails) if to_emails else ', '.join(cc_emails[:1])
email_cc = ', '.join(cc_emails) if to_emails else ', '.join(cc_emails[1:])
try:
self.env['mail.mail'].sudo().create({
'subject': f'Ready for Delivery - {client_name} - {self.name}',
'body_html': body_html,
'email_to': email_to, 'email_cc': email_cc,
'model': 'sale.order', 'res_id': self.id,
}).send()
_logger.info(f"Ready for delivery email sent for {self.name}")
return True
except Exception as e:
_logger.error(f"Failed to send delivery email for {self.name}: {e}")
return False
def _send_on_hold_email(self, reason=None):
"""Send notification when application is put on hold."""
self.ensure_one()
if not self._is_email_notifications_enabled():
return False
recipients = self._get_email_recipients(include_client=True, include_authorizer=True, include_sales_rep=True)
to_emails = recipients.get('to', [])
cc_emails = recipients.get('cc', []) + recipients.get('office_cc', [])
if not to_emails and not cc_emails:
return False
client_name = (recipients.get('client') or self.partner_id).name or 'Client'
sales_rep_name = (recipients.get('sales_rep') or self.env.user).name
note_text = 'This application has been placed on hold. We will resume processing as soon as possible.'
if reason:
note_text += f'<br/><strong>Reason:</strong> {reason}'
body_html = self._email_build(
title='Application On Hold',
summary=f'The ADP application for <strong>{client_name}</strong> has been placed on hold.',
email_type='attention',
sections=[('Case Details', self._build_case_detail_rows())],
note=note_text,
note_color='#d69e2e',
button_url=f'{self.get_base_url()}/web#id={self.id}&model=sale.order&view_type=form',
sender_name=sales_rep_name,
)
email_to = ', '.join(to_emails) if to_emails else ', '.join(cc_emails[:1])
email_cc = ', '.join(cc_emails) if to_emails else ', '.join(cc_emails[1:])
try:
self.env['mail.mail'].sudo().create({
'subject': f'Application On Hold - {client_name} - {self.name}',
'body_html': body_html,
'email_to': email_to, 'email_cc': email_cc,
'model': 'sale.order', 'res_id': self.id,
}).send()
self._email_chatter_log('Application On Hold email sent', email_to, email_cc)
return True
except Exception as e:
_logger.error(f"Failed to send on-hold email for {self.name}: {e}")
return False
# ==========================================================================
# OVERRIDE WRITE
# ==========================================================================
def web_save(self, vals, specification):
"""TEMP DEBUG: Intercept web_save to diagnose 'Missing required fields' on old orders."""
_logger.warning(
"DEBUG web_save() on %s: vals keys = %s",
[r.name for r in self], list(vals.keys())
)
try:
return super().web_save(vals, specification)
except Exception as e:
_logger.error("DEBUG web_save() FAILED on %s: %s", [r.name for r in self], e)
raise
def write(self, vals):
"""Override write to handle ADP status changes, date auto-population, and document tracking."""
from datetime import date as date_class
# =================================================================
# VALIDATION BYPASS (for internal operations like crons, email tracking)
# =================================================================
if self.env.context.get('skip_all_validations'):
return super().write(vals)
# =================================================================
# CASE LOCK CHECK
# =================================================================
# If unlocking (setting x_fc_case_locked to False), allow it
# Otherwise, if any order is locked, block changes to ADP fields
if 'x_fc_case_locked' not in vals or vals.get('x_fc_case_locked') is True:
# Fields that are always allowed to be modified even when locked
always_allowed = {
'x_fc_case_locked', # Allow toggling the lock itself
'message_main_attachment_id',
'message_follower_ids',
'activity_ids',
}
# Check if any locked orders would have ADP fields modified
adf_fields_being_changed = [k for k in vals.keys() if k.startswith('x_fc_') and k not in always_allowed]
if adf_fields_being_changed:
for order in self:
if order.x_fc_case_locked:
raise UserError(
f"Cannot modify order {order.name}.\n\n"
"This case is locked. Please unlock it first by toggling off the "
"'Case Locked' switch in the ADP Order Trail tab."
)
# =================================================================
# SALE TYPE LOCK CHECK
# =================================================================
# Sale type is locked after application is submitted
# Can be overridden by: context flag, sale type override setting,
# OR the document lock override (setting + group)
if 'x_fc_sale_type' in vals and not self.env.context.get('skip_sale_type_lock'):
locked_statuses = [
'submitted', 'accepted', 'rejected', 'resubmitted',
'approved', 'approved_deduction', 'ready_bill', 'billed', 'case_closed',
]
ICP = self.env['ir.config_parameter'].sudo()
allow_sale_type = ICP.get_param('fusion_claims.allow_sale_type_override', 'False').lower() in ('true', '1', 'yes')
allow_doc_lock = ICP.get_param('fusion_claims.allow_document_lock_override', 'False').lower() in ('true', '1', 'yes')
has_override_group = self.env.user.has_group('fusion_claims.group_document_lock_override')
if not allow_sale_type and not (allow_doc_lock and has_override_group):
for order in self:
if order.x_fc_adp_application_status in locked_statuses:
raise UserError(
f"Cannot modify Sale Type on order {order.name}.\n\n"
f"Sale Type is locked after the application has been submitted to ADP.\n"
f"Current status: {order.x_fc_adp_application_status}\n\n"
f"To modify, enable 'Allow Document Lock Override' in Settings\n"
f"and ensure your user is in the 'Document Lock Override' group."
)
# =================================================================
# DOCUMENT LOCKING BASED ON STATUS PROGRESSION
# =================================================================
# Documents become locked at specific stages to prevent modification
# Lock rules:
# - Original Application & Signed Pages 11/12 → Lock when submitted or later
# - Final Application & XML File → Lock when approved or later
# - Approval Letter & Screenshots → Lock when billed (but tracked on change)
# - Proof of Delivery → Lock when billed or later
# Define document lock rules: field -> list of statuses where field is locked
statuses_after_submitted = [
'submitted', 'accepted', 'rejected', 'resubmitted', 'needs_correction',
'approved', 'approved_deduction', 'ready_bill', 'billed', 'case_closed',
]
statuses_after_approved = [
'approved', 'approved_deduction', 'ready_bill', 'billed', 'case_closed',
]
statuses_after_billed = ['billed', 'case_closed']
document_lock_rules = {
# Application documents - lock after submitted
'x_fc_original_application': statuses_after_submitted,
'x_fc_original_application_filename': statuses_after_submitted,
'x_fc_signed_pages_11_12': statuses_after_submitted,
'x_fc_signed_pages_filename': statuses_after_submitted,
# Submission documents - lock after approved
'x_fc_final_submitted_application': statuses_after_approved,
'x_fc_final_application_filename': statuses_after_approved,
'x_fc_xml_file': statuses_after_approved,
'x_fc_xml_filename': statuses_after_approved,
# Approval documents - lock after billed
'x_fc_approval_letter': statuses_after_billed,
'x_fc_approval_letter_filename': statuses_after_billed,
# POD - lock after billed
'x_fc_proof_of_delivery': statuses_after_billed,
'x_fc_proof_of_delivery_filename': statuses_after_billed,
}
# Check if any locked documents are being modified
# Skip check if:
# - context has skip_document_lock_validation (for programmatic override)
# - BOTH: the "Allow Document Lock Override" setting is ON
# AND the user is in the "Document Lock Override" group
can_override = False
if not self.env.context.get('skip_document_lock_validation'):
ICP_lock = self.env['ir.config_parameter'].sudo()
override_enabled = ICP_lock.get_param(
'fusion_claims.allow_document_lock_override', 'False'
).lower() in ('true', '1', 'yes')
if override_enabled:
can_override = self.env.user.has_group('fusion_claims.group_document_lock_override')
else:
can_override = True
if not can_override:
for order in self:
current_status = order.x_fc_adp_application_status or ''
for field_name, locked_statuses in document_lock_rules.items():
if field_name in vals:
if current_status in locked_statuses:
old_value = getattr(order, field_name, None)
new_value = vals.get(field_name)
if old_value == new_value:
continue
if current_status in statuses_after_billed:
lock_stage = "billed"
elif current_status in statuses_after_approved:
lock_stage = "approved"
else:
lock_stage = "submitted"
field_label_map = {
'x_fc_original_application': 'Original ADP Application',
'x_fc_signed_pages_11_12': 'Signed Pages 11 & 12',
'x_fc_final_submitted_application': 'Final Submitted Application',
'x_fc_xml_file': 'XML File',
'x_fc_approval_letter': 'Approval Letter',
'x_fc_proof_of_delivery': 'Proof of Delivery',
}
field_label = field_label_map.get(field_name, field_name)
raise UserError(
f"Cannot modify '{field_label}' on order {order.name}.\n\n"
f"This document is locked because the application status is '{current_status}'.\n"
f"Documents are locked once the application reaches the '{lock_stage}' stage.\n\n"
f"To modify this document:\n"
f"1. The 'Allow Document Lock Override' setting must be enabled (Fusion Claims > Settings)\n"
f"2. Your user must be in the 'Document Lock Override' group"
)
# =================================================================
# DOCUMENT AUDIT TRAIL - Track all document changes
# =================================================================
document_fields = [
'x_fc_original_application',
'x_fc_signed_pages_11_12',
'x_fc_final_submitted_application',
'x_fc_xml_file',
'x_fc_proof_of_delivery',
'x_fc_approval_letter',
]
if self.env.context.get('skip_document_chatter'):
doc_changes = {}
else:
doc_changes = {f: vals.get(f) for f in document_fields if f in vals and vals.get(f)}
# Preserve old documents in chatter BEFORE they get replaced or deleted
# This ensures document history is maintained for audit purposes
document_labels = {
'x_fc_original_application': 'Original ADP Application',
'x_fc_signed_pages_11_12': 'Page 11 & 12 (Signed)',
'x_fc_final_submitted_application': 'Final Application',
'x_fc_xml_file': 'XML File',
'x_fc_proof_of_delivery': 'Proof of Delivery',
'x_fc_approval_letter': 'Approval Letter',
}
user_name = self.env.user.name
change_timestamp = fields.Datetime.now().strftime('%Y-%m-%d %H:%M:%S')
# Fields already handled by the needs_correction flow below (avoid duplicate posts)
correction_handled = set()
if vals.get('x_fc_adp_application_status') == 'needs_correction':
correction_handled = {'x_fc_final_submitted_application', 'x_fc_xml_file'}
for order in self:
for field_name in document_fields:
if field_name in vals and field_name not in correction_handled and not self.env.context.get('skip_document_chatter'):
old_data = getattr(order, field_name, None)
new_data = vals.get(field_name)
label = document_labels.get(field_name, field_name)
if old_data and new_data:
order._post_document_to_chatter(
field_name,
f"{label} (replaced)",
preserve_copy=True,
)
elif old_data and not new_data:
order._post_document_to_chatter(
field_name,
f"{label} (DELETED)",
preserve_copy=True,
)
# Post deletion notice
deletion_msg = Markup(
'<div style="border-left: 3px solid #dc3545; padding-left: 12px; margin: 8px 0;">'
'<p style="margin: 0 0 8px 0; font-weight: 600; color: #dc3545;">'
'<i class="fa fa-trash"></i> Document Deleted</p>'
'<table style="font-size: 13px; color: #555;">'
'<tr><td style="padding: 2px 8px 2px 0; font-weight: 500;">Document:</td>'
f'<td>{label}</td></tr>'
'<tr><td style="padding: 2px 8px 2px 0; font-weight: 500;">Deleted By:</td>'
f'<td>{user_name}</td></tr>'
'<tr><td style="padding: 2px 8px 2px 0; font-weight: 500;">Time:</td>'
f'<td>{change_timestamp}</td></tr>'
'</table>'
'<p style="margin: 8px 0 0 0; font-size: 12px; color: #666;">'
'The deleted document has been preserved in the message above.</p>'
'</div>'
)
order.message_post(
body=deletion_msg,
message_type='notification',
subtype_xmlid='mail.mt_note',
)
# Track status changes for auto-actions
new_app_status = vals.get('x_fc_adp_application_status')
new_mod_status = vals.get('x_fc_mod_status')
# Handle document correction flow - clear document fields and submission date when needs_correction
if new_app_status == 'needs_correction':
for order in self:
# Post existing final application to chatter before clearing
if order.x_fc_final_submitted_application:
order._post_document_to_chatter(
'x_fc_final_submitted_application',
'Final Application (before correction)',
preserve_copy=True,
)
if order.x_fc_xml_file:
order._post_document_to_chatter(
'x_fc_xml_file',
'XML File (before correction)',
preserve_copy=True,
)
# Clear the document fields AND submission date
# Use _correction_cleared to prevent the audit trail from posting duplicates
vals['x_fc_final_submitted_application'] = False
vals['x_fc_final_application_filename'] = False
vals['x_fc_xml_file'] = False
vals['x_fc_xml_filename'] = False
vals['x_fc_claim_submission_date'] = False
# Post correction notice
for order in self:
order.message_post(
body=Markup(
'<div class="alert alert-warning" role="alert">'
'<strong><i class="fa fa-exclamation-triangle"/> Correction Needed</strong><br/>'
'Document fields and submission date have been cleared. Please upload the corrected application and resubmit.'
'</div>'
),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
# Auto-populate date fields based on status changes
today = date_class.today()
if new_app_status == 'assessment_scheduled' and 'x_fc_assessment_start_date' not in vals:
vals['x_fc_assessment_start_date'] = today
elif new_app_status == 'assessment_completed' and 'x_fc_assessment_end_date' not in vals:
vals['x_fc_assessment_end_date'] = today
# Auto-transition to 'waiting_for_application'
vals['x_fc_adp_application_status'] = 'waiting_for_application'
new_app_status = 'waiting_for_application'
elif new_app_status in ('submitted', 'resubmitted') and 'x_fc_claim_submission_date' not in vals:
vals['x_fc_claim_submission_date'] = today
elif new_app_status == 'accepted' and 'x_fc_claim_acceptance_date' not in vals:
vals['x_fc_claim_acceptance_date'] = today
elif new_app_status in ('approved', 'approved_deduction') and 'x_fc_claim_approval_date' not in vals:
vals['x_fc_claim_approval_date'] = today
elif new_app_status == 'billed' and 'x_fc_billing_date' not in vals:
vals['x_fc_billing_date'] = today
# =================================================================
# REQUIRED FIELD VALIDATION based on status
# =================================================================
# Note: UserError is imported at top of file
# Helper to get field value (check vals first, then existing record)
def get_val(order, field):
if field in vals:
return vals[field]
return getattr(order, field, None)
# Authorizer validation based on sale type and authorizer_required field
# Always required for: adp, adp_odsp, wsib, march_of_dimes, muscular_dystrophy
# Optional for: odsp, direct_private, insurance, other, rental (depends on x_fc_authorizer_required)
#
# IMPORTANT: Only validate when changing relevant fields, not on every write.
# This prevents blocking unrelated saves when authorizer is missing.
authorizer_related_fields = {
'x_fc_sale_type', 'x_fc_authorizer_id', 'x_fc_authorizer_required',
'x_fc_adp_application_status', # Also validate when changing ADP status
}
should_validate_authorizer = bool(authorizer_related_fields & set(vals.keys()))
if should_validate_authorizer:
always_auth_types = ('adp', 'adp_odsp', 'wsib', 'march_of_dimes', 'muscular_dystrophy')
optional_auth_types = ('odsp', 'direct_private', 'insurance', 'other')
for order in self:
sale_type = get_val(order, 'x_fc_sale_type')
if sale_type == 'rental':
continue
auth_id = get_val(order, 'x_fc_authorizer_id')
auth_required = get_val(order, 'x_fc_authorizer_required')
if sale_type in always_auth_types:
if not auth_id:
raise UserError("Authorizer is required for this sale type.")
elif sale_type in optional_auth_types and auth_required == 'yes':
if not auth_id:
raise UserError("Authorizer is required. You selected 'Yes' for Authorizer Required.")
# Helper to check if previous funding date is required based on reason
def requires_previous_funding(reason_val):
"""Return True if the reason requires previous funding date."""
exempt_reasons = ['first_access', 'mod_non_adp']
return reason_val and reason_val not in exempt_reasons
# =================================================================
# STATUS TRANSITION VALIDATIONS
# =================================================================
# All status changes to "controlled" statuses must go through dedicated
# buttons/wizards. Direct dropdown/statusbar changes are blocked.
# Use context flag 'skip_status_validation' to bypass when calling from wizards.
if not self.env.context.get('skip_status_validation') and new_app_status:
# Statuses that can ONLY be set via buttons/wizards
# This ensures proper workflow tracking and validation
controlled_statuses = {
# Early workflow stages
'assessment_scheduled': 'Schedule Assessment',
'assessment_completed': 'Complete Assessment',
'waiting_for_application': 'Complete Assessment',
'application_received': 'Application Received',
'ready_submission': 'Ready for Submission',
# Submission and approval stages
'submitted': 'Submit Application',
'accepted': 'Mark as Accepted', # New: ADP accepted submission
'rejected': 'Mark as Rejected', # New: ADP rejected submission
'resubmitted': 'Submit Application',
'approved': 'Mark as Approved',
'approved_deduction': 'Mark as Approved',
# Delivery stage
'ready_delivery': 'Ready for Delivery',
# Billing stages
'ready_bill': 'Ready to Bill',
'billed': 'Mark as Billed',
'case_closed': 'Close Case',
# Special statuses (require reason wizard)
'on_hold': 'Put On Hold',
'withdrawn': 'Withdraw',
'denied': 'Denied',
'cancelled': 'Cancel',
'needs_correction': 'Needs Correction',
}
if new_app_status in controlled_statuses:
button_name = controlled_statuses[new_app_status]
raise UserError(
f"To change status to this value, please use the '{button_name}' button.\n\n"
f"Direct status changes are not allowed for workflow integrity."
)
# =================================================================
# RESUMING FROM ON_HOLD: Check assessment validity (3 months)
# =================================================================
for order in self:
if order.x_fc_adp_application_status == 'on_hold' and new_app_status and new_app_status != 'on_hold':
# Check if assessment is expired (more than 3 months old)
if order.x_fc_assessment_expired:
days_expired = (today - order.x_fc_assessment_end_date).days - 90 if order.x_fc_assessment_end_date else 0
order.message_post(
body=Markup(
'<div class="alert alert-warning" role="alert">'
'<strong><i class="fa fa-exclamation-triangle"/> Assessment Expired</strong><br/>'
f'The assessment was completed on {order.x_fc_assessment_end_date} and is now <strong>{days_expired} days past the 3-month validity period</strong>.<br/>'
'A new assessment must be completed by the Occupational Therapist before proceeding.'
'</div>'
),
message_type='notification',
subtype_xmlid='mail.mt_note',
)
raise UserError(
f"Cannot resume from 'On Hold' - Assessment has expired!\n\n"
f"The assessment was completed on {order.x_fc_assessment_end_date} and is now "
f"{days_expired} days past the 3-month validity period.\n\n"
f"A new assessment must be completed before proceeding."
)
# assessment_scheduled: No special requirements (Assessment Start Date auto-populated)
if new_app_status == 'assessment_completed':
for order in self:
missing = []
if not get_val(order, 'x_fc_assessment_start_date'):
missing.append('Assessment Start Date')
if not get_val(order, 'x_fc_assessment_end_date'):
missing.append('Assessment End Date')
if missing:
raise UserError(
f"Cannot change status to 'Assessment Completed'.\n\n"
f"Required fields missing:\n" + "\n".join(missing)
)
elif new_app_status == 'application_received':
for order in self:
missing = []
# Only Assessment Start Date required at this stage
if not get_val(order, 'x_fc_assessment_start_date'):
missing.append('Assessment Start Date')
if missing:
raise UserError(
f"Cannot change status to 'Application Received'.\n\n"
f"Required fields missing:\n" + "\n".join(missing)
)
elif new_app_status == 'ready_submission':
for order in self:
missing = []
# Assessment dates
if not get_val(order, 'x_fc_assessment_start_date'):
missing.append('Assessment Start Date')
if not get_val(order, 'x_fc_assessment_end_date'):
missing.append('Assessment End Date')
# Reason for application
if not get_val(order, 'x_fc_reason_for_application'):
missing.append('Reason for Application')
# Client references and authorization date
if not get_val(order, 'x_fc_client_ref_1'):
missing.append('Client Reference 1')
if not get_val(order, 'x_fc_client_ref_2'):
missing.append('Client Reference 2')
if not get_val(order, 'x_fc_claim_authorization_date'):
missing.append('Claim Authorization Date')
# Previous funding date if required by reason
reason_val = get_val(order, 'x_fc_reason_for_application')
if requires_previous_funding(reason_val):
if not get_val(order, 'x_fc_previous_funding_date'):
missing.append('Previous Funding Date')
# Documents
if not order.x_fc_original_application and not vals.get('x_fc_original_application'):
missing.append('Original ADP Application')
if not order.x_fc_signed_pages_11_12 and not vals.get('x_fc_signed_pages_11_12'):
missing.append('Page 11 & 12 (Signed)')
if missing:
raise UserError(
f"Cannot change status to 'Ready for Submission'.\n\n"
f"Required fields/documents missing:\n" + "\n".join(missing)
)
elif new_app_status in ('submitted', 'resubmitted'):
for order in self:
missing = []
# Documents
if not order.x_fc_final_submitted_application and not vals.get('x_fc_final_submitted_application'):
missing.append('Final Submitted Application')
if not order.x_fc_xml_file and not vals.get('x_fc_xml_file'):
missing.append('XML File')
# Fields
if not get_val(order, 'x_fc_claim_submission_date'):
missing.append('Claim Submission Date')
if missing:
raise UserError(
f"Cannot change status to 'Application Submitted'.\n\n"
f"Required fields/documents missing:\n" + "\n".join(missing)
)
elif new_app_status in ('approved', 'approved_deduction'):
for order in self:
missing = []
if not get_val(order, 'x_fc_claim_number'):
missing.append('Claim Number')
if not get_val(order, 'x_fc_claim_approval_date'):
missing.append('Claim Approval Date')
if missing:
raise UserError(
f"Cannot change status to 'Application Approved'.\n\n"
f"Required fields missing:\n" + "\n".join(missing)
)
elif new_app_status == 'ready_bill':
for order in self:
missing = []
if not get_val(order, 'x_fc_adp_delivery_date'):
missing.append('ADP Delivery Date')
if not order.x_fc_proof_of_delivery and not vals.get('x_fc_proof_of_delivery'):
missing.append('Proof of Delivery')
if missing:
raise UserError(
f"Cannot change status to 'Ready to Bill'.\n\n"
f"Required fields/documents missing:\n" + "\n".join(missing)
)
elif new_app_status == 'billed':
for order in self:
missing = []
if not get_val(order, 'x_fc_billing_date'):
missing.append('Billing Date')
if missing:
raise UserError(
f"Cannot change status to 'Billed to ADP'.\n\n"
f"Required fields missing:\n" + "\n".join(missing)
)
elif new_app_status == 'case_closed':
for order in self:
missing = []
if not get_val(order, 'x_fc_billing_date'):
missing.append('Billing Date')
if missing:
raise UserError(
f"Cannot change status to 'Case Closed'.\n\n"
f"Required fields missing:\n" + "\n".join(missing)
)
# ==================================================================
# MARCH OF DIMES STATUS TRANSITION VALIDATIONS
# ==================================================================
if new_mod_status:
for order in self:
if not order._is_mod_sale() and order.x_fc_sale_type != 'march_of_dimes':
continue
if new_mod_status == 'contract_received':
missing = []
if not get_val(order, 'x_fc_case_reference') and not vals.get('x_fc_case_reference'):
missing.append('HVMP Reference Number')
if missing:
raise UserError(
"Cannot change status to 'PCA Received'.\n\n"
"Required:\n" + "\n".join(f"- {m}" for m in missing)
)
elif new_mod_status == 'pod_submitted':
if not order.x_fc_mod_proof_of_delivery and not vals.get('x_fc_mod_proof_of_delivery'):
raise UserError(
"Cannot change status to 'POD Sent'.\n\n"
"Please upload the Proof of Delivery document first."
)
result = super().write(vals)
# Skip additional processing if we're in a sync operation (prevent infinite loops)
if self.env.context.get('skip_sync'):
return result
# Post document uploads to chatter
if doc_changes:
for order in self:
for field_name, data in doc_changes.items():
if data:
order._post_document_to_chatter(field_name)
# Auto-overlay POD signature onto SA Mobility approval form
if 'x_fc_pod_signature' in vals and vals['x_fc_pod_signature'] and not self.env.context.get('skip_pod_signature_hook'):
for order in self:
try:
order._apply_pod_signature_to_approval_form()
except Exception as e:
_logger.error("Failed to overlay POD signature for %s: %s", order.name, e)
# Auto-advance SA Mobility from ready_delivery to delivered when POD is signed
if (order.x_fc_odsp_division == 'sa_mobility'
and order.x_fc_sa_status == 'ready_delivery'):
order._odsp_advance_status(
'delivered',
"Delivery completed. POD signature collected and SA form auto-signed.",
)
# Handle status-based actions (emails and reminders)
# skip_status_emails: suppress all status-triggered emails
# (used when reverting status e.g. cancelled delivery task)
if self.env.context.get('skip_status_emails'):
new_app_status = None # Disable all email triggers below
if new_app_status in ('submitted', 'resubmitted'):
for order in self:
order._send_submission_email()
# Create submission history record
submission_type = 'resubmission' if new_app_status == 'resubmitted' else 'initial'
self.env['fusion.submission.history'].create_from_submission(order, submission_type=submission_type)
elif new_app_status in ('approved', 'approved_deduction'):
for order in self:
order._send_approval_email()
order._schedule_delivery_reminder()
elif new_app_status == 'accepted':
# 'Accepted' is internal tracking - no external email notification
# But we record it in submission history
for order in self:
# Update the most recent pending submission to 'accepted'
pending_submission = self.env['fusion.submission.history'].search([
('sale_order_id', '=', order.id),
('result', '=', 'pending'),
], order='submission_date desc', limit=1)
if pending_submission:
pending_submission.update_result('accepted')
elif new_app_status == 'rejected':
# 'Rejected' - ADP rejected the submission, needs correction
for order in self:
order._send_rejection_email()
# Update the most recent pending submission to 'rejected'
pending_submission = self.env['fusion.submission.history'].search([
('sale_order_id', '=', order.id),
('result', '=', 'pending'),
], order='submission_date desc', limit=1)
if pending_submission:
pending_submission.update_result(
'rejected',
rejection_reason=order.x_fc_rejection_reason,
rejection_details=order.x_fc_rejection_reason_other,
)
elif new_app_status == 'denied':
for order in self:
order._send_denial_email()
elif new_app_status == 'needs_correction':
# Email sent from the wizard with the reason text, not here.
# If called programmatically without the wizard, send without reason.
if not self.env.context.get('skip_correction_email'):
for order in self:
order._send_correction_needed_email()
elif new_app_status == 'case_closed':
if not self.env.context.get('skip_status_emails'):
for order in self:
order._send_case_closed_email()
# ==================================================================
# MARCH OF DIMES STATUS-TRIGGERED EMAILS & SMS
# ==================================================================
if new_mod_status and not self.env.context.get('skip_status_emails'):
for order in self:
if not order._is_mod_sale():
continue
try:
if new_mod_status == 'assessment_scheduled':
order._send_mod_assessment_scheduled_email()
order._send_mod_sms('assessment_scheduled')
elif new_mod_status == 'assessment_completed':
order._send_mod_assessment_completed_email()
elif new_mod_status == 'quote_submitted':
order._send_mod_quote_submitted_email()
if not order.x_fc_case_submitted:
order.with_context(skip_all_validations=True).write({
'x_fc_case_submitted': fields.Date.today()})
elif new_mod_status == 'funding_approved':
order._send_mod_funding_approved_email()
order._send_mod_sms('funding_approved')
if not order.x_fc_case_approved:
order.with_context(skip_all_validations=True).write({
'x_fc_case_approved': fields.Date.today()})
elif new_mod_status == 'funding_denied':
order._send_mod_funding_denied_email()
elif new_mod_status == 'contract_received':
order._send_mod_contract_received_email()
elif new_mod_status == 'in_production':
order._send_mod_sms('initial_payment_received')
elif new_mod_status == 'project_complete':
order._send_mod_project_complete_email()
order._send_mod_sms('project_complete')
elif new_mod_status == 'pod_submitted':
order._send_mod_pod_submitted_email()
elif new_mod_status == 'case_closed':
order._send_mod_case_closed_email()
except Exception as e:
_logger.error(f"MOD status email/sms failed for {order.name} ({new_mod_status}): {e}")
# Check if we need to recalculate
ICP = self.env['ir.config_parameter'].sudo()
sale_type_field = ICP.get_param('fusion_claims.field_sale_type', 'x_fc_sale_type')
client_type_field = ICP.get_param('fusion_claims.field_so_client_type', 'x_fc_client_type')
trigger_fields = {
'x_fc_sale_type', 'x_fc_client_type',
sale_type_field, client_type_field,
}
if trigger_fields & set(vals.keys()):
for order in self:
# Trigger recomputation of x_fc_is_adp_sale
order._compute_is_adp_sale()
# Trigger recalculation of ADP portions
for line in order.order_line:
line._compute_adp_portions()
# Sync FC fields to invoices when relevant fields change
sync_fields = {
'x_fc_claim_number', 'x_fc_client_ref_1', 'x_fc_client_ref_2',
'x_fc_adp_delivery_date', 'x_fc_authorizer_id', 'x_fc_client_type', 'x_fc_primary_serial',
'x_fc_service_start_date', 'x_fc_service_end_date',
}
if sync_fields & set(vals.keys()):
for order in self:
order._sync_fields_to_invoices()
return result
# ==========================================================================
# FIELD SYNCHRONIZATION (SO -> Invoice)
# ==========================================================================
def _get_field_mappings(self):
"""Get field mappings from system parameters.
Returns dict with SO and Invoice field mappings configured in Settings.
"""
ICP = self.env['ir.config_parameter'].sudo()
return {
# Sale Order field mappings
'so_claim_number': ICP.get_param('fusion_claims.field_so_claim_number', 'x_fc_claim_number'),
'so_client_ref_1': ICP.get_param('fusion_claims.field_so_client_ref_1', 'x_fc_client_ref_1'),
'so_client_ref_2': ICP.get_param('fusion_claims.field_so_client_ref_2', 'x_fc_client_ref_2'),
'so_delivery_date': ICP.get_param('fusion_claims.field_so_delivery_date', 'x_fc_adp_delivery_date'),
'so_authorizer': ICP.get_param('fusion_claims.field_so_authorizer', 'x_fc_authorizer_id'),
'so_client_type': ICP.get_param('fusion_claims.field_so_client_type', 'x_fc_client_type'),
'so_service_start': ICP.get_param('fusion_claims.field_so_service_start', 'x_fc_service_start_date'),
'so_service_end': ICP.get_param('fusion_claims.field_so_service_end', 'x_fc_service_end_date'),
'sol_serial': ICP.get_param('fusion_claims.field_sol_serial', 'x_fc_serial_number'),
# Invoice field mappings
'inv_claim_number': ICP.get_param('fusion_claims.field_inv_claim_number', 'x_fc_claim_number'),
'inv_client_ref_1': ICP.get_param('fusion_claims.field_inv_client_ref_1', 'x_fc_client_ref_1'),
'inv_client_ref_2': ICP.get_param('fusion_claims.field_inv_client_ref_2', 'x_fc_client_ref_2'),
'inv_delivery_date': ICP.get_param('fusion_claims.field_inv_delivery_date', 'x_fc_adp_delivery_date'),
'inv_authorizer': ICP.get_param('fusion_claims.field_inv_authorizer', 'x_fc_authorizer_id'),
'inv_client_type': ICP.get_param('fusion_claims.field_inv_client_type', 'x_fc_client_type'),
'inv_service_start': ICP.get_param('fusion_claims.field_inv_service_start', 'x_fc_service_start_date'),
'inv_service_end': ICP.get_param('fusion_claims.field_inv_service_end', 'x_fc_service_end_date'),
'aml_serial': ICP.get_param('fusion_claims.field_aml_serial', 'x_fc_serial_number'),
}
def _get_field_value(self, record, field_name):
"""Safely get a field value from a record."""
if not field_name or field_name not in record._fields:
return None
value = getattr(record, field_name, None)
# Handle Many2one fields - return id for writing
if hasattr(value, 'id'):
return value.id if value else False
return value
def _sync_fields_to_invoices(self):
"""Sync ADP fields from Sale Order to linked Invoices.
Uses dynamic field mappings from Settings.
"""
mappings = self._get_field_mappings()
for order in self:
invoices = order.invoice_ids.filtered(lambda inv: inv.state != 'cancel')
if not invoices:
_logger.debug(f"No invoices found for order {order.name}")
continue
for invoice in invoices:
vals = {}
# Get source values from SO
claim_number = order.x_fc_claim_number
client_ref_1 = order.x_fc_client_ref_1
client_ref_2 = order.x_fc_client_ref_2
delivery_date = order.x_fc_adp_delivery_date
authorizer_id = False
if order.x_fc_authorizer_id:
authorizer_id = order.x_fc_authorizer_id.id
client_type = order.x_fc_client_type
service_start = order.x_fc_service_start_date
service_end = order.x_fc_service_end_date
# Write to Invoice FC fields only (no Studio field writes)
if claim_number:
if 'x_fc_claim_number' in invoice._fields:
vals['x_fc_claim_number'] = claim_number
if client_ref_1:
if 'x_fc_client_ref_1' in invoice._fields:
vals['x_fc_client_ref_1'] = client_ref_1
if client_ref_2:
if 'x_fc_client_ref_2' in invoice._fields:
vals['x_fc_client_ref_2'] = client_ref_2
if delivery_date:
if 'x_fc_adp_delivery_date' in invoice._fields:
vals['x_fc_adp_delivery_date'] = delivery_date
if authorizer_id:
if 'x_fc_authorizer_id' in invoice._fields:
vals['x_fc_authorizer_id'] = authorizer_id
if client_type:
if 'x_fc_client_type' in invoice._fields:
vals['x_fc_client_type'] = client_type
if service_start:
if 'x_fc_service_start_date' in invoice._fields:
vals['x_fc_service_start_date'] = service_start
if service_end:
if 'x_fc_service_end_date' in invoice._fields:
vals['x_fc_service_end_date'] = service_end
# Serial Number - sync from SO header to invoice header
primary_serial = order.x_fc_primary_serial
if primary_serial:
vals['x_fc_primary_serial'] = primary_serial
if vals:
try:
invoice.sudo().with_context(skip_sync=True).write(vals)
_logger.debug(f"Synced fields to invoice {invoice.name}: {list(vals.keys())}")
except Exception as e:
_logger.warning(f"Failed to sync to invoice {invoice.name}: {e}")
else:
_logger.debug(f"No fields to sync to invoice {invoice.name}")
# Sync serial numbers from SO lines to corresponding invoice lines
order._sync_serial_numbers_to_invoices()
def _sync_serial_numbers_to_invoices(self):
"""Sync serial numbers from SO lines to linked invoice lines.
Uses dynamic field mappings from Settings.
"""
if self.env.context.get('skip_sync'):
_logger.info("_sync_serial_numbers_to_invoices: skipped (skip_sync context)")
return
mappings = self._get_field_mappings()
sol_serial_field = mappings.get('sol_serial', 'x_fc_serial_number')
aml_serial_field = mappings.get('aml_serial', 'x_fc_serial_number')
_logger.debug(f"_sync_serial_numbers_to_invoices: Starting. sol_field={sol_serial_field}, aml_field={aml_serial_field}")
for order in self:
_logger.debug(f" Processing SO {order.name}")
for so_line in order.order_line:
if so_line.display_type in ('line_section', 'line_note'):
continue
# Get serial from THIS SO line ONLY - no fallback to header
# Each line syncs its OWN serial to corresponding invoice lines
serial_value = None
if sol_serial_field in so_line._fields:
serial_value = getattr(so_line, sol_serial_field, None)
# Skip if this line has no serial - don't use header fallback
if not serial_value:
continue
_logger.debug(f" SO line {so_line.id}: serial={serial_value}")
# Find linked invoice lines
invoice_lines = self.env['account.move.line'].sudo().search([
('sale_line_ids', 'in', so_line.id),
('move_id.state', '!=', 'cancel')
])
for inv_line in invoice_lines:
vals = {}
# Write to x_fc_serial_number on invoice line
if 'x_fc_serial_number' in inv_line._fields:
vals['x_fc_serial_number'] = serial_value
if vals:
try:
inv_line.sudo().with_context(skip_sync=True).write(vals)
_logger.debug(f" Synced serial '{serial_value}' to invoice line {inv_line.id} (inv {inv_line.move_id.name})")
except Exception as e:
_logger.error(f" Failed to sync serial to invoice line {inv_line.id}: {e}")
# ==========================================================================
# EMAIL SEND OVERRIDE (Use ADP templates for ADP sales)
# ==========================================================================
def action_quotation_send(self):
"""Override to use ADP email template for ADP sales.
When sending a quotation for an ADP sale, automatically selects the
ADP landscape template instead of the default template.
"""
self.ensure_one()
# Check if this is an ADP sale
if self._is_adp_sale():
# Get the ADP template
template_xmlid = 'fusion_claims.email_template_adp_quotation'
if self.state in ('sale', 'done'):
# Use sales order confirmation template for confirmed orders
template_xmlid = 'fusion_claims.email_template_adp_sales_order'
try:
template = self.env.ref(template_xmlid, raise_if_not_found=False)
if template:
# Open the mail compose wizard with the ADP template pre-selected
ctx = {
'default_model': 'sale.order',
'default_res_ids': self.ids,
'default_template_id': template.id,
'default_email_layout_xmlid': 'mail.mail_notification_layout',
'default_composition_mode': 'comment',
'mark_so_as_sent': True,
'force_email': True,
'model_description': self.with_context(lang=self.partner_id.lang).type_name,
}
return {
'type': 'ir.actions.act_window',
'res_model': 'mail.compose.message',
'view_mode': 'form',
'views': [(False, 'form')],
'target': 'new',
'context': ctx,
}
except Exception as e:
_logger.warning(f"Could not load ADP email template: {e}")
# Fall back to standard behavior for non-ADP sales
return super().action_quotation_send()
# ==========================================================================
# ADP ACTIVITY REMINDER METHODS
# ==========================================================================
def _schedule_or_renew_adp_activity(self, activity_type_xmlid, user_id, date_deadline, summary, note=False):
"""Schedule or renew an ADP-related activity.
If an activity of the same type for the same user already exists,
update its deadline instead of creating a duplicate.
Args:
activity_type_xmlid: XML ID of the activity type
user_id: ID of the user to assign the activity to
date_deadline: Deadline date for the activity
summary: Activity summary text
note: Optional note text
"""
self.ensure_one()
try:
activity_type = self.env.ref(activity_type_xmlid)
except ValueError:
_logger.warning(f"Activity type not found: {activity_type_xmlid}")
return
# Search for existing activity of this type for this user
existing = self.activity_ids.filtered(
lambda a: a.activity_type_id.id == activity_type.id
and a.user_id.id == user_id
)
if existing:
# Update existing activity
existing[0].write({
'date_deadline': date_deadline,
'summary': summary,
'note': note or existing[0].note,
})
_logger.info(f"Renewed ADP activity for {self.name}: {summary} -> {date_deadline}")
else:
# Create new activity
self.activity_schedule(
activity_type_xmlid,
date_deadline=date_deadline,
summary=summary,
note=note,
user_id=user_id
)
_logger.info(f"Scheduled new ADP activity for {self.name}: {summary} -> {date_deadline}")
def _complete_adp_activities(self, activity_type_xmlid):
"""Complete all activities of a specific type for this record.
Args:
activity_type_xmlid: XML ID of the activity type to complete
"""
self.ensure_one()
try:
activity_type = self.env.ref(activity_type_xmlid)
except ValueError:
return
activities = self.activity_ids.filtered(
lambda a: a.activity_type_id.id == activity_type.id
)
for activity in activities:
activity.action_feedback(feedback='Completed automatically')
_logger.info(f"Completed ADP activity for {self.name}: {activity.summary}")
def _schedule_delivery_reminder(self):
"""Schedule a delivery reminder for the salesperson.
Triggered when ADP application status changes to 'approved' or 'approved_deduction'.
Reminds the salesperson to deliver the order by Tuesday of the next posting week.
"""
self.ensure_one()
if not self._is_adp_sale():
return
# Get the salesperson
salesperson = self.user_id
if not salesperson:
_logger.warning(f"No salesperson assigned to {self.name}, cannot schedule delivery reminder")
return
# Calculate the next posting date and the Tuesday of that week
next_posting = self._get_next_posting_date()
reminder_date = self._get_posting_week_tuesday(next_posting)
# Don't schedule if reminder date is in the past
from datetime import date
if reminder_date < date.today():
# Schedule for the next posting cycle
next_posting = self._get_next_posting_date(next_posting)
reminder_date = self._get_posting_week_tuesday(next_posting)
summary = f"Deliver ADP order {self.name} for {next_posting.strftime('%b %d')} billing"
note = f"Complete delivery by Tuesday to meet the Wednesday 6 PM submission deadline for the {next_posting.strftime('%B %d, %Y')} ADP posting."
self._schedule_or_renew_adp_activity(
'fusion_claims.mail_activity_type_adp_delivery',
salesperson.id,
reminder_date,
summary,
note
)
def _cron_renew_delivery_reminders(self):
"""Cron job to renew overdue delivery reminders.
For sale orders with approved status that have overdue delivery activities,
reschedule them to the next posting week's Tuesday.
"""
from datetime import date
today = date.today()
# Find approved orders with overdue delivery activities
try:
activity_type = self.env.ref('fusion_claims.mail_activity_type_adp_delivery')
except ValueError:
_logger.warning("ADP Delivery activity type not found")
return
# Find orders that are approved but not yet billed (delivery still pending)
approved_orders = self.search([
('x_fc_is_adp_sale', '=', True),
('x_fc_adp_application_status', 'in', ['approved', 'approved_deduction']),
])
for order in approved_orders:
# Check if there's an overdue delivery activity
overdue_activities = order.activity_ids.filtered(
lambda a: a.activity_type_id.id == activity_type.id
and a.date_deadline < today
)
if overdue_activities:
# Reschedule to next posting week
order._schedule_delivery_reminder()
_logger.info(f"Renewed overdue delivery reminder for {order.name}")
def _cron_auto_close_billed_cases(self):
"""Cron job to automatically close cases 1 month after being billed.
Finds all sale orders with 'billed' status where the billing date
was more than 30 days ago, and automatically changes them to 'case_closed'.
"""
from datetime import date, timedelta
today = date.today()
cutoff_date = today - timedelta(days=30)
# Find orders that are billed and have billing date > 30 days ago
orders_to_close = self.search([
('x_fc_is_adp_sale', '=', True),
('x_fc_adp_application_status', '=', 'billed'),
('x_fc_billing_date', '<=', cutoff_date),
])
for order in orders_to_close:
try:
# Use context to skip status validation for automated process
order.with_context(skip_status_validation=True, skip_status_emails=True).write({
'x_fc_adp_application_status': 'case_closed',
})
# Post to chatter
days_since_billed = (today - order.x_fc_billing_date).days
order.message_post(
body=f'<p><strong><i class="fa fa-check-circle text-success"/> Case Automatically Closed</strong></p>'
f'<p>This case has been automatically closed after {days_since_billed} days since billing.</p>'
f'<p>Billing Date: {order.x_fc_billing_date}</p>',
message_type='notification',
subtype_xmlid='mail.mt_note',
)
_logger.info(f"Auto-closed case {order.name} after {days_since_billed} days since billing")
except Exception as e:
_logger.error(f"Failed to auto-close case {order.name}: {e}")
@api.model
def _cron_auto_close_odsp_paid_cases(self):
"""Auto-close ODSP/SA/OW cases 7 days after their final workflow step.
SA Mobility & Standard ODSP: close 7 days after payment_received.
Ontario Works: close 7 days after delivered (payment comes before delivery).
"""
from datetime import timedelta
cutoff = fields.Datetime.now() - timedelta(days=7)
orders = self.search([
('x_fc_is_odsp_sale', '=', True),
('write_date', '<=', cutoff),
'|', '|', '|',
('x_fc_sa_status', '=', 'payment_received'),
('x_fc_odsp_std_status', '=', 'payment_received'),
('x_fc_ow_status', '=', 'payment_received'),
('x_fc_ow_status', '=', 'delivered'),
])
closeable = {'payment_received', 'delivered'}
for order in orders:
status = order._get_odsp_status()
if status not in closeable:
continue
if order.x_fc_odsp_division == 'ontario_works' and status != 'delivered':
continue
if order.x_fc_odsp_division != 'ontario_works' and status != 'payment_received':
continue
try:
order.with_context(skip_status_emails=True)._odsp_advance_status(
'case_closed',
"Case automatically closed 7 days after %s." % status.replace('_', ' '),
)
_logger.info(f"Auto-closed ODSP case {order.name}")
except Exception as e:
_logger.error(f"Failed to auto-close ODSP case {order.name}: {e}")
@api.model
def _cron_send_acceptance_reminders(self):
"""Cron job: Send reminders for orders still in 'submitted' status next business day.
Per business rule: If 'Accepted by ADP' not marked within 1 business day after submission:
- First email to Office Notification Recipients
- Second email to Office + Sales Rep
"""
from datetime import timedelta
if not self._is_email_notifications_enabled():
_logger.info("Email notifications disabled, skipping acceptance reminders")
return
today = fields.Date.today()
# Find orders where:
# - Status is still 'submitted' (not accepted, rejected, or later)
# - Submission date was at least 1 business day ago
#
# For simplicity, we check if submission was 2+ days ago (covers weekends)
cutoff_date = today - timedelta(days=2)
orders = self.search([
('x_fc_is_adp_sale', '=', True),
('x_fc_adp_application_status', '=', 'submitted'),
('x_fc_claim_submission_date', '<=', cutoff_date),
('x_fc_acceptance_reminder_sent', '=', False),
])
if not orders:
_logger.info("Acceptance reminder cron: No orders require reminders")
return
_logger.info(f"Acceptance reminder cron: Found {len(orders)} orders to remind")
# Get office notification emails from company
company = self.env.company
office_partners = company.sudo().x_fc_office_notification_ids
office_emails = [p.email for p in office_partners if p.email]
if not office_emails:
_logger.warning("Acceptance reminder cron: No office notification recipients configured")
return
for order in orders:
try:
days_since_submission = (today - order.x_fc_claim_submission_date).days
client_name = order.partner_id.name or 'Client'
claim_number = order.x_fc_claim_number or 'N/A'
order_name = order.name
submission_date = order.x_fc_claim_submission_date.strftime('%B %d, %Y')
sales_rep = order.user_id
# Determine recipients
if days_since_submission > 3:
to_emails = office_emails.copy()
if sales_rep and sales_rep.email:
to_emails.append(sales_rep.email)
reminder_type = "SECOND"
else:
to_emails = office_emails.copy()
reminder_type = "FIRST"
# Build email using the mixin builder
level = 'Follow-up' if reminder_type == 'SECOND' else 'Pending'
subject = f'{level} Review: Acceptance Status - {order_name}'
body_html = order._email_build(
title='Acceptance Status Pending',
summary=f'The application for <strong>{client_name}</strong> was submitted on '
f'{submission_date} but has not been marked as accepted or rejected '
f'({days_since_submission} days pending).',
email_type='attention',
sections=[('Details', [
('Case', order_name),
('Client', client_name),
('Claim Number', claim_number),
('Submitted', submission_date),
('Days Pending', f'{days_since_submission} days'),
])],
note='<strong>Action needed:</strong> Please update the acceptance status in the system.',
note_color='#d69e2e',
button_url=f'{order.get_base_url()}/web#id={order.id}&model=sale.order&view_type=form',
button_text='Open Case',
)
self.env['mail.mail'].sudo().create({
'subject': subject,
'body_html': body_html,
'email_to': ', '.join(to_emails),
'model': 'sale.order', 'res_id': order.id,
}).send()
# Mark as sent so it won't resend on next cron run / restart
order.with_context(skip_all_validations=True).write({
'x_fc_acceptance_reminder_sent': True,
})
_logger.info(f"Sent {reminder_type.lower()} acceptance reminder for {order.name}")
except Exception as e:
_logger.error(f"Failed to send acceptance reminder for {order.name}: {e}")
# ======================================================================
# MARCH OF DIMES - WORKFLOW ACTION METHODS
# ======================================================================
def action_mod_schedule_assessment(self):
self.ensure_one()
self.write({
'x_fc_mod_status': 'assessment_scheduled',
'x_fc_mod_assessment_scheduled_date': fields.Date.today(),
})
def action_mod_complete_assessment(self):
self.ensure_one()
self.write({
'x_fc_mod_status': 'assessment_completed',
'x_fc_mod_assessment_completed_date': fields.Date.today(),
})
def action_mod_processing_drawing(self):
"""Open wizard to attach drawing + photos and send quotation to MOD.
If drawing/photos already exist, they are pre-loaded in the wizard.
On confirm: saves to order, sets status to quote_submitted, sends email."""
self.ensure_one()
# First set to processing_drawings
self.with_context(skip_status_emails=True).write({'x_fc_mod_status': 'processing_drawings'})
# Open the wizard in drawing mode
return {
'type': 'ir.actions.act_window',
'name': 'Attach Drawing and Send Quotation',
'res_model': 'fusion_claims.send.to.mod.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'active_id': self.id,
'active_model': 'sale.order',
'mod_wizard_mode': 'drawing',
},
}
def action_mod_awaiting_funding(self):
"""Open wizard to record Application Submission Date before moving to awaiting funding."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Application Submitted to March of Dimes',
'res_model': 'fusion_claims.mod.awaiting.funding.wizard',
'view_mode': 'form',
'target': 'new',
'context': {'active_id': self.id},
}
def action_mod_funding_approved(self):
"""Open wizard to record case worker and HVMP reference on approval."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Funding Approved',
'res_model': 'fusion_claims.mod.funding.approved.wizard',
'view_mode': 'form',
'target': 'new',
'context': {'active_id': self.id},
}
def action_mod_funding_denied(self):
self.ensure_one()
self.write({'x_fc_mod_status': 'funding_denied'})
def action_mod_contract_received(self):
"""Open wizard to upload PCA document and record receipt."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'PCA Received',
'res_model': 'fusion_claims.mod.pca.received.wizard',
'view_mode': 'form',
'target': 'new',
'context': {'active_id': self.id},
}
def action_mod_in_production(self):
self.ensure_one()
self.write({
'x_fc_mod_status': 'in_production',
'x_fc_mod_production_started_date': fields.Date.today(),
})
def action_mod_project_complete(self):
self.ensure_one()
self.write({
'x_fc_mod_status': 'project_complete',
'x_fc_mod_project_completed_date': fields.Date.today(),
})
def action_mod_pod_submitted(self):
"""Open wizard to attach completion photos + POD and send to case worker."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Submit Completion Photos and POD',
'res_model': 'fusion_claims.send.to.mod.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'active_id': self.id,
'active_model': 'sale.order',
'mod_wizard_mode': 'completion',
},
}
def action_mod_close_case(self):
self.ensure_one()
self.write({
'x_fc_mod_status': 'case_closed',
'x_fc_mod_case_closed_date': fields.Date.today(),
})
def action_mod_on_hold(self):
self.ensure_one()
self.write({'x_fc_mod_status': 'on_hold'})
def action_mod_resume(self):
"""Resume from on_hold - go back to in_production."""
self.ensure_one()
self.write({'x_fc_mod_status': 'in_production'})
def action_cancel(self):
"""Override: also set MOD status to cancelled when order is cancelled."""
res = super().action_cancel()
for order in self:
if order._is_mod_sale() and order.x_fc_mod_status not in ('cancelled', False):
order.with_context(skip_all_validations=True, skip_status_emails=True).write({
'x_fc_mod_status': 'cancelled',
})
return res
def _get_mod_partner(self):
"""Find or create the March of Dimes partner for invoicing."""
ICP = self.env['ir.config_parameter'].sudo()
mod_email = ICP.get_param('fusion_claims.mod_default_email', 'hvmp@marchofdimes.ca')
partner = self.env['res.partner'].sudo().search([('email', '=', mod_email)], limit=1)
if not partner:
partner = self.env['res.partner'].sudo().create({
'name': 'March of Dimes Canada (HVMP)',
'email': mod_email,
'is_company': True,
})
return partner
def _create_mod_invoice(self, partner_id, invoice_lines, portion_type='full', label=''):
"""Create a MOD invoice with given lines. Reusable for full/split."""
self.ensure_one()
from odoo.fields import Command
ICP = self.env['ir.config_parameter'].sudo()
vendor_code = ICP.get_param('fusion_claims.mod_vendor_code', '')
authorizer = self.x_fc_authorizer_id
case_worker = self.x_fc_case_worker
invoice = self.env['account.move'].sudo().create({
'move_type': 'out_invoice',
'partner_id': partner_id,
'partner_shipping_id': self.partner_id.id,
'x_fc_source_sale_order_id': self.id,
'x_fc_invoice_type': 'march_of_dimes',
'x_fc_adp_invoice_portion': portion_type,
'x_fc_authorizer_id': authorizer.id if authorizer else False,
'x_fc_claim_number': self.x_fc_case_reference or '',
'ref': self.x_fc_case_reference or self.name,
'invoice_origin': f'{self.name}{label}',
'invoice_line_ids': invoice_lines,
'narration': Markup(
f'<strong>HVMP Reference:</strong> {self.x_fc_case_reference or "N/A"}<br/>'
f'<strong>Client:</strong> {self.partner_id.name}<br/>'
f'<strong>Case Worker:</strong> {case_worker.name if case_worker else "N/A"}<br/>'
f'<strong>Sale Order:</strong> {self.name}<br/>'
f'<strong>Vendor Code:</strong> {vendor_code}'
),
})
return invoice
def action_send_to_mod(self):
"""Open the Send to March of Dimes wizard."""
self.ensure_one()
return {
'type': 'ir.actions.act_window',
'name': 'Send to March of Dimes',
'res_model': 'fusion_claims.send.to.mod.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'active_id': self.id,
'active_model': 'sale.order',
},
}
# --- MOD Document Preview Actions ---
def action_open_mod_drawing(self):
self.ensure_one()
return self._action_open_document('x_fc_mod_drawing', 'Drawing')
def action_open_mod_initial_photos(self):
self.ensure_one()
return self._action_open_image('x_fc_mod_initial_photos', 'Initial Photos')
def action_open_mod_pca(self):
self.ensure_one()
return self._action_open_document('x_fc_mod_pca_document', 'PCA Document')
def action_open_mod_pod(self):
self.ensure_one()
return self._action_open_document('x_fc_mod_proof_of_delivery', 'Proof of Delivery')
def action_open_mod_completion_photos(self):
self.ensure_one()
return self._action_open_image('x_fc_mod_completion_photos', 'Completion Photos')
def _action_open_image(self, field_name, label):
"""Open an image attachment in a new browser tab."""
self.ensure_one()
if not getattr(self, field_name):
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'No File',
'message': f'No {label} uploaded yet.',
'type': 'warning', 'sticky': False,
},
}
attachment = self._get_or_create_attachment(field_name, label)
if attachment:
return {
'type': 'ir.actions.act_url',
'url': f'/web/content/{attachment.id}',
'target': 'new',
}
return {'type': 'ir.actions.act_window_close'}
# ======================================================================
# MARCH OF DIMES - EMAIL METHODS
# ======================================================================
def _build_mod_case_detail_rows(self, include_amounts=False):
"""Build case detail rows for MOD email templates."""
self.ensure_one()
def fmt(d):
return d.strftime('%B %d, %Y') if d else None
status_label = dict(self._fields['x_fc_mod_status'].selection).get(
self.x_fc_mod_status, self.x_fc_mod_status or '')
rows = [
('Case', self.name),
('Client', self.partner_id.name or 'N/A'),
('HVMP Reference', self.x_fc_case_reference or None),
('Status', status_label or None),
('Funding Approved', fmt(self.x_fc_case_approved)),
('Est. Completion', fmt(self.x_fc_estimated_completion_date)),
]
if include_amounts:
approved = self.x_fc_mod_approved_amount or 0
rows.extend([
('Order Total', f'${self.amount_total:,.2f}'),
])
if approved:
rows.append(('MOD Approved', f'${approved:,.2f}'))
if approved < self.amount_total:
rows.append(('Client Portion', f'${self.amount_total - approved:,.2f}'))
return [(l, v) for l, v in rows if v is not None]
def _mod_email_build(self, **kwargs):
"""Wrapper around _email_build that overrides the footer for MOD emails."""
# Build the email normally
html = self._email_build(**kwargs)
# Replace the footer text
html = html.replace(
'This is an automated notification from the ADP Claims Management System.',
'This is an automated notification from the Accessibility Case Management System.',
)
return html
def _get_mod_email_recipients(self, include_client=True, include_authorizer=True,
include_mod_contact=False, include_sales_rep=True):
"""Get email recipients for MOD notifications."""
self.ensure_one()
to_emails = []
cc_emails = []
client = self.partner_id
authorizer = self.x_fc_authorizer_id
sales_rep = self.user_id
if include_client and client and client.email:
to_emails.append(client.email)
if include_authorizer and authorizer and authorizer.email:
if to_emails:
cc_emails.append(authorizer.email)
else:
to_emails.append(authorizer.email)
if include_mod_contact and self.x_fc_mod_contact_email:
cc_emails.append(self.x_fc_mod_contact_email)
if include_sales_rep and sales_rep and sales_rep.email:
cc_emails.append(sales_rep.email)
office_cc = self._get_office_cc_emails()
return {
'to': to_emails,
'cc': cc_emails,
'office_cc': office_cc,
'authorizer': authorizer,
'sales_rep': sales_rep,
'client': client,
}
def _send_mod_email(self, subject_prefix, title, summary, email_type='info',
include_client=True, include_authorizer=True,
include_mod_contact=False, include_sales_rep=True,
sections=None, note=None, note_color=None,
attachments=None, attachment_names=None):
"""Generic MOD email sender to avoid repeating boilerplate."""
self.ensure_one()
if not self._is_email_notifications_enabled():
return False
recipients = self._get_mod_email_recipients(
include_client=include_client, include_authorizer=include_authorizer,
include_mod_contact=include_mod_contact, include_sales_rep=include_sales_rep)
to_emails = recipients.get('to', [])
cc_emails = recipients.get('cc', []) + recipients.get('office_cc', [])
if not to_emails and not cc_emails:
return False
client_name = (recipients.get('client') or self.partner_id).name or 'Client'
sender_name = (recipients.get('sales_rep') or self.env.user).name
body_html = self._mod_email_build(
title=title,
summary=summary,
email_type=email_type,
sections=sections or [('Case Details', self._build_mod_case_detail_rows())],
note=note,
note_color=note_color,
button_url=f'{self.get_base_url()}/web#id={self.id}&model=sale.order&view_type=form',
sender_name=sender_name,
attachments_note=', '.join(attachment_names) if attachment_names else None,
)
email_to = ', '.join(to_emails) if to_emails else ', '.join(cc_emails[:1])
email_cc_str = ', '.join(cc_emails) if to_emails else ', '.join(cc_emails[1:])
subject = f'{subject_prefix} - {client_name} - {self.name}'
try:
mail_vals = {
'subject': subject,
'body_html': body_html,
'email_to': email_to,
'email_cc': email_cc_str,
'model': 'sale.order',
'res_id': self.id,
}
if attachments:
mail_vals['attachment_ids'] = [(6, 0, attachments)]
self.env['mail.mail'].sudo().create(mail_vals).send()
self._email_chatter_log(f'{title} email sent', email_to, email_cc_str,
[f'Attachments: {", ".join(attachment_names)}'] if attachment_names else None)
_logger.info(f"MOD email '{title}' sent for {self.name}")
return True
except Exception as e:
_logger.error(f"Failed to send MOD email '{title}' for {self.name}: {e}")
return False
# --- Individual MOD status email methods ---
def _send_mod_assessment_scheduled_email(self):
"""Email: Assessment has been scheduled. To: Client, CC: Authorizer."""
self.ensure_one()
client_name = self.partner_id.name or 'Client'
assess_date = self.x_fc_assessment_start_date.strftime('%B %d, %Y') if hasattr(self, 'x_fc_assessment_start_date') and self.x_fc_assessment_start_date else 'a date to be confirmed'
return self._send_mod_email(
subject_prefix='Assessment Scheduled',
title='Assessment Scheduled',
summary=f'An accessibility assessment for <strong>{client_name}</strong> has been scheduled for <strong>{assess_date}</strong>.',
email_type='info',
include_client=True, include_authorizer=True,
note='<strong>What to expect:</strong> Our assessor will visit your home to evaluate '
'the accessibility modifications needed. Please ensure someone is available at the '
'scheduled time. If you need to reschedule, please contact us as soon as possible.',
)
def _send_mod_assessment_completed_email(self):
"""Email: Assessment completed. To: Client."""
self.ensure_one()
client_name = self.partner_id.name or 'Client'
return self._send_mod_email(
subject_prefix='Assessment Completed',
title='Assessment Completed',
summary=f'The accessibility assessment for <strong>{client_name}</strong> has been completed.',
email_type='success',
include_client=True, include_authorizer=False,
note='<strong>Next steps:</strong> Our team is now preparing the drawings and quotation '
'based on the assessment. We will send you the proposal once it is ready for review.',
note_color='#38a169',
)
def _send_mod_quote_submitted_email(self):
"""Email: Quote/drawings submitted. To: Client, CC: Authorizer, MOD contact."""
self.ensure_one()
client_name = self.partner_id.name or 'Client'
return self._send_mod_email(
subject_prefix='Quotation & Drawings Submitted',
title='Quotation & Drawings Submitted',
summary=f'The quotation and drawings for <strong>{client_name}</strong> have been submitted for review.',
email_type='info',
include_client=True, include_authorizer=True, include_mod_contact=True,
note='<strong>Next steps:</strong> The proposal will be reviewed by March of Dimes. '
'The funding review process typically takes several weeks. We will follow up '
'regularly and keep you updated on the status.',
)
def _send_mod_funding_approved_email(self):
"""Email: Funding approved by MOD. To: Client, CC: Authorizer, Sales Rep."""
self.ensure_one()
client_name = self.partner_id.name or 'Client'
commitment = f'${self.x_fc_mod_approved_amount:,.2f}' if self.x_fc_mod_approved_amount else 'TBD'
return self._send_mod_email(
subject_prefix='Funding Approved',
title='Great News - Funding Approved',
summary=f'The March of Dimes funding for <strong>{client_name}</strong> has been <strong>approved</strong>.',
email_type='success',
include_client=True, include_authorizer=True,
sections=[('Case Details', self._build_mod_case_detail_rows(include_amounts=True))],
note=f'<strong>Approved Amount:</strong> {commitment}<br/><br/>'
'<strong>Next steps:</strong> We will receive the Payment Commitment Agreement (PCA) '
'from March of Dimes shortly. Once received, we will proceed with the project. '
'Our team will be in touch to discuss timelines.',
note_color='#38a169',
)
def _send_mod_funding_denied_email(self):
"""Email: Funding denied. To: Client, CC: Authorizer, Sales Rep."""
self.ensure_one()
client_name = self.partner_id.name or 'Client'
return self._send_mod_email(
subject_prefix='Funding Update',
title='Funding Update',
summary=f'Unfortunately, the March of Dimes funding request for <strong>{client_name}</strong> '
f'was not approved at this time.',
email_type='urgent',
include_client=True, include_authorizer=True,
note='<strong>Your options:</strong> You may contact March of Dimes directly for more '
'information about the decision. Alternative funding options or private payment '
'arrangements may be available. Our team is here to help explore your options.',
note_color='#c53030',
)
def _send_mod_contract_received_email(self):
"""Email: PCA/Contract received. To: Client, CC: Sales Rep."""
self.ensure_one()
client_name = self.partner_id.name or 'Client'
completion_date = self.x_fc_estimated_completion_date.strftime('%B %d, %Y') if self.x_fc_estimated_completion_date else 'TBD'
return self._send_mod_email(
subject_prefix='Contract Received - Project Starting',
title='Contract Received',
summary=f'The Payment Commitment Agreement for <strong>{client_name}</strong> has been received from March of Dimes.',
email_type='success',
include_client=True, include_authorizer=False,
note=f'<strong>Project Completion Deadline:</strong> {completion_date}<br/><br/>'
'<strong>Next steps:</strong> We will now begin processing your project. '
'Our team will be in contact to schedule the next steps and keep you updated on progress.',
note_color='#38a169',
)
def _send_mod_invoice_submitted_email(self):
"""Email: Invoice submitted to MOD. To: MOD contact."""
self.ensure_one()
client_name = self.partner_id.name or 'Client'
if not self.x_fc_mod_contact_email:
_logger.warning(f"No MOD contact email for {self.name}, skipping invoice email")
return False
return self._send_mod_email(
subject_prefix='Invoice Submitted',
title='Invoice Submitted',
summary=f'Please find attached the invoice for the accessibility modification project for <strong>{client_name}</strong>.',
email_type='info',
include_client=False, include_authorizer=False, include_mod_contact=True,
sections=[('Invoice Details', self._build_mod_case_detail_rows(include_amounts=True))],
note='Please process the initial payment (90%) as per the Payment Commitment Agreement terms.',
)
def _send_mod_initial_payment_email(self):
"""Email: 90% payment received, project progressing. To: Client."""
self.ensure_one()
client_name = self.partner_id.name or 'Client'
amount = f'${self.x_fc_mod_initial_payment_amount:,.2f}' if self.x_fc_mod_initial_payment_amount else 'the initial payment'
return self._send_mod_email(
subject_prefix='Project Update - Payment Received',
title='Project Update',
summary=f'We have received {amount} for the accessibility project for <strong>{client_name}</strong>. '
f'Your project is now in active production.',
email_type='success',
include_client=True, include_authorizer=False,
note='<strong>What is happening:</strong> Your project is being processed and we are '
'working towards completion. We will keep you updated on key milestones.',
note_color='#38a169',
)
def _send_mod_project_complete_email(self):
"""Email: Project/installation complete. To: Client, CC: Authorizer."""
self.ensure_one()
client_name = self.partner_id.name or 'Client'
return self._send_mod_email(
subject_prefix='Project Complete',
title='Project Installation Complete',
summary=f'The accessibility modification project for <strong>{client_name}</strong> has been <strong>completed</strong>.',
email_type='success',
include_client=True, include_authorizer=True,
note='<strong>Next steps:</strong> We will be submitting the photos and proof of delivery '
'to March of Dimes for final payment processing. If you have any questions or '
'concerns about the installation, please contact us.',
note_color='#38a169',
)
def _send_mod_pod_submitted_email(self):
"""Email: Photos/POD submitted to MOD. To: MOD contact, CC: Authorizer."""
self.ensure_one()
client_name = self.partner_id.name or 'Client'
return self._send_mod_email(
subject_prefix='Proof of Delivery Submitted',
title='Proof of Delivery Submitted',
summary=f'Photos and proof of delivery for <strong>{client_name}</strong> have been submitted.',
email_type='info',
include_client=False, include_authorizer=True, include_mod_contact=True,
note='Please process the final payment (10%) as per the Payment Commitment Agreement terms.',
)
def _send_mod_final_payment_email(self):
"""Email: Final payment received. To: Client."""
self.ensure_one()
client_name = self.partner_id.name or 'Client'
return self._send_mod_email(
subject_prefix='Final Payment Received',
title='Final Payment Received',
summary=f'The final payment for the accessibility project for <strong>{client_name}</strong> has been received.',
email_type='success',
include_client=True, include_authorizer=False,
note='<strong>Thank you!</strong> All payments have been received and your project is '
'now fully complete. If you need any support or have warranty questions, '
'please do not hesitate to contact us.',
note_color='#38a169',
)
def _send_mod_case_closed_email(self):
"""Email: Case closed. To: Client, CC: Authorizer."""
self.ensure_one()
client_name = self.partner_id.name or 'Client'
return self._send_mod_email(
subject_prefix='Case Closed',
title='Case Closed',
summary=f'The accessibility modification case for <strong>{client_name}</strong> has been closed.',
email_type='info',
include_client=True, include_authorizer=True,
note='<strong>Important:</strong> Your equipment comes with a one-year warranty on '
'materials, equipment, and workmanship from the date of installation. '
'If you experience any issues, please contact us immediately.',
)
def _send_mod_followup_email(self):
"""Auto-email to client when follow-up activity is not completed on time."""
self.ensure_one()
if not self._is_email_notifications_enabled():
return False
client = self.partner_id
if not client or not client.email:
return False
client_name = client.name or 'Client'
sender_name = (self.user_id or self.env.user).name
followup_count = self.x_fc_mod_followup_count or 0
body_html = self._mod_email_build(
title='Project Status Check-In',
summary=f'We wanted to check in on the accessibility modification project for '
f'<strong>{client_name}</strong>.',
email_type='info',
sections=[('Case Details', self._build_mod_case_detail_rows())],
note='<strong>We are here to help:</strong> If you have received any updates from March of Dimes '
'regarding your funding application, please let us know so we can proceed accordingly. '
'If you have any questions about your project, feel free to reach out to us anytime.',
button_url=False,
sender_name=sender_name,
)
try:
self.env['mail.mail'].sudo().create({
'subject': f'Project Update Check-In - {client_name} - {self.name}',
'body_html': body_html,
'email_to': client.email,
'model': 'sale.order', 'res_id': self.id,
}).send()
self.with_context(skip_all_validations=True).write({
'x_fc_mod_last_followup_date': fields.Date.today(),
'x_fc_mod_followup_count': followup_count + 1,
'x_fc_mod_followup_escalated': True,
})
self._email_chatter_log('MOD Follow-up auto-email sent (activity overdue)', client.email)
return True
except Exception as e:
_logger.error(f"Failed to send MOD follow-up email for {self.name}: {e}")
return False
# ======================================================================
# MARCH OF DIMES - TWILIO SMS
# ======================================================================
def _send_mod_sms(self, trigger):
"""Send Twilio SMS for key MOD status changes."""
self.ensure_one()
ICP = self.env['ir.config_parameter'].sudo()
if ICP.get_param('fusion_claims.twilio_enabled', 'False').lower() not in ('true', '1', 'yes'):
return False
client = self.partner_id
phone = client.mobile or client.phone if client else None
if not phone:
_logger.info(f"No phone number for {self.name}, skipping SMS")
return False
client_name = client.name or 'Client'
company_phone = self.company_id.phone or ''
messages = {
'assessment_scheduled': (
f"Hi {client_name}, your accessibility assessment with Westin Healthcare "
f"has been scheduled. We will confirm the exact date and time shortly. "
f"For questions, call {company_phone}."
),
'funding_approved': (
f"Hi {client_name}, great news! Your March of Dimes funding has been approved. "
f"Our team will be in touch with next steps. Questions? Call {company_phone}."
),
'initial_payment_received': (
f"Hi {client_name}, we have received the initial payment for your project. "
f"Work is in progress. We will keep you updated. Call {company_phone} for info."
),
'project_complete': (
f"Hi {client_name}, your accessibility modification project is now complete! "
f"If you have any questions or concerns, call us at {company_phone}."
),
}
message = messages.get(trigger)
if not message:
return False
return self._twilio_send_sms(phone, message)
def _twilio_send_sms(self, to_number, message):
"""Send SMS via Twilio REST API."""
import requests as req
self.ensure_one()
ICP = self.env['ir.config_parameter'].sudo()
account_sid = ICP.get_param('fusion_claims.twilio_account_sid', '')
auth_token = ICP.get_param('fusion_claims.twilio_auth_token', '')
from_number = ICP.get_param('fusion_claims.twilio_phone_number', '')
if not all([account_sid, auth_token, from_number]):
_logger.warning("Twilio not configured, skipping SMS")
return False
url = f'https://api.twilio.com/2010-04-01/Accounts/{account_sid}/Messages.json'
try:
resp = req.post(url, data={
'To': to_number,
'From': from_number,
'Body': message,
}, auth=(account_sid, auth_token), timeout=10)
if resp.status_code in (200, 201):
self._email_chatter_log(f'SMS sent to {to_number}', to_number)
_logger.info(f"Twilio SMS sent to {to_number} for {self.name}")
return True
else:
_logger.error(f"Twilio SMS failed ({resp.status_code}): {resp.text}")
return False
except Exception as e:
_logger.error(f"Twilio SMS error for {self.name}: {e}")
return False
# ======================================================================
# MARCH OF DIMES - FOLLOW-UP CRON
# ======================================================================
@api.model
def _cron_mod_schedule_followups(self):
"""Cron: Schedule bi-weekly follow-up activities for MOD cases awaiting funding."""
from datetime import timedelta
ICP = self.env['ir.config_parameter'].sudo()
interval_days = int(ICP.get_param('fusion_claims.mod_followup_interval_days', '14'))
# Statuses that need follow-up (waiting for funding decision)
followup_statuses = ['quote_submitted', 'awaiting_funding']
orders = self.search([
('x_fc_sale_type', '=', 'march_of_dimes'),
('x_fc_mod_status', 'in', followup_statuses),
])
today = fields.Date.today()
for order in orders:
try:
next_date = order.x_fc_mod_next_followup_date
# If no next followup date set, or it's in the past, schedule one
if not next_date or next_date <= today:
# Calculate from last followup or quote submission date
base_date = order.x_fc_mod_last_followup_date or order.x_fc_case_submitted or today
new_followup = base_date + timedelta(days=interval_days)
if new_followup <= today:
new_followup = today + timedelta(days=1) # Schedule for tomorrow at minimum
# Create scheduled activity
activity_type = self.env.ref(
'fusion_claims.mail_activity_type_mod_followup', raise_if_not_found=False)
if activity_type:
# Check if there's already an open activity of this type
existing = self.env['mail.activity'].search([
('res_model', '=', 'sale.order'),
('res_id', '=', order.id),
('activity_type_id', '=', activity_type.id),
], limit=1)
if not existing:
order.activity_schedule(
'fusion_claims.mail_activity_type_mod_followup',
date_deadline=new_followup,
user_id=(order.user_id or self.env.user).id,
summary=f'MOD Follow-up: Call {order.partner_id.name or "client"} for funding update',
)
order.with_context(skip_all_validations=True).write({
'x_fc_mod_next_followup_date': new_followup,
})
_logger.info(f"Scheduled MOD follow-up for {order.name} on {new_followup}")
except Exception as e:
_logger.error(f"Failed to schedule MOD follow-up for {order.name}: {e}")
@api.model
def _cron_mod_escalate_followups(self):
"""Cron: Send auto-email if follow-up activity is overdue (not completed within 3 days)."""
from datetime import timedelta
ICP = self.env['ir.config_parameter'].sudo()
escalation_days = int(ICP.get_param('fusion_claims.mod_followup_escalation_days', '3'))
activity_type = self.env.ref(
'fusion_claims.mail_activity_type_mod_followup', raise_if_not_found=False)
if not activity_type:
return
# Find overdue follow-up activities
cutoff_date = fields.Date.today() - timedelta(days=escalation_days)
overdue_activities = self.env['mail.activity'].search([
('res_model', '=', 'sale.order'),
('activity_type_id', '=', activity_type.id),
('date_deadline', '<=', cutoff_date),
])
for activity in overdue_activities:
try:
order = self.browse(activity.res_id)
if not order.exists() or not order._is_mod_sale():
continue
if order.x_fc_mod_status not in ('quote_submitted', 'awaiting_funding'):
# Status moved past follow-up phase, clean up the activity
activity.unlink()
continue
# Only escalate once per activity
if not order.x_fc_mod_followup_escalated:
order._send_mod_followup_email()
# Clean up the overdue activity and let the scheduler create a new one
activity.unlink()
except Exception as e:
_logger.error(f"Failed to escalate MOD follow-up for activity {activity.id}: {e}")
# ======================================================================
# ODSP EMAIL AUTOMATION
# ======================================================================
def _odsp_email_build(self, **kwargs):
"""Wrapper around _email_build that overrides the footer for ODSP emails."""
html = self._email_build(**kwargs)
html = html.replace(
'This is an automated notification from the ADP Claims Management System.',
'This is an automated notification from the ODSP Case Management System.',
)
return html
def _get_sa_mobility_email(self):
"""Get the configured SA Mobility email address."""
return self.env['ir.config_parameter'].sudo().get_param(
'fusion_claims.sa_mobility_email', 'samobility@ontario.ca')
def _send_sa_mobility_email(self, request_type='repair', device_description='',
attachment_ids=None, email_body_notes=None):
"""Send SA Mobility submission email.
Args:
request_type: 'batteries' or 'repair'
device_description: human-readable device label
attachment_ids: list of ir.attachment IDs to attach
email_body_notes: optional urgency/priority notes for the email body
"""
self.ensure_one()
client_name = self.partner_id.name or 'Client'
member_id = self.x_fc_odsp_member_id or self.partner_id.x_fc_odsp_member_id or ''
client_address = self.partner_id.contact_address or ''
sa_email = self._get_sa_mobility_email()
subject = f'{client_name} - ODSP - {member_id}' if member_id else client_name
summary_parts = []
if email_body_notes:
summary_parts.append(
f'<strong style="color:#c53030;">{email_body_notes}</strong>'
)
if request_type == 'batteries':
summary_parts.append(
f'Client is getting {device_description or "Electric Wheelchair / Mobility Scooter"} from ADP. '
f'ADP is covering the equipment. We have submitted request for approval to ADP '
f'and client is seeking approval from ODSP for Batteries.'
)
else:
summary_parts.append(
f'Client has {device_description or "mobility equipment"} '
f'and is looking for replacement parts and repairs. '
f'Please find the attached SA Mobility Form and Quotation.'
)
summary = '<br/>'.join(summary_parts)
sections = [('Client Details', [
('Client Name', client_name),
('ODSP Member ID', member_id),
('Address', client_address),
('Order #', self.name),
])]
body_html = self._odsp_email_build(
title='SA Mobility Request',
summary=summary,
email_type='info',
sections=sections,
sender_name=(self.user_id or self.env.user).name,
)
cc_emails = []
if self.user_id and self.user_id.email:
cc_emails.append(self.user_id.email)
mail_vals = {
'subject': subject,
'body_html': body_html,
'email_to': sa_email,
'email_cc': ', '.join(cc_emails) if cc_emails else '',
'model': 'sale.order',
'res_id': self.id,
}
if attachment_ids:
mail_vals['attachment_ids'] = [(6, 0, attachment_ids)]
try:
self.env['mail.mail'].sudo().create(mail_vals).send()
self._email_chatter_log(
'SA Mobility request sent', sa_email,
', '.join(cc_emails) if cc_emails else None)
_logger.info(f"SA Mobility email sent for {self.name} to {sa_email}")
except Exception as e:
_logger.error(f"Failed to send SA Mobility email for {self.name}: {e}")
from odoo.exceptions import UserError
raise UserError(f"Failed to send email: {e}")
def _send_sa_mobility_completion_email(self, attachment_ids=None):
"""Send SA Mobility completion email with signed form, POD, and invoice."""
self.ensure_one()
client_name = self.partner_id.name or 'Client'
member_id = self.x_fc_odsp_member_id or ''
sa_email = self._get_sa_mobility_email()
subject = f'{client_name} - {member_id} - Completed'
summary = (
f'Delivery/repair for <strong>{client_name}</strong> has been completed. '
f'Please find the attached signed SA Mobility approval form, '
f'Proof of Delivery, and Invoice.'
)
body_html = self._odsp_email_build(
title='SA Mobility - Completed',
summary=summary,
email_type='success',
sections=[('Case Details', [
('Client Name', client_name),
('ODSP Member ID', member_id),
('Order #', self.name),
])],
sender_name=(self.user_id or self.env.user).name,
)
cc_emails = []
if self.user_id and self.user_id.email:
cc_emails.append(self.user_id.email)
mail_vals = {
'subject': subject,
'body_html': body_html,
'email_to': sa_email,
'email_cc': ', '.join(cc_emails) if cc_emails else '',
'model': 'sale.order',
'res_id': self.id,
}
if attachment_ids:
mail_vals['attachment_ids'] = [(6, 0, attachment_ids)]
try:
self.env['mail.mail'].sudo().create(mail_vals).send()
self._email_chatter_log(
'SA Mobility completion sent', sa_email,
', '.join(cc_emails) if cc_emails else None)
_logger.info(f"SA Mobility completion email sent for {self.name}")
except Exception as e:
_logger.error(f"Failed to send SA Mobility completion email for {self.name}: {e}")
def _send_odsp_submission_email(self, attachment_ids=None, email_body_notes=None):
"""Send ODSP submission email to the selected ODSP office."""
self.ensure_one()
if not self.x_fc_odsp_office_id or not self.x_fc_odsp_office_id.email:
from odoo.exceptions import UserError
raise UserError("ODSP Office email is required. Please select an ODSP Office with an email.")
client_name = self.partner_id.name or 'Client'
member_id = self.x_fc_odsp_member_id or ''
office_email = self.x_fc_odsp_office_id.email
subject = f'ODSP Application - {client_name} - {member_id}'
summary_parts = []
if email_body_notes:
summary_parts.append(
f'<strong style="color:#c53030;">{email_body_notes}</strong>'
)
summary_parts.append(
f'Please find enclosed the ODSP application documents for '
f'<strong>{client_name}</strong> (Member ID: {member_id}).'
)
summary = '<br/>'.join(summary_parts)
body_html = self._odsp_email_build(
title='ODSP Application Submission',
summary=summary,
email_type='info',
sections=[('Application Details', [
('Client Name', client_name),
('ODSP Member ID', member_id),
('Order #', self.name),
])],
sender_name=(self.user_id or self.env.user).name,
)
cc_emails = []
if self.user_id and self.user_id.email:
cc_emails.append(self.user_id.email)
mail_vals = {
'subject': subject,
'body_html': body_html,
'email_to': office_email,
'email_cc': ', '.join(cc_emails) if cc_emails else '',
'model': 'sale.order',
'res_id': self.id,
}
if attachment_ids:
mail_vals['attachment_ids'] = [(6, 0, attachment_ids)]
try:
self.env['mail.mail'].sudo().create(mail_vals).send()
self._email_chatter_log('ODSP submission sent', office_email,
', '.join(cc_emails) if cc_emails else None)
if self._get_odsp_status() in ('quotation', 'documents_ready'):
self._odsp_advance_status('submitted_to_odsp',
"Status auto-advanced after ODSP submission email.")
_logger.info(f"ODSP submission email sent for {self.name} to {office_email}")
except Exception as e:
_logger.error(f"Failed to send ODSP submission email for {self.name}: {e}")