Rental orders no longer show the "Authorizer Required?" question or the Authorizer field. The sale type is automatically set to 'Rentals' when creating or confirming a rental order. Validation logic also skips authorizer checks for rental sale type. Made-with: Cursor
7836 lines
342 KiB
Python
7836 lines
342 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']
|
|
_rec_names_search = ['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',
|
|
)
|
|
|
|
# ==========================================================================
|
|
# LTC REPAIR LINK
|
|
# ==========================================================================
|
|
x_fc_ltc_repair_id = fields.Many2one(
|
|
'fusion.ltc.repair',
|
|
string='LTC Repair',
|
|
tracking=True,
|
|
ondelete='set null',
|
|
index=True,
|
|
)
|
|
x_fc_is_ltc_repair_sale = fields.Boolean(
|
|
compute='_compute_is_ltc_repair_sale',
|
|
store=True,
|
|
string='Is LTC Repair Sale',
|
|
)
|
|
|
|
# ==========================================================================
|
|
# 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()
|
|
|
|
@api.depends('x_fc_ltc_repair_id')
|
|
def _compute_is_ltc_repair_sale(self):
|
|
for order in self:
|
|
order.x_fc_is_ltc_repair_sale = bool(order.x_fc_ltc_repair_id)
|
|
|
|
# ==========================================================================
|
|
# 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 = []
|
|
|
|
# 1. Signed SA Form -- reuse existing attachment created by attachment=True
|
|
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 = Attachment.search([
|
|
('res_model', '=', 'sale.order'),
|
|
('res_id', '=', self.id),
|
|
('res_field', '=', signed_field),
|
|
], order='id desc', limit=1)
|
|
if att:
|
|
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])
|
|
att = Attachment.create({
|
|
'name': f'Invoice_{invoice.name}.pdf',
|
|
'type': 'binary',
|
|
'datas': base64.b64encode(pdf_content),
|
|
'res_model': 'sale.order',
|
|
'res_id': self.id,
|
|
})
|
|
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 = []
|
|
|
|
# 1. Approval document
|
|
if self.x_fc_odsp_approval_document:
|
|
att = Attachment.search([
|
|
('res_model', '=', 'sale.order'),
|
|
('res_id', '=', self.id),
|
|
('res_field', '=', 'x_fc_odsp_approval_document'),
|
|
], order='id desc', limit=1)
|
|
if att:
|
|
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])
|
|
att = Attachment.create({
|
|
'name': f'Invoice_{invoice.name}.pdf',
|
|
'type': 'binary',
|
|
'datas': base64.b64encode(pdf_content),
|
|
'res_model': 'sale.order',
|
|
'res_id': self.id,
|
|
})
|
|
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)
|
|
|
|
# ==========================================================================
|
|
# DELIVERY STATUS FIELDS
|
|
# ==========================================================================
|
|
x_fc_delivery_status = fields.Selection(
|
|
selection=[
|
|
('waiting', 'Waiting'),
|
|
('waiting_approval', 'Waiting for Approval'),
|
|
('ready', 'Ready for Delivery'),
|
|
('scheduled', 'Delivery Scheduled'),
|
|
('shipped_warehouse', 'Shipped to Warehouse'),
|
|
('received_warehouse', 'Received in Warehouse'),
|
|
('delivered', 'Delivered'),
|
|
('hold', 'Hold'),
|
|
('cancelled', 'Cancelled'),
|
|
],
|
|
string='Delivery Status',
|
|
tracking=True,
|
|
help='Current delivery status of the order',
|
|
)
|
|
|
|
x_fc_delivery_datetime = fields.Datetime(
|
|
string='Delivery Date & Time',
|
|
tracking=True,
|
|
help='Scheduled or actual delivery date and time',
|
|
)
|
|
|
|
# Computed field to show/hide delivery datetime
|
|
x_fc_show_delivery_datetime = fields.Boolean(
|
|
compute='_compute_show_delivery_datetime',
|
|
string='Show Delivery DateTime',
|
|
)
|
|
|
|
@api.depends('x_fc_delivery_status')
|
|
def _compute_show_delivery_datetime(self):
|
|
"""Compute whether to show delivery datetime field."""
|
|
for order in self:
|
|
order.x_fc_show_delivery_datetime = order.x_fc_delivery_status in ('scheduled', 'delivered')
|
|
|
|
# ==========================================================================
|
|
# 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='Primary client reference (e.g., Health Card Number)',
|
|
)
|
|
x_fc_client_ref_2 = fields.Char(
|
|
string='Client Reference 2',
|
|
help='Secondary client reference',
|
|
)
|
|
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_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',
|
|
)
|
|
|
|
# ==========================================================================
|
|
# 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)
|
|
# ==========================================================================
|
|
def _get_document_attachment(self, field_name):
|
|
"""Get the ir.attachment record for a binary field stored as attachment."""
|
|
self.ensure_one()
|
|
# Find the attachment by field name - get most recent 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 and return it.
|
|
"""
|
|
self.ensure_one()
|
|
|
|
data = getattr(self, field_name)
|
|
if not data:
|
|
return None
|
|
|
|
# For attachment=True fields, Odoo creates/updates an attachment with res_field set
|
|
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:
|
|
# If attachment name is the field name (Odoo default), use the actual filename
|
|
if attachment.name == field_name:
|
|
filename_mapping = {
|
|
'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',
|
|
}
|
|
filename_field = filename_mapping.get(field_name)
|
|
if filename_field:
|
|
filename = getattr(self, filename_field, None)
|
|
if filename and filename != field_name:
|
|
attachment.sudo().write({'name': filename})
|
|
return attachment
|
|
|
|
# Fallback: create attachment manually (shouldn't happen for attachment=True fields)
|
|
filename_mapping = {
|
|
'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',
|
|
}
|
|
filename_field = filename_mapping.get(field_name)
|
|
filename = getattr(self, filename_field) if filename_field else f'{document_label}.pdf'
|
|
|
|
attachment = self.env['ir.attachment'].sudo().create({
|
|
'name': filename or f'{document_label}.pdf',
|
|
'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')
|
|
|
|
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_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."""
|
|
self.ensure_one()
|
|
if self.x_fc_adp_application_status != 'assessment_scheduled':
|
|
raise UserError("Can only complete assessment from 'Assessment Scheduled' status.")
|
|
|
|
return {
|
|
'name': 'Assessment Completed',
|
|
'type': 'ir.actions.act_window',
|
|
'res_model': 'fusion_claims.assessment.completed.wizard',
|
|
'view_mode': '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_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
|
|
|
|
# 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}&model=account.move&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}&model=account.move&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):
|
|
"""Post a document attachment to the chatter with a link.
|
|
|
|
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)
|
|
"""
|
|
self.ensure_one()
|
|
|
|
# Map field names to filename fields
|
|
filename_mapping = {
|
|
'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',
|
|
}
|
|
|
|
data_field = field_name
|
|
filename_field = filename_mapping.get(field_name, field_name + '_filename')
|
|
|
|
data = getattr(self, data_field, None)
|
|
original_filename = getattr(self, filename_field, None) or 'document'
|
|
|
|
if not data:
|
|
return
|
|
|
|
# Get document label from field definition if not provided
|
|
if not document_label:
|
|
field_obj = self._fields.get(data_field)
|
|
document_label = field_obj.string if field_obj else data_field
|
|
|
|
# Check for existing attachments with same name for revision numbering
|
|
existing_count = self.env['ir.attachment'].sudo().search_count([
|
|
('res_model', '=', 'sale.order'),
|
|
('res_id', '=', self.id),
|
|
('name', '=like', original_filename.rsplit('.', 1)[0] + '%'),
|
|
])
|
|
|
|
# Add revision number if this is a replacement
|
|
if existing_count > 0 and '(replaced)' in (document_label or ''):
|
|
# This is an old document being replaced - add revision number
|
|
base_name, ext = original_filename.rsplit('.', 1) if '.' in original_filename else (original_filename, '')
|
|
filename = f"R{existing_count}_{base_name}.{ext}" if ext else f"R{existing_count}_{base_name}"
|
|
else:
|
|
filename = original_filename
|
|
|
|
# Create attachment with the original/revised filename
|
|
attachment = self.env['ir.attachment'].sudo().create({
|
|
'name': filename,
|
|
'datas': data,
|
|
'res_model': 'sale.order',
|
|
'res_id': self.id,
|
|
})
|
|
|
|
# Post message with attachment (shows as native Odoo attachment with preview)
|
|
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')
|
|
)
|
|
|
|
# Use attachment_ids to show as native attachment with preview capability
|
|
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
|
|
|
|
# Reuse existing field attachments (created by Odoo for attachment=True fields)
|
|
# instead of creating duplicates
|
|
attachments = []
|
|
attachment_names = []
|
|
Attachment = self.env['ir.attachment'].sudo()
|
|
if self.x_fc_final_submitted_application:
|
|
att = Attachment.search([
|
|
('res_model', '=', 'sale.order'),
|
|
('res_id', '=', self.id),
|
|
('res_field', '=', 'x_fc_final_submitted_application'),
|
|
], order='id desc', limit=1)
|
|
if att:
|
|
attachments.append(att.id)
|
|
attachment_names.append('Final ADP Application (PDF)')
|
|
if self.x_fc_xml_file:
|
|
att = Attachment.search([
|
|
('res_model', '=', 'sale.order'),
|
|
('res_id', '=', self.id),
|
|
('res_field', '=', 'x_fc_xml_file'),
|
|
], order='id desc', limit=1)
|
|
if att:
|
|
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 _send_approval_email(self):
|
|
"""Send notification when ADP application is approved."""
|
|
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.'
|
|
)
|
|
|
|
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))],
|
|
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)
|
|
email_cc = ', '.join(cc_emails) if cc_emails else ''
|
|
try:
|
|
self.env['mail.mail'].sudo().create({
|
|
'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,
|
|
}).send()
|
|
self._email_chatter_log(f'Application {status_label} email sent', email_to, email_cc)
|
|
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):
|
|
"""Send notification when application is withdrawn."""
|
|
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 withdrawn from the Assistive Devices Program.'
|
|
if reason:
|
|
note_text += f'<br/><strong>Reason:</strong> {reason}'
|
|
|
|
body_html = self._email_build(
|
|
title='Application Withdrawn',
|
|
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='#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 Withdrawn - {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 Withdrawn 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 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',
|
|
]
|
|
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:
|
|
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:
|
|
# REPLACEMENT: Old document being replaced with new one
|
|
# Preserve old document in chatter as attachment
|
|
order._post_document_to_chatter(
|
|
field_name,
|
|
f"{label} (replaced)"
|
|
)
|
|
|
|
elif old_data and not new_data:
|
|
# DELETION: Document is being deleted
|
|
# Preserve the deleted document in chatter
|
|
order._post_document_to_chatter(
|
|
field_name,
|
|
f"{label} (DELETED)"
|
|
)
|
|
|
|
# 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)')
|
|
if order.x_fc_xml_file:
|
|
order._post_document_to_chatter('x_fc_xml_file',
|
|
'XML File (before correction)')
|
|
|
|
# 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':
|
|
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}")
|
|
|
|
def action_sync_adp_fields(self):
|
|
"""Manual action to sync all ADP fields to invoices."""
|
|
synced_invoices = 0
|
|
for order in self:
|
|
# First sync Studio fields to FC fields on the SO itself
|
|
order._sync_studio_to_fc_fields()
|
|
|
|
# Then sync to invoices
|
|
invoices = order.invoice_ids.filtered(lambda inv: inv.state != 'cancel')
|
|
if invoices:
|
|
order._sync_fields_to_invoices()
|
|
synced_invoices += len(invoices)
|
|
|
|
# Force refresh of the view
|
|
return {
|
|
'type': 'ir.actions.client',
|
|
'tag': 'display_notification',
|
|
'params': {
|
|
'title': 'Fields Synchronized',
|
|
'message': f'Synced ADP fields from {len(self)} sale order(s) to {synced_invoices} invoice(s). Please refresh the page to see updated values.',
|
|
'type': 'success',
|
|
'sticky': False,
|
|
}
|
|
}
|
|
|
|
@api.model
|
|
def _cron_sync_adp_fields(self):
|
|
"""Cron job to sync ADP fields from Sale Orders to Invoices.
|
|
|
|
Processes all ADP sales created/modified in the last 7 days.
|
|
Uses dynamic field mappings from Settings.
|
|
"""
|
|
from datetime import timedelta
|
|
cutoff_date = fields.Datetime.now() - timedelta(days=7)
|
|
|
|
# Get field mappings
|
|
mappings = self._get_field_mappings()
|
|
sale_type_field = self.env['ir.config_parameter'].sudo().get_param(
|
|
'fusion_claims.field_sale_type', 'x_fc_sale_type'
|
|
)
|
|
|
|
# Build domain - check FC sale type fields
|
|
domain = [('write_date', '>=', cutoff_date)]
|
|
or_conditions = []
|
|
|
|
# Check FC sale type field
|
|
if sale_type_field in self._fields:
|
|
or_conditions.append((sale_type_field, 'in', ['adp', 'adp_odsp', 'ADP', 'ADP/ODSP']))
|
|
|
|
# Check claim number fields
|
|
claim_field = mappings.get('so_claim_number', 'x_fc_claim_number')
|
|
if claim_field in self._fields:
|
|
or_conditions.append((claim_field, '!=', False))
|
|
|
|
# Combine with OR - each '|' must be a separate element in the domain list
|
|
if or_conditions:
|
|
# Add (n-1) OR operators for n conditions
|
|
for _ in range(len(or_conditions) - 1):
|
|
domain.append('|')
|
|
# Add all conditions
|
|
for cond in or_conditions:
|
|
domain.append(cond)
|
|
|
|
try:
|
|
orders = self.search(domain)
|
|
except Exception as e:
|
|
_logger.error(f"Error searching for ADP orders: {e}")
|
|
# Fallback to simpler search
|
|
orders = self.search([
|
|
('write_date', '>=', cutoff_date),
|
|
('invoice_ids', '!=', False),
|
|
])
|
|
|
|
synced_count = 0
|
|
error_count = 0
|
|
|
|
for order in orders:
|
|
try:
|
|
# Only sync if it's an ADP sale
|
|
if order._is_adp_sale() or order.x_fc_claim_number:
|
|
order._sync_studio_to_fc_fields()
|
|
order._sync_fields_to_invoices()
|
|
synced_count += 1
|
|
except Exception as e:
|
|
error_count += 1
|
|
_logger.warning(f"Failed to sync order {order.name}: {e}")
|
|
|
|
_logger.info(f"Fusion Claims sync complete: {synced_count} orders synced, {error_count} errors")
|
|
return synced_count
|
|
|
|
# ==========================================================================
|
|
# 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).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._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}")
|