- fusion_claims: separated field service logic, updated controllers/views - fusion_tasks: updated task views and map integration - fusion_authorizer_portal: added page 11 signing, schedule booking, migrations - fusion_shipping: new standalone shipping module (Canada Post, FedEx, DHL, Purolator) - fusion_ltc_management: new standalone LTC management module
2771 lines
120 KiB
Python
2771 lines
120 KiB
Python
# -*- coding: utf-8 -*-
|
|
|
|
from odoo import http, _, fields
|
|
from odoo.http import request
|
|
from odoo.addons.portal.controllers.portal import CustomerPortal, pager as portal_pager
|
|
from odoo.exceptions import AccessError, MissingError
|
|
import base64
|
|
import logging
|
|
import pytz
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class AuthorizerPortal(CustomerPortal):
|
|
"""Portal controller for Authorizers (OTs/Therapists)"""
|
|
|
|
@http.route(['/my', '/my/home'], type='http', auth='user', website=True)
|
|
def home(self, **kw):
|
|
"""Override home to add ADP posting info for Fusion users"""
|
|
partner = request.env.user.partner_id
|
|
|
|
# Get the standard portal home response
|
|
response = super().home(**kw)
|
|
|
|
# Add ADP posting info and other data for Fusion users
|
|
if hasattr(response, 'qcontext') and (partner.is_authorizer or partner.is_sales_rep_portal or partner.is_client_portal or partner.is_technician_portal):
|
|
posting_info = self._get_adp_posting_info()
|
|
response.qcontext.update(posting_info)
|
|
response.qcontext.update(self._get_clock_status_data())
|
|
|
|
# Add signature count (documents to sign) - only if Sign module is installed
|
|
sign_count = 0
|
|
sign_module_available = 'sign.request.item' in request.env
|
|
if sign_module_available:
|
|
sign_count = request.env['sign.request.item'].sudo().search_count([
|
|
('partner_id', '=', partner.id),
|
|
('state', '=', 'sent'),
|
|
])
|
|
response.qcontext['sign_count'] = sign_count
|
|
response.qcontext['sign_module_available'] = sign_module_available
|
|
|
|
ICP = request.env['ir.config_parameter'].sudo()
|
|
g_start = ICP.get_param('fusion_claims.portal_gradient_start', '#5ba848')
|
|
g_mid = ICP.get_param('fusion_claims.portal_gradient_mid', '#3a8fb7')
|
|
g_end = ICP.get_param('fusion_claims.portal_gradient_end', '#2e7aad')
|
|
response.qcontext['portal_gradient'] = (
|
|
'linear-gradient(135deg, %s 0%%, %s 60%%, %s 100%%)' % (g_start, g_mid, g_end)
|
|
)
|
|
|
|
return response
|
|
|
|
def _prepare_home_portal_values(self, counters):
|
|
"""Add authorizer/sales rep counts to portal home"""
|
|
values = super()._prepare_home_portal_values(counters)
|
|
partner = request.env.user.partner_id
|
|
|
|
if 'authorizer_case_count' in counters:
|
|
if partner.is_authorizer:
|
|
values['authorizer_case_count'] = request.env['sale.order'].sudo().search_count([
|
|
('x_fc_authorizer_id', '=', partner.id)
|
|
])
|
|
else:
|
|
values['authorizer_case_count'] = 0
|
|
|
|
if 'sales_rep_case_count' in counters:
|
|
if partner.is_sales_rep_portal:
|
|
values['sales_rep_case_count'] = request.env['sale.order'].sudo().search_count([
|
|
('user_id', '=', request.env.user.id)
|
|
])
|
|
else:
|
|
values['sales_rep_case_count'] = 0
|
|
|
|
if 'assessment_count' in counters:
|
|
count = 0
|
|
if partner.is_authorizer:
|
|
count += request.env['fusion.assessment'].sudo().search_count([
|
|
('authorizer_id', '=', partner.id)
|
|
])
|
|
if partner.is_sales_rep_portal:
|
|
count += request.env['fusion.assessment'].sudo().search_count([
|
|
('sales_rep_id', '=', request.env.user.id)
|
|
])
|
|
values['assessment_count'] = count
|
|
|
|
if 'technician_delivery_count' in counters:
|
|
if partner.is_technician_portal:
|
|
values['technician_delivery_count'] = request.env['sale.order'].sudo().search_count([
|
|
('x_fc_delivery_technician_ids', 'in', [request.env.user.id])
|
|
])
|
|
else:
|
|
values['technician_delivery_count'] = 0
|
|
|
|
# Add ADP posting schedule info for portal users
|
|
if partner.is_authorizer or partner.is_sales_rep_portal or partner.is_client_portal or partner.is_technician_portal:
|
|
values.update(self._get_adp_posting_info())
|
|
|
|
return values
|
|
|
|
def _get_adp_posting_info(self):
|
|
"""Get ADP posting schedule information for the portal home."""
|
|
from datetime import date, timedelta
|
|
|
|
ICP = request.env['ir.config_parameter'].sudo()
|
|
|
|
# Get base date and frequency from settings
|
|
base_date_str = ICP.get_param('fusion_claims.adp_posting_base_date', '2026-01-23')
|
|
frequency = int(ICP.get_param('fusion_claims.adp_posting_frequency_days', '14'))
|
|
|
|
try:
|
|
base_date = date.fromisoformat(base_date_str)
|
|
except (ValueError, TypeError):
|
|
base_date = date(2026, 1, 23)
|
|
|
|
# Get user's timezone for accurate date display
|
|
user_tz = request.env.user.tz or 'UTC'
|
|
try:
|
|
tz = pytz.timezone(user_tz)
|
|
except pytz.exceptions.UnknownTimeZoneError:
|
|
tz = pytz.UTC
|
|
|
|
# Get today's date in user's timezone
|
|
from datetime import datetime
|
|
now_utc = datetime.now(pytz.UTC)
|
|
now_local = now_utc.astimezone(tz)
|
|
today = now_local.date()
|
|
|
|
# Calculate next posting date
|
|
if today < base_date:
|
|
next_posting = base_date
|
|
else:
|
|
days_since_base = (today - base_date).days
|
|
cycles_passed = days_since_base // frequency
|
|
next_posting = base_date + timedelta(days=(cycles_passed + 1) * frequency)
|
|
|
|
# If today is a posting day, return the next one
|
|
if days_since_base % frequency == 0:
|
|
next_posting = base_date + timedelta(days=(cycles_passed + 1) * frequency)
|
|
|
|
# Calculate key dates for the posting cycle
|
|
# Wednesday is submission deadline (posting day - 2 if posting is Friday)
|
|
days_until_wednesday = (next_posting.weekday() - 2) % 7
|
|
if days_until_wednesday == 0 and next_posting.weekday() != 2:
|
|
days_until_wednesday = 7
|
|
submission_deadline = next_posting - timedelta(days=days_until_wednesday)
|
|
|
|
# Get next 3 posting dates for the calendar
|
|
posting_dates = []
|
|
current_posting = next_posting
|
|
for i in range(6):
|
|
posting_dates.append({
|
|
'date': current_posting.isoformat(),
|
|
'display': current_posting.strftime('%B %d, %Y'),
|
|
'day': current_posting.day,
|
|
'month': current_posting.strftime('%B'),
|
|
'year': current_posting.year,
|
|
'weekday': current_posting.strftime('%A'),
|
|
'is_next': i == 0,
|
|
})
|
|
current_posting = current_posting + timedelta(days=frequency)
|
|
|
|
# Days until next posting
|
|
days_until_posting = (next_posting - today).days
|
|
|
|
return {
|
|
'next_posting_date': next_posting,
|
|
'next_posting_display': next_posting.strftime('%B %d, %Y'),
|
|
'next_posting_weekday': next_posting.strftime('%A'),
|
|
'submission_deadline': submission_deadline,
|
|
'submission_deadline_display': submission_deadline.strftime('%B %d, %Y'),
|
|
'days_until_posting': days_until_posting,
|
|
'posting_dates': posting_dates,
|
|
'current_month': today.strftime('%B %Y'),
|
|
'today': today,
|
|
}
|
|
|
|
# ==================== AUTHORIZER PORTAL ====================
|
|
|
|
@http.route(['/my/authorizer', '/my/authorizer/dashboard'], type='http', auth='user', website=True)
|
|
def authorizer_dashboard(self, **kw):
|
|
"""Authorizer dashboard - simplified mobile-first view"""
|
|
partner = request.env.user.partner_id
|
|
|
|
if not partner.is_authorizer:
|
|
return request.redirect('/my')
|
|
|
|
SaleOrder = request.env['sale.order'].sudo()
|
|
Assessment = request.env['fusion.assessment'].sudo()
|
|
|
|
# Base domain for this authorizer
|
|
base_domain = [('x_fc_authorizer_id', '=', partner.id)]
|
|
|
|
# Total cases
|
|
total_cases = SaleOrder.search_count(base_domain)
|
|
|
|
# Assessment counts (express + accessibility)
|
|
express_count = Assessment.search_count([('authorizer_id', '=', partner.id)])
|
|
accessibility_count = 0
|
|
if 'fusion.accessibility.assessment' in request.env:
|
|
accessibility_count = request.env['fusion.accessibility.assessment'].sudo().search_count([
|
|
('authorizer_id', '=', partner.id)
|
|
])
|
|
assessment_count = express_count + accessibility_count
|
|
|
|
# Cases needing authorizer attention (waiting for application)
|
|
needs_attention = SaleOrder.search(
|
|
base_domain + [('x_fc_adp_application_status', 'in', [
|
|
'waiting_for_application', 'assessment_completed',
|
|
])],
|
|
order='write_date desc',
|
|
limit=10,
|
|
)
|
|
|
|
# Human-readable status labels
|
|
status_labels = {}
|
|
if needs_attention:
|
|
status_labels = dict(needs_attention[0]._fields['x_fc_adp_application_status'].selection)
|
|
|
|
# Sale type labels
|
|
sale_type_labels = {}
|
|
if total_cases:
|
|
sample = SaleOrder.search(base_domain, limit=1)
|
|
if sample and 'x_fc_sale_type' in sample._fields:
|
|
sale_type_labels = dict(sample._fields['x_fc_sale_type'].selection)
|
|
|
|
# Recent cases (last 5 updated)
|
|
recent_cases = SaleOrder.search(
|
|
base_domain,
|
|
order='write_date desc',
|
|
limit=5,
|
|
)
|
|
|
|
# Get status labels from recent cases if not already loaded
|
|
if not status_labels and recent_cases:
|
|
status_labels = dict(recent_cases[0]._fields['x_fc_adp_application_status'].selection)
|
|
|
|
# Pending assessments
|
|
pending_assessments = Assessment.search([
|
|
('authorizer_id', '=', partner.id),
|
|
('state', 'in', ['draft', 'pending_signature'])
|
|
], limit=5, order='assessment_date desc')
|
|
|
|
company = request.env.company
|
|
|
|
values = {
|
|
'partner': partner,
|
|
'company': company,
|
|
'total_cases': total_cases,
|
|
'assessment_count': assessment_count,
|
|
'needs_attention': needs_attention,
|
|
'recent_cases': recent_cases,
|
|
'pending_assessments': pending_assessments,
|
|
'status_labels': status_labels,
|
|
'sale_type_labels': sale_type_labels,
|
|
'page_name': 'authorizer_dashboard',
|
|
}
|
|
|
|
return request.render('fusion_authorizer_portal.portal_authorizer_dashboard', values)
|
|
|
|
@http.route(['/my/authorizer/cases', '/my/authorizer/cases/page/<int:page>'], type='http', auth='user', website=True)
|
|
def authorizer_cases(self, page=1, search='', sortby='date', sale_type='', **kw):
|
|
"""List of cases assigned to the authorizer"""
|
|
partner = request.env.user.partner_id
|
|
|
|
if not partner.is_authorizer:
|
|
return request.redirect('/my')
|
|
|
|
SaleOrder = request.env['sale.order'].sudo()
|
|
|
|
# Sale type groupings for filtering
|
|
sale_type_groups = {
|
|
'adp': ['adp', 'adp_odsp'],
|
|
'odsp': ['odsp'],
|
|
'march_of_dimes': ['march_of_dimes'],
|
|
'others': ['wsib', 'direct_private', 'insurance', 'muscular_dystrophy', 'other', 'rental'],
|
|
}
|
|
|
|
# Build domain
|
|
from odoo.osv import expression
|
|
domain = [('x_fc_authorizer_id', '=', partner.id)]
|
|
|
|
# Add sale type filter
|
|
if sale_type and sale_type in sale_type_groups:
|
|
domain.append(('x_fc_sale_type', 'in', sale_type_groups[sale_type]))
|
|
|
|
# Add search filter
|
|
if search:
|
|
search_domain = [
|
|
'|', '|', '|', '|',
|
|
('partner_id.name', 'ilike', search),
|
|
('name', 'ilike', search),
|
|
('x_fc_claim_number', 'ilike', search),
|
|
('x_fc_client_ref_1', 'ilike', search),
|
|
('x_fc_client_ref_2', 'ilike', search),
|
|
]
|
|
domain = expression.AND([domain, search_domain])
|
|
|
|
# Sorting
|
|
sortings = {
|
|
'date': {'label': _('Date'), 'order': 'date_order desc'},
|
|
'name': {'label': _('Reference'), 'order': 'name'},
|
|
'client': {'label': _('Client'), 'order': 'partner_id'},
|
|
'state': {'label': _('Status'), 'order': 'state'},
|
|
}
|
|
order = sortings.get(sortby, sortings['date'])['order']
|
|
|
|
# Pager
|
|
case_count = SaleOrder.search_count(domain)
|
|
pager = portal_pager(
|
|
url='/my/authorizer/cases',
|
|
url_args={'search': search, 'sortby': sortby, 'sale_type': sale_type},
|
|
total=case_count,
|
|
page=page,
|
|
step=20,
|
|
)
|
|
|
|
# Get cases
|
|
cases = SaleOrder.search(domain, order=order, limit=20, offset=pager['offset'])
|
|
|
|
values = {
|
|
'cases': cases,
|
|
'pager': pager,
|
|
'search': search,
|
|
'sortby': sortby,
|
|
'sortings': sortings,
|
|
'sale_type': sale_type,
|
|
'sale_type_label': {
|
|
'adp': 'ADP Cases',
|
|
'odsp': 'ODSP Cases',
|
|
'march_of_dimes': 'March of Dimes',
|
|
'others': 'Other Cases',
|
|
}.get(sale_type, 'All Cases'),
|
|
'page_name': 'authorizer_cases',
|
|
}
|
|
|
|
return request.render('fusion_authorizer_portal.portal_authorizer_cases', values)
|
|
|
|
@http.route('/my/authorizer/cases/search', type='jsonrpc', auth='user')
|
|
def authorizer_cases_search(self, query='', **kw):
|
|
"""AJAX search endpoint for real-time search"""
|
|
partner = request.env.user.partner_id
|
|
|
|
if not partner.is_authorizer:
|
|
return {'error': 'Access denied', 'results': []}
|
|
|
|
if len(query) < 2:
|
|
return {'results': []}
|
|
|
|
SaleOrder = request.env['sale.order'].sudo()
|
|
orders = SaleOrder.get_authorizer_portal_cases(partner.id, search_query=query, limit=50)
|
|
|
|
results = []
|
|
for order in orders:
|
|
results.append({
|
|
'id': order.id,
|
|
'name': order.name,
|
|
'partner_name': order.partner_id.name if order.partner_id else '',
|
|
'date_order': order.date_order.strftime('%Y-%m-%d') if order.date_order else '',
|
|
'state': order.state,
|
|
'state_display': dict(order._fields['state'].selection).get(order.state, order.state),
|
|
'claim_number': getattr(order, 'x_fc_claim_number', '') or '',
|
|
'client_ref_1': order.x_fc_client_ref_1 or '',
|
|
'client_ref_2': order.x_fc_client_ref_2 or '',
|
|
'url': f'/my/authorizer/case/{order.id}',
|
|
})
|
|
|
|
return {'results': results}
|
|
|
|
@http.route('/my/authorizer/case/<int:order_id>', type='http', auth='user', website=True)
|
|
def authorizer_case_detail(self, order_id, **kw):
|
|
"""View a specific case"""
|
|
partner = request.env.user.partner_id
|
|
|
|
if not partner.is_authorizer:
|
|
return request.redirect('/my')
|
|
|
|
try:
|
|
order = request.env['sale.order'].sudo().browse(order_id)
|
|
if not order.exists() or order.x_fc_authorizer_id.id != partner.id:
|
|
raise AccessError(_('You do not have access to this case.'))
|
|
except (AccessError, MissingError):
|
|
return request.redirect('/my/authorizer/cases')
|
|
|
|
# Get documents
|
|
documents = request.env['fusion.adp.document'].sudo().search([
|
|
('sale_order_id', '=', order_id),
|
|
('is_current', '=', True),
|
|
])
|
|
|
|
# Get messages from chatter - only those relevant to this user
|
|
# (authored by them, sent to them, or mentioning them)
|
|
all_messages = request.env['mail.message'].sudo().search([
|
|
('model', '=', 'sale.order'),
|
|
('res_id', '=', order_id),
|
|
('message_type', 'in', ['comment', 'notification']),
|
|
('body', '!=', ''),
|
|
('body', '!=', '<p><br></p>'),
|
|
], order='date desc', limit=100)
|
|
|
|
# Filter to only show messages relevant to this partner:
|
|
# 1. Messages authored by this partner
|
|
# 2. Messages where this partner is in notified_partner_ids
|
|
# 3. Messages where this partner is mentioned (partner_ids)
|
|
def is_relevant_message(msg):
|
|
if not msg.body or len(msg.body.strip()) == 0 or '<p><br></p>' in msg.body:
|
|
return False
|
|
# Authored by current partner
|
|
if msg.author_id.id == partner.id:
|
|
return True
|
|
# Partner is in notified partners
|
|
if partner.id in msg.notified_partner_ids.ids:
|
|
return True
|
|
# Partner is mentioned in partner_ids
|
|
if partner.id in msg.partner_ids.ids:
|
|
return True
|
|
return False
|
|
|
|
filtered_messages = all_messages.filtered(is_relevant_message)
|
|
|
|
values = {
|
|
'order': order,
|
|
'documents': documents,
|
|
'messages': filtered_messages,
|
|
'page_name': 'authorizer_case_detail',
|
|
}
|
|
|
|
return request.render('fusion_authorizer_portal.portal_authorizer_case_detail', values)
|
|
|
|
@http.route('/my/authorizer/case/<int:order_id>/comment', type='http', auth='user', website=True, methods=['POST'])
|
|
def authorizer_add_comment(self, order_id, comment='', **kw):
|
|
"""Add a comment to a case - posts to sale order chatter and emails salesperson"""
|
|
partner = request.env.user.partner_id
|
|
|
|
if not partner.is_authorizer:
|
|
return request.redirect('/my')
|
|
|
|
if not comment.strip():
|
|
return request.redirect(f'/my/authorizer/case/{order_id}')
|
|
|
|
try:
|
|
order = request.env['sale.order'].sudo().browse(order_id)
|
|
if not order.exists() or order.x_fc_authorizer_id.id != partner.id:
|
|
raise AccessError(_('You do not have access to this case.'))
|
|
|
|
# Post message to sale order chatter (internal note, not to all followers)
|
|
message = order.message_post(
|
|
body=comment.strip(),
|
|
message_type='comment',
|
|
subtype_xmlid='mail.mt_note', # Internal note - doesn't notify followers
|
|
author_id=partner.id,
|
|
)
|
|
|
|
# Send email notification to the salesperson
|
|
if order.user_id and order.user_id.partner_id:
|
|
from markupsafe import Markup
|
|
salesperson_partner = order.user_id.partner_id
|
|
order.message_notify(
|
|
partner_ids=[salesperson_partner.id],
|
|
body=Markup(f"<p><strong>New message from Authorizer {partner.name}:</strong></p><p>{comment.strip()}</p>"),
|
|
subject=f"[{order.name}] New message from Authorizer",
|
|
author_id=partner.id,
|
|
)
|
|
|
|
# Also save to fusion.authorizer.comment for portal display
|
|
if 'fusion.authorizer.comment' in request.env:
|
|
request.env['fusion.authorizer.comment'].sudo().create({
|
|
'sale_order_id': order_id,
|
|
'author_id': partner.id,
|
|
'comment': comment.strip(),
|
|
'comment_type': 'general',
|
|
})
|
|
|
|
except Exception as e:
|
|
_logger.error(f"Error adding comment: {e}")
|
|
|
|
return request.redirect(f'/my/authorizer/case/{order_id}')
|
|
|
|
@http.route('/my/authorizer/case/<int:order_id>/upload', type='http', auth='user', website=True, methods=['POST'], csrf=True)
|
|
def authorizer_upload_document(self, order_id, document_type='full_application', document_file=None, revision_note='', **kw):
|
|
"""Upload a document for a case"""
|
|
partner = request.env.user.partner_id
|
|
|
|
if not partner.is_authorizer:
|
|
return request.redirect('/my')
|
|
|
|
if not document_file or not document_file.filename:
|
|
return request.redirect(f'/my/authorizer/case/{order_id}')
|
|
|
|
try:
|
|
order = request.env['sale.order'].sudo().browse(order_id)
|
|
if not order.exists() or order.x_fc_authorizer_id.id != partner.id:
|
|
raise AccessError(_('You do not have access to this case.'))
|
|
|
|
# Don't allow authorizers to upload 'submitted_final'
|
|
if document_type == 'submitted_final':
|
|
document_type = 'full_application'
|
|
|
|
file_content = document_file.read()
|
|
file_base64 = base64.b64encode(file_content)
|
|
|
|
request.env['fusion.adp.document'].sudo().create({
|
|
'sale_order_id': order_id,
|
|
'document_type': document_type,
|
|
'file': file_base64,
|
|
'filename': document_file.filename,
|
|
'revision_note': revision_note,
|
|
'source': 'authorizer',
|
|
})
|
|
|
|
except Exception as e:
|
|
_logger.error(f"Error uploading document: {e}")
|
|
|
|
return request.redirect(f'/my/authorizer/case/{order_id}')
|
|
|
|
@http.route('/my/authorizer/document/<int:doc_id>/download', type='http', auth='user')
|
|
def authorizer_download_document(self, doc_id, **kw):
|
|
"""Download a document"""
|
|
partner = request.env.user.partner_id
|
|
|
|
if not partner.is_authorizer and not partner.is_sales_rep_portal:
|
|
return request.redirect('/my')
|
|
|
|
try:
|
|
document = request.env['fusion.adp.document'].sudo().browse(doc_id)
|
|
if not document.exists():
|
|
raise MissingError(_('Document not found.'))
|
|
|
|
# Verify access
|
|
if document.sale_order_id:
|
|
order = document.sale_order_id
|
|
has_access = (
|
|
(partner.is_authorizer and order.x_fc_authorizer_id.id == partner.id) or
|
|
(partner.is_sales_rep_portal and order.user_id.id == request.env.user.id)
|
|
)
|
|
if not has_access:
|
|
raise AccessError(_('You do not have access to this document.'))
|
|
|
|
file_content = base64.b64decode(document.file)
|
|
|
|
# Check if viewing inline or downloading
|
|
view_inline = kw.get('view', '0') == '1'
|
|
disposition = 'inline' if view_inline else 'attachment'
|
|
|
|
return request.make_response(
|
|
file_content,
|
|
headers=[
|
|
('Content-Type', document.mimetype or 'application/octet-stream'),
|
|
('Content-Disposition', f'{disposition}; filename="{document.filename}"'),
|
|
('Content-Length', len(file_content)),
|
|
]
|
|
)
|
|
|
|
except Exception as e:
|
|
_logger.error(f"Error downloading document: {e}")
|
|
return request.redirect('/my')
|
|
|
|
@http.route(['/my/authorizer/case/<int:order_id>/attachment/<string:attachment_type>',
|
|
'/my/sales/case/<int:order_id>/attachment/<string:attachment_type>'], type='http', auth='user')
|
|
def authorizer_download_attachment(self, order_id, attachment_type, **kw):
|
|
"""Download an attachment from sale order (original application, xml, proof of delivery)"""
|
|
partner = request.env.user.partner_id
|
|
|
|
if not partner.is_authorizer and not partner.is_sales_rep_portal:
|
|
return request.redirect('/my')
|
|
|
|
try:
|
|
order = request.env['sale.order'].sudo().browse(order_id)
|
|
if not order.exists():
|
|
raise MissingError(_('Order not found.'))
|
|
|
|
# Verify access
|
|
has_access = (
|
|
(partner.is_authorizer and order.x_fc_authorizer_id.id == partner.id) or
|
|
(partner.is_sales_rep_portal and order.user_id.id == request.env.user.id)
|
|
)
|
|
if not has_access:
|
|
raise AccessError(_('You do not have access to this order.'))
|
|
|
|
# Get the attachment based on type
|
|
attachment_map = {
|
|
'original_application': ('x_fc_original_application', 'x_fc_original_application_filename', 'application/pdf', 'original_application.pdf'),
|
|
'final_application': ('x_fc_final_submitted_application', 'x_fc_final_application_filename', 'application/pdf', 'final_application.pdf'),
|
|
'xml_file': ('x_fc_xml_file', 'x_fc_xml_filename', 'application/xml', 'application.xml'),
|
|
'proof_of_delivery': ('x_fc_proof_of_delivery', 'x_fc_proof_of_delivery_filename', 'application/pdf', 'proof_of_delivery.pdf'),
|
|
}
|
|
|
|
if attachment_type not in attachment_map:
|
|
raise MissingError(_('Invalid attachment type.'))
|
|
|
|
field_name, filename_field, default_mimetype, default_filename = attachment_map[attachment_type]
|
|
|
|
if not hasattr(order, field_name) or not getattr(order, field_name):
|
|
raise MissingError(_('Attachment not found.'))
|
|
|
|
file_content = base64.b64decode(getattr(order, field_name))
|
|
filename = getattr(order, filename_field, None) or default_filename
|
|
|
|
# Check if viewing inline or downloading
|
|
view_inline = kw.get('view', '0') == '1'
|
|
disposition = 'inline' if view_inline else 'attachment'
|
|
|
|
return request.make_response(
|
|
file_content,
|
|
headers=[
|
|
('Content-Type', default_mimetype),
|
|
('Content-Disposition', f'{disposition}; filename="{filename}"'),
|
|
('Content-Length', len(file_content)),
|
|
]
|
|
)
|
|
|
|
except Exception as e:
|
|
_logger.error(f"Error downloading attachment: {e}")
|
|
return request.redirect('/my')
|
|
|
|
@http.route(['/my/authorizer/case/<int:order_id>/photo/<int:photo_id>',
|
|
'/my/sales/case/<int:order_id>/photo/<int:photo_id>'], type='http', auth='user')
|
|
def authorizer_view_photo(self, order_id, photo_id, **kw):
|
|
"""View an approval photo"""
|
|
partner = request.env.user.partner_id
|
|
|
|
if not partner.is_authorizer and not partner.is_sales_rep_portal:
|
|
return request.redirect('/my')
|
|
|
|
try:
|
|
order = request.env['sale.order'].sudo().browse(order_id)
|
|
if not order.exists():
|
|
raise MissingError(_('Order not found.'))
|
|
|
|
# Verify access
|
|
has_access = (
|
|
(partner.is_authorizer and order.x_fc_authorizer_id.id == partner.id) or
|
|
(partner.is_sales_rep_portal and order.user_id.id == request.env.user.id)
|
|
)
|
|
if not has_access:
|
|
raise AccessError(_('You do not have access to this order.'))
|
|
|
|
# Find the photo attachment
|
|
attachment = request.env['ir.attachment'].sudo().browse(photo_id)
|
|
if not attachment.exists() or attachment.id not in order.x_fc_approval_photo_ids.ids:
|
|
raise MissingError(_('Photo not found.'))
|
|
|
|
file_content = base64.b64decode(attachment.datas)
|
|
|
|
return request.make_response(
|
|
file_content,
|
|
headers=[
|
|
('Content-Type', attachment.mimetype or 'image/png'),
|
|
('Content-Disposition', f'inline; filename="{attachment.name}"'),
|
|
('Content-Length', len(file_content)),
|
|
]
|
|
)
|
|
|
|
except Exception as e:
|
|
_logger.error(f"Error viewing photo: {e}")
|
|
return request.redirect('/my')
|
|
|
|
# ==================== SALES REP PORTAL ====================
|
|
|
|
@http.route(['/my/sales', '/my/sales/dashboard'], type='http', auth='user', website=True)
|
|
def sales_rep_dashboard(self, search='', sale_type='', status='', **kw):
|
|
"""Sales rep dashboard with search and filters"""
|
|
partner = request.env.user.partner_id
|
|
user = request.env.user
|
|
|
|
if not partner.is_sales_rep_portal:
|
|
return request.redirect('/my')
|
|
|
|
SaleOrder = request.env['sale.order'].sudo()
|
|
Assessment = request.env['fusion.assessment'].sudo()
|
|
|
|
# Get case counts by status (unfiltered for stats)
|
|
base_domain = [('user_id', '=', user.id)]
|
|
|
|
draft_count = SaleOrder.search_count(base_domain + [('state', '=', 'draft')])
|
|
sent_count = SaleOrder.search_count(base_domain + [('state', '=', 'sent')])
|
|
sale_count = SaleOrder.search_count(base_domain + [('state', '=', 'sale')])
|
|
total_count = SaleOrder.search_count(base_domain)
|
|
|
|
# Build filtered domain for recent cases
|
|
filtered_domain = base_domain.copy()
|
|
|
|
# Apply search filter
|
|
if search:
|
|
search = search.strip()
|
|
filtered_domain += [
|
|
'|', '|', '|',
|
|
('name', 'ilike', search),
|
|
('partner_id.name', 'ilike', search),
|
|
('x_fc_claim_number', 'ilike', search),
|
|
('partner_id.email', 'ilike', search),
|
|
]
|
|
|
|
# Apply sale type filter
|
|
if sale_type:
|
|
filtered_domain += [('x_fc_sale_type', '=', sale_type)]
|
|
|
|
# Apply status filter
|
|
if status:
|
|
filtered_domain += [('state', '=', status)]
|
|
|
|
# Recent cases (filtered)
|
|
recent_cases = SaleOrder.search(filtered_domain, limit=20, order='date_order desc')
|
|
|
|
# Assessments
|
|
assessment_domain = [('sales_rep_id', '=', user.id)]
|
|
pending_assessments = Assessment.search(
|
|
assessment_domain + [('state', 'in', ['draft', 'pending_signature'])],
|
|
limit=5, order='assessment_date desc'
|
|
)
|
|
completed_assessments_count = Assessment.search_count(
|
|
assessment_domain + [('state', '=', 'completed')]
|
|
)
|
|
|
|
values = {
|
|
'partner': partner,
|
|
'draft_count': draft_count,
|
|
'sent_count': sent_count,
|
|
'sale_count': sale_count,
|
|
'total_count': total_count,
|
|
'recent_cases': recent_cases,
|
|
'pending_assessments': pending_assessments,
|
|
'completed_assessments_count': completed_assessments_count,
|
|
'page_name': 'sales_dashboard',
|
|
# Search and filter values
|
|
'search': search,
|
|
'sale_type_filter': sale_type,
|
|
'status_filter': status,
|
|
}
|
|
values.update(self._get_clock_status_data())
|
|
return request.render('fusion_authorizer_portal.portal_sales_dashboard', values)
|
|
|
|
@http.route(['/my/sales/cases', '/my/sales/cases/page/<int:page>'], type='http', auth='user', website=True)
|
|
def sales_rep_cases(self, page=1, search='', sortby='date', **kw):
|
|
"""List of cases for the sales rep"""
|
|
partner = request.env.user.partner_id
|
|
user = request.env.user
|
|
|
|
if not partner.is_sales_rep_portal:
|
|
return request.redirect('/my')
|
|
|
|
SaleOrder = request.env['sale.order'].sudo()
|
|
|
|
# Build domain
|
|
from odoo.osv import expression
|
|
domain = [('user_id', '=', user.id)]
|
|
|
|
# Add search filter
|
|
if search:
|
|
search_domain = [
|
|
'|', '|', '|', '|',
|
|
('partner_id.name', 'ilike', search),
|
|
('name', 'ilike', search),
|
|
('x_fc_claim_number', 'ilike', search),
|
|
('x_fc_client_ref_1', 'ilike', search),
|
|
('x_fc_client_ref_2', 'ilike', search),
|
|
]
|
|
domain = expression.AND([domain, search_domain])
|
|
|
|
# Sorting
|
|
sortings = {
|
|
'date': {'label': _('Date'), 'order': 'date_order desc'},
|
|
'name': {'label': _('Reference'), 'order': 'name'},
|
|
'client': {'label': _('Client'), 'order': 'partner_id'},
|
|
'state': {'label': _('Status'), 'order': 'state'},
|
|
}
|
|
order = sortings.get(sortby, sortings['date'])['order']
|
|
|
|
# Pager
|
|
case_count = SaleOrder.search_count(domain)
|
|
pager = portal_pager(
|
|
url='/my/sales/cases',
|
|
url_args={'search': search, 'sortby': sortby},
|
|
total=case_count,
|
|
page=page,
|
|
step=20,
|
|
)
|
|
|
|
# Get cases
|
|
cases = SaleOrder.search(domain, order=order, limit=20, offset=pager['offset'])
|
|
|
|
values = {
|
|
'cases': cases,
|
|
'pager': pager,
|
|
'search': search,
|
|
'sortby': sortby,
|
|
'sortings': sortings,
|
|
'page_name': 'sales_cases',
|
|
}
|
|
|
|
return request.render('fusion_authorizer_portal.portal_sales_cases', values)
|
|
|
|
@http.route('/my/sales/cases/search', type='jsonrpc', auth='user')
|
|
def sales_rep_cases_search(self, query='', **kw):
|
|
"""AJAX search endpoint for sales rep real-time search"""
|
|
partner = request.env.user.partner_id
|
|
user = request.env.user
|
|
|
|
if not partner.is_sales_rep_portal:
|
|
return {'error': 'Access denied', 'results': []}
|
|
|
|
if len(query) < 2:
|
|
return {'results': []}
|
|
|
|
SaleOrder = request.env['sale.order'].sudo()
|
|
orders = SaleOrder.get_sales_rep_portal_cases(user.id, search_query=query, limit=50)
|
|
|
|
results = []
|
|
for order in orders:
|
|
results.append({
|
|
'id': order.id,
|
|
'name': order.name,
|
|
'partner_name': order.partner_id.name if order.partner_id else '',
|
|
'date_order': order.date_order.strftime('%Y-%m-%d') if order.date_order else '',
|
|
'state': order.state,
|
|
'state_display': dict(order._fields['state'].selection).get(order.state, order.state),
|
|
'claim_number': getattr(order, 'x_fc_claim_number', '') or '',
|
|
'client_ref_1': order.x_fc_client_ref_1 or '',
|
|
'client_ref_2': order.x_fc_client_ref_2 or '',
|
|
'url': f'/my/sales/case/{order.id}',
|
|
})
|
|
|
|
return {'results': results}
|
|
|
|
@http.route('/my/sales/case/<int:order_id>', type='http', auth='user', website=True)
|
|
def sales_rep_case_detail(self, order_id, **kw):
|
|
"""View a specific case for sales rep"""
|
|
partner = request.env.user.partner_id
|
|
user = request.env.user
|
|
|
|
if not partner.is_sales_rep_portal:
|
|
return request.redirect('/my')
|
|
|
|
try:
|
|
order = request.env['sale.order'].sudo().browse(order_id)
|
|
if not order.exists() or order.user_id.id != user.id:
|
|
raise AccessError(_('You do not have access to this case.'))
|
|
except (AccessError, MissingError):
|
|
return request.redirect('/my/sales/cases')
|
|
|
|
# Get documents
|
|
documents = request.env['fusion.adp.document'].sudo().search([
|
|
('sale_order_id', '=', order_id),
|
|
('is_current', '=', True),
|
|
])
|
|
|
|
# Get messages from chatter - only those relevant to this user
|
|
all_messages = request.env['mail.message'].sudo().search([
|
|
('model', '=', 'sale.order'),
|
|
('res_id', '=', order_id),
|
|
('message_type', 'in', ['comment', 'notification']),
|
|
('body', '!=', ''),
|
|
('body', '!=', '<p><br></p>'),
|
|
], order='date desc', limit=100)
|
|
|
|
# Filter to only show messages relevant to this partner:
|
|
# 1. Messages authored by this partner
|
|
# 2. Messages where this partner is in notified_partner_ids
|
|
# 3. Messages where this partner is mentioned (partner_ids)
|
|
def is_relevant_message(msg):
|
|
if not msg.body or len(msg.body.strip()) == 0 or '<p><br></p>' in msg.body:
|
|
return False
|
|
# Authored by current partner
|
|
if msg.author_id.id == partner.id:
|
|
return True
|
|
# Partner is in notified partners
|
|
if partner.id in msg.notified_partner_ids.ids:
|
|
return True
|
|
# Partner is mentioned in partner_ids
|
|
if partner.id in msg.partner_ids.ids:
|
|
return True
|
|
return False
|
|
|
|
filtered_messages = all_messages.filtered(is_relevant_message)
|
|
|
|
values = {
|
|
'order': order,
|
|
'documents': documents,
|
|
'messages': filtered_messages,
|
|
'page_name': 'sales_case_detail',
|
|
}
|
|
|
|
return request.render('fusion_authorizer_portal.portal_sales_case_detail', values)
|
|
|
|
@http.route('/my/sales/case/<int:order_id>/comment', type='http', auth='user', website=True, methods=['POST'])
|
|
def sales_rep_add_comment(self, order_id, comment='', **kw):
|
|
"""Add a comment to a case (sales rep) - posts to sale order chatter and emails authorizer"""
|
|
partner = request.env.user.partner_id
|
|
user = request.env.user
|
|
|
|
if not partner.is_sales_rep_portal:
|
|
return request.redirect('/my')
|
|
|
|
if not comment.strip():
|
|
return request.redirect(f'/my/sales/case/{order_id}')
|
|
|
|
try:
|
|
order = request.env['sale.order'].sudo().browse(order_id)
|
|
if not order.exists() or order.user_id.id != user.id:
|
|
raise AccessError(_('You do not have access to this case.'))
|
|
|
|
# Post message to sale order chatter (internal note, not to all followers)
|
|
message = order.message_post(
|
|
body=comment.strip(),
|
|
message_type='comment',
|
|
subtype_xmlid='mail.mt_note', # Internal note - doesn't notify followers
|
|
author_id=partner.id,
|
|
)
|
|
|
|
# Send email notification to the authorizer
|
|
if order.x_fc_authorizer_id:
|
|
from markupsafe import Markup
|
|
order.message_notify(
|
|
partner_ids=[order.x_fc_authorizer_id.id],
|
|
body=Markup(f"<p><strong>New message from Sales Rep {partner.name}:</strong></p><p>{comment.strip()}</p>"),
|
|
subject=f"[{order.name}] New message from Sales Rep",
|
|
author_id=partner.id,
|
|
)
|
|
|
|
# Also save to fusion.authorizer.comment for portal display
|
|
if 'fusion.authorizer.comment' in request.env:
|
|
request.env['fusion.authorizer.comment'].sudo().create({
|
|
'sale_order_id': order_id,
|
|
'author_id': partner.id,
|
|
'comment': comment.strip(),
|
|
'comment_type': 'general',
|
|
})
|
|
|
|
except Exception as e:
|
|
_logger.error(f"Error adding comment: {e}")
|
|
|
|
return request.redirect(f'/my/sales/case/{order_id}')
|
|
|
|
# ==================== CLIENT FUNDING CLAIMS PORTAL ====================
|
|
|
|
def _prepare_home_portal_values(self, counters):
|
|
"""Add client funding claims count to portal home"""
|
|
values = super()._prepare_home_portal_values(counters)
|
|
partner = request.env.user.partner_id
|
|
|
|
if 'funding_claims_count' in counters:
|
|
# Count sale orders where partner is the customer
|
|
values['funding_claims_count'] = request.env['sale.order'].sudo().search_count([
|
|
('partner_id', '=', partner.id),
|
|
('x_fc_sale_type', 'in', ['adp', 'adp_odsp', 'odsp', 'march_of_dimes']),
|
|
])
|
|
|
|
return values
|
|
|
|
@http.route(['/my/funding-claims', '/my/funding-claims/page/<int:page>'], type='http', auth='user', website=True)
|
|
def client_funding_claims(self, page=1, sortby='date', **kw):
|
|
"""List of funding claims for the client"""
|
|
partner = request.env.user.partner_id
|
|
SaleOrder = request.env['sale.order'].sudo()
|
|
|
|
# Build domain - orders where partner is the customer
|
|
domain = [
|
|
('partner_id', '=', partner.id),
|
|
('x_fc_sale_type', 'in', ['adp', 'adp_odsp', 'odsp', 'march_of_dimes']),
|
|
]
|
|
|
|
# Sorting
|
|
sortings = {
|
|
'date': {'label': _('Date'), 'order': 'date_order desc'},
|
|
'name': {'label': _('Reference'), 'order': 'name'},
|
|
'status': {'label': _('Status'), 'order': 'x_fc_adp_application_status'},
|
|
}
|
|
order = sortings.get(sortby, sortings['date'])['order']
|
|
|
|
# Pager
|
|
claim_count = SaleOrder.search_count(domain)
|
|
pager = portal_pager(
|
|
url='/my/funding-claims',
|
|
url_args={'sortby': sortby},
|
|
total=claim_count,
|
|
page=page,
|
|
step=20,
|
|
)
|
|
|
|
# Get claims
|
|
claims = SaleOrder.search(domain, order=order, limit=20, offset=pager['offset'])
|
|
|
|
values = {
|
|
'claims': claims,
|
|
'pager': pager,
|
|
'sortby': sortby,
|
|
'sortings': sortings,
|
|
'page_name': 'funding_claims',
|
|
}
|
|
|
|
return request.render('fusion_authorizer_portal.portal_client_claims', values)
|
|
|
|
@http.route('/my/funding-claims/<int:order_id>', type='http', auth='user', website=True)
|
|
def client_funding_claim_detail(self, order_id, **kw):
|
|
"""View a specific funding claim"""
|
|
partner = request.env.user.partner_id
|
|
|
|
try:
|
|
order = request.env['sale.order'].sudo().browse(order_id)
|
|
if not order.exists() or order.partner_id.id != partner.id:
|
|
raise AccessError(_('You do not have access to this claim.'))
|
|
except (AccessError, MissingError):
|
|
return request.redirect('/my/funding-claims')
|
|
|
|
# Check if case is closed - documents only visible after case closed
|
|
is_case_closed = order.x_fc_adp_application_status == 'case_closed'
|
|
|
|
# Get documents (only if case is closed)
|
|
documents = []
|
|
if is_case_closed:
|
|
documents = request.env['fusion.adp.document'].sudo().search([
|
|
('sale_order_id', '=', order_id),
|
|
('is_current', '=', True),
|
|
('document_type', 'in', ['submitted_final', 'pages_11_12']),
|
|
])
|
|
|
|
# Get invoices
|
|
invoices = order.invoice_ids.filtered(lambda inv: inv.state != 'cancel')
|
|
|
|
values = {
|
|
'order': order,
|
|
'is_case_closed': is_case_closed,
|
|
'documents': documents,
|
|
'invoices': invoices,
|
|
'page_name': 'funding_claim_detail',
|
|
}
|
|
|
|
return request.render('fusion_authorizer_portal.portal_client_claim_detail', values)
|
|
|
|
@http.route('/my/funding-claims/<int:order_id>/document/<int:doc_id>/download', type='http', auth='user')
|
|
def client_download_document(self, order_id, doc_id, **kw):
|
|
"""Download a document from a funding claim"""
|
|
partner = request.env.user.partner_id
|
|
|
|
try:
|
|
order = request.env['sale.order'].sudo().browse(order_id)
|
|
if not order.exists() or order.partner_id.id != partner.id:
|
|
raise AccessError(_('You do not have access to this claim.'))
|
|
|
|
# Check if case is closed
|
|
if order.x_fc_adp_application_status != 'case_closed':
|
|
raise AccessError(_('Documents are only available after the case is closed.'))
|
|
|
|
document = request.env['fusion.adp.document'].sudo().browse(doc_id)
|
|
if not document.exists() or document.sale_order_id.id != order_id:
|
|
raise MissingError(_('Document not found.'))
|
|
|
|
file_content = base64.b64decode(document.file)
|
|
|
|
return request.make_response(
|
|
file_content,
|
|
headers=[
|
|
('Content-Type', document.mimetype or 'application/octet-stream'),
|
|
('Content-Disposition', f'attachment; filename="{document.filename}"'),
|
|
('Content-Length', len(file_content)),
|
|
]
|
|
)
|
|
|
|
except Exception as e:
|
|
_logger.error(f"Error downloading document: {e}")
|
|
return request.redirect('/my/funding-claims')
|
|
|
|
@http.route('/my/funding-claims/<int:order_id>/proof-of-delivery', type='http', auth='user')
|
|
def client_download_proof_of_delivery(self, order_id, **kw):
|
|
"""Download proof of delivery from a funding claim"""
|
|
partner = request.env.user.partner_id
|
|
|
|
try:
|
|
order = request.env['sale.order'].sudo().browse(order_id)
|
|
if not order.exists() or order.partner_id.id != partner.id:
|
|
raise AccessError(_('You do not have access to this claim.'))
|
|
|
|
# Check if case is closed
|
|
if order.x_fc_adp_application_status != 'case_closed':
|
|
raise AccessError(_('Documents are only available after the case is closed.'))
|
|
|
|
if not order.x_fc_proof_of_delivery:
|
|
raise MissingError(_('Proof of delivery not found.'))
|
|
|
|
file_content = base64.b64decode(order.x_fc_proof_of_delivery)
|
|
filename = order.x_fc_proof_of_delivery_filename or 'proof_of_delivery.pdf'
|
|
|
|
return request.make_response(
|
|
file_content,
|
|
headers=[
|
|
('Content-Type', 'application/pdf'),
|
|
('Content-Disposition', f'attachment; filename="{filename}"'),
|
|
('Content-Length', len(file_content)),
|
|
]
|
|
)
|
|
|
|
except Exception as e:
|
|
_logger.error(f"Error downloading proof of delivery: {e}")
|
|
return request.redirect('/my/funding-claims')
|
|
|
|
# ==================== CLOCK STATUS HELPER ====================
|
|
|
|
def _get_clock_status_data(self):
|
|
"""Get clock in/out status for the current portal user."""
|
|
try:
|
|
user = request.env.user
|
|
Employee = request.env['hr.employee'].sudo()
|
|
employee = Employee.search([('user_id', '=', user.id)], limit=1)
|
|
if not employee:
|
|
employee = Employee.search([
|
|
('name', '=', user.partner_id.name),
|
|
('user_id', '=', False),
|
|
], limit=1)
|
|
if not employee or not getattr(employee, 'x_fclk_enable_clock', False):
|
|
return {'clock_enabled': False}
|
|
|
|
is_checked_in = employee.attendance_state == 'checked_in'
|
|
check_in_time = ''
|
|
location_name = ''
|
|
if is_checked_in:
|
|
att = request.env['hr.attendance'].sudo().search([
|
|
('employee_id', '=', employee.id),
|
|
('check_out', '=', False),
|
|
], limit=1)
|
|
if att:
|
|
check_in_time = att.check_in.isoformat() if att.check_in else ''
|
|
location_name = att.x_fclk_location_id.name if att.x_fclk_location_id else ''
|
|
|
|
return {
|
|
'clock_enabled': True,
|
|
'clock_checked_in': is_checked_in,
|
|
'clock_check_in_time': check_in_time,
|
|
'clock_location_name': location_name,
|
|
}
|
|
except Exception as e:
|
|
_logger.warning("Clock status check failed: %s", e)
|
|
return {'clock_enabled': False}
|
|
|
|
# ==================== TECHNICIAN PORTAL ====================
|
|
|
|
def _check_technician_access(self):
|
|
"""Check if current user is a technician portal user."""
|
|
partner = request.env.user.partner_id
|
|
if partner.is_technician_portal:
|
|
return True
|
|
has_tasks = request.env['fusion.technician.task'].sudo().search_count([
|
|
'|',
|
|
('technician_id', '=', request.env.user.id),
|
|
('additional_technician_ids', 'in', [request.env.user.id]),
|
|
], limit=1)
|
|
if has_tasks:
|
|
partner.sudo().write({'is_technician_portal': True})
|
|
return True
|
|
return False
|
|
|
|
@http.route(['/my/technician', '/my/technician/dashboard'], type='http', auth='user', website=True)
|
|
def technician_dashboard(self, **kw):
|
|
"""Technician dashboard - today's schedule with timeline."""
|
|
if not self._check_technician_access():
|
|
return request.redirect('/my')
|
|
|
|
partner = request.env.user.partner_id
|
|
user = request.env.user
|
|
Task = request.env['fusion.technician.task'].sudo()
|
|
SaleOrder = request.env['sale.order'].sudo()
|
|
today = fields.Date.context_today(request.env['fusion.technician.task'])
|
|
|
|
# Today's tasks (lead or additional technician)
|
|
today_tasks = Task.search([
|
|
'|',
|
|
('technician_id', '=', user.id),
|
|
('additional_technician_ids', 'in', [user.id]),
|
|
('scheduled_date', '=', today),
|
|
('status', '!=', 'cancelled'),
|
|
], order='sequence, time_start, id')
|
|
|
|
# Current in-progress task
|
|
current_task = today_tasks.filtered(lambda t: t.status == 'in_progress')[:1]
|
|
|
|
# Next upcoming task (first scheduled/en_route today)
|
|
next_task = today_tasks.filtered(lambda t: t.status in ('scheduled', 'en_route'))[:1]
|
|
|
|
# Stats
|
|
completed_today = len(today_tasks.filtered(lambda t: t.status == 'completed'))
|
|
remaining_today = len(today_tasks.filtered(lambda t: t.status in ('scheduled', 'en_route', 'in_progress')))
|
|
total_today = len(today_tasks)
|
|
|
|
# Total travel time for the day
|
|
total_travel = sum(today_tasks.mapped('travel_time_minutes'))
|
|
|
|
# Legacy: deliveries assigned (for backward compat with existing delivery views)
|
|
delivery_domain = [('x_fc_delivery_technician_ids', 'in', [user.id])]
|
|
pending_pod_count = SaleOrder.search_count(delivery_domain + [
|
|
('x_fc_pod_signature', '=', False),
|
|
('x_fc_adp_application_status', '=', 'ready_delivery'),
|
|
])
|
|
|
|
# Tomorrow's task count
|
|
from datetime import timedelta
|
|
tomorrow = today + timedelta(days=1)
|
|
tomorrow_count = Task.search_count([
|
|
'|',
|
|
('technician_id', '=', user.id),
|
|
('additional_technician_ids', 'in', [user.id]),
|
|
('scheduled_date', '=', tomorrow),
|
|
('status', '!=', 'cancelled'),
|
|
])
|
|
|
|
# Technician's personal start address
|
|
start_address = user.sudo().x_fc_start_address or ''
|
|
|
|
# Google Maps API key for Places autocomplete
|
|
ICP = request.env['ir.config_parameter'].sudo()
|
|
google_maps_api_key = ICP.get_param('fusion_claims.google_maps_api_key', '')
|
|
|
|
clock_data = self._get_clock_status_data()
|
|
|
|
values = {
|
|
'today_tasks': today_tasks,
|
|
'current_task': current_task,
|
|
'next_task': next_task,
|
|
'completed_today': completed_today,
|
|
'remaining_today': remaining_today,
|
|
'total_today': total_today,
|
|
'total_travel': total_travel,
|
|
'pending_pod_count': pending_pod_count,
|
|
'tomorrow_count': tomorrow_count,
|
|
'today_date': today,
|
|
'start_address': start_address,
|
|
'google_maps_api_key': google_maps_api_key,
|
|
'page_name': 'technician_dashboard',
|
|
}
|
|
values.update(clock_data)
|
|
return request.render('fusion_authorizer_portal.portal_technician_dashboard', values)
|
|
|
|
@http.route(['/my/technician/tasks', '/my/technician/tasks/page/<int:page>'], type='http', auth='user', website=True)
|
|
def technician_tasks(self, page=1, search='', filter_status='all', filter_date='', **kw):
|
|
"""List of all tasks for the technician."""
|
|
if not self._check_technician_access():
|
|
return request.redirect('/my')
|
|
|
|
user = request.env.user
|
|
Task = request.env['fusion.technician.task'].sudo()
|
|
|
|
domain = ['|', ('technician_id', '=', user.id), ('additional_technician_ids', 'in', [user.id])]
|
|
|
|
if filter_status == 'scheduled':
|
|
domain.append(('status', '=', 'scheduled'))
|
|
elif filter_status == 'in_progress':
|
|
domain.append(('status', 'in', ('en_route', 'in_progress')))
|
|
elif filter_status == 'completed':
|
|
domain.append(('status', '=', 'completed'))
|
|
elif filter_status == 'active':
|
|
domain.append(('status', 'not in', ('cancelled', 'completed')))
|
|
# Default: show all
|
|
|
|
if filter_date:
|
|
domain.append(('scheduled_date', '=', filter_date))
|
|
|
|
if search:
|
|
domain += ['|', '|', '|',
|
|
('name', 'ilike', search),
|
|
('partner_id.name', 'ilike', search),
|
|
('address_city', 'ilike', search),
|
|
('sale_order_id.name', 'ilike', search),
|
|
]
|
|
|
|
task_count = Task.search_count(domain)
|
|
pager = portal_pager(
|
|
url='/my/technician/tasks',
|
|
url_args={'search': search, 'filter_status': filter_status, 'filter_date': filter_date},
|
|
total=task_count,
|
|
page=page,
|
|
step=20,
|
|
)
|
|
|
|
tasks = Task.search(domain, limit=20, offset=pager['offset'],
|
|
order='scheduled_date desc, sequence, time_start')
|
|
|
|
values = {
|
|
'tasks': tasks,
|
|
'pager': pager,
|
|
'search': search,
|
|
'filter_status': filter_status,
|
|
'filter_date': filter_date,
|
|
'page_name': 'technician_tasks',
|
|
}
|
|
return request.render('fusion_authorizer_portal.portal_technician_tasks', values)
|
|
|
|
@http.route('/my/technician/task/<int:task_id>', type='http', auth='user', website=True)
|
|
def technician_task_detail(self, task_id, **kw):
|
|
"""View a specific technician task."""
|
|
if not self._check_technician_access():
|
|
return request.redirect('/my')
|
|
|
|
user = request.env.user
|
|
Task = request.env['fusion.technician.task'].sudo()
|
|
|
|
try:
|
|
task = Task.browse(task_id)
|
|
if not task.exists() or (
|
|
task.technician_id.id != user.id
|
|
and user.id not in task.additional_technician_ids.ids
|
|
):
|
|
raise AccessError(_('You do not have access to this task.'))
|
|
except (AccessError, MissingError):
|
|
return request.redirect('/my/technician/tasks')
|
|
|
|
# Check for earlier uncompleted tasks (sequential enforcement)
|
|
earlier_incomplete = Task.search([
|
|
'|',
|
|
('technician_id', '=', user.id),
|
|
('additional_technician_ids', 'in', [user.id]),
|
|
('scheduled_date', '=', task.scheduled_date),
|
|
('time_start', '<', task.time_start),
|
|
('status', 'not in', ['completed', 'cancelled']),
|
|
('id', '!=', task.id),
|
|
], order='time_start', limit=1)
|
|
|
|
# Get order lines if linked to a sale order
|
|
order_lines = []
|
|
if task.sale_order_id:
|
|
order_lines = task.sale_order_id.order_line.filtered(lambda l: not l.display_type)
|
|
|
|
# Get VAPID public key for push notifications
|
|
vapid_public = request.env['ir.config_parameter'].sudo().get_param(
|
|
'fusion_claims.vapid_public_key', ''
|
|
)
|
|
|
|
values = {
|
|
'task': task,
|
|
'order_lines': order_lines,
|
|
'vapid_public_key': vapid_public,
|
|
'page_name': 'technician_task_detail',
|
|
'earlier_incomplete': earlier_incomplete,
|
|
}
|
|
return request.render('fusion_authorizer_portal.portal_technician_task_detail', values)
|
|
|
|
@http.route('/my/technician/task/<int:task_id>/add-notes', type='json', auth='user', website=True)
|
|
def technician_task_add_notes(self, task_id, notes, photos=None, **kw):
|
|
"""Add notes (and optional photos) to a completed task.
|
|
|
|
:param notes: text content of the note
|
|
:param photos: list of dicts with 'data' (base64 data-url) and 'name'
|
|
"""
|
|
if not self._check_technician_access():
|
|
return {'success': False, 'error': 'Access denied'}
|
|
user = request.env.user
|
|
Task = request.env['fusion.technician.task'].sudo()
|
|
Attachment = request.env['ir.attachment'].sudo()
|
|
try:
|
|
task = Task.browse(task_id)
|
|
if not task.exists() or (
|
|
task.technician_id.id != user.id
|
|
and user.id not in task.additional_technician_ids.ids
|
|
):
|
|
return {'success': False, 'error': 'Task not found'}
|
|
|
|
from markupsafe import Markup, escape
|
|
import re
|
|
|
|
# ----------------------------------------------------------
|
|
# Process photos -> create ir.attachment records
|
|
# ----------------------------------------------------------
|
|
attachment_ids = []
|
|
if photos:
|
|
for i, photo in enumerate(photos):
|
|
photo_data = photo.get('data', '')
|
|
photo_name = photo.get('name', f'photo_{i+1}.jpg')
|
|
if not photo_data:
|
|
continue
|
|
# Strip data-url prefix (e.g. "data:image/jpeg;base64,...")
|
|
if ',' in photo_data:
|
|
photo_data = photo_data.split(',', 1)[1]
|
|
try:
|
|
att = Attachment.create({
|
|
'name': photo_name,
|
|
'type': 'binary',
|
|
'datas': photo_data,
|
|
'res_model': 'fusion.technician.task',
|
|
'res_id': task.id,
|
|
'mimetype': 'image/jpeg',
|
|
})
|
|
attachment_ids.append(att.id)
|
|
except Exception as e:
|
|
_logger.warning("Failed to attach photo %s: %s", photo_name, e)
|
|
|
|
# ----------------------------------------------------------
|
|
# Sanitize and format the notes text
|
|
# ----------------------------------------------------------
|
|
safe_notes = str(escape(notes or ''))
|
|
formatted_notes = re.sub(r'\n', '<br/>', safe_notes)
|
|
|
|
timestamp = fields.Datetime.now().strftime("%b %d, %Y %I:%M %p")
|
|
safe_user = str(escape(user.name))
|
|
safe_task = str(escape(task.name))
|
|
|
|
has_text = bool((notes or '').strip())
|
|
photo_count = len(attachment_ids)
|
|
|
|
# Build a small photo summary for inline display
|
|
photo_html = ''
|
|
if photo_count:
|
|
photo_html = '<div style="margin-top:6px;"><em>%d photo(s) attached</em></div>' % photo_count
|
|
|
|
# --- 1. Append to the completion_notes field on the task ---
|
|
note_parts = []
|
|
if has_text:
|
|
note_parts.append(
|
|
'<div style="margin-top:4px;white-space:pre-wrap;">%s</div>' % formatted_notes
|
|
)
|
|
if photo_html:
|
|
note_parts.append(photo_html)
|
|
|
|
if note_parts:
|
|
new_note = Markup(
|
|
'<div style="border-top:1px solid #dee2e6;padding-top:8px;margin-top:8px;">'
|
|
'<small class="text-muted">%s - %s</small>'
|
|
'%s'
|
|
'</div>'
|
|
) % (Markup(timestamp), Markup(safe_user), Markup(''.join(note_parts)))
|
|
|
|
existing = task.completion_notes or ''
|
|
task.completion_notes = Markup(existing) + new_note
|
|
|
|
# --- 2. Post to the TASK chatter ---
|
|
chatter_parts = []
|
|
if has_text:
|
|
chatter_parts.append(
|
|
'<div style="margin-top:6px;white-space:pre-wrap;">%s</div>' % formatted_notes
|
|
)
|
|
if photo_html:
|
|
chatter_parts.append(photo_html)
|
|
|
|
task_chatter = Markup(
|
|
'<div class="alert alert-success" style="margin:0;">'
|
|
'<strong><i class="fa fa-sticky-note"></i> Note Added</strong>'
|
|
'%s'
|
|
'<small class="text-muted">By %s</small>'
|
|
'</div>'
|
|
) % (Markup(''.join(chatter_parts)), Markup(safe_user))
|
|
|
|
task.message_post(
|
|
body=task_chatter,
|
|
message_type='comment',
|
|
subtype_xmlid='mail.mt_note',
|
|
attachment_ids=attachment_ids,
|
|
)
|
|
|
|
# --- 3. Post to the SALE ORDER chatter (if linked) ---
|
|
if task.sale_order_id:
|
|
so_chatter = Markup(
|
|
'<div class="alert alert-info" style="margin:0;">'
|
|
'<strong><i class="fa fa-sticky-note"></i> Technician Note - %s</strong>'
|
|
'%s'
|
|
'<small class="text-muted">By %s on %s</small>'
|
|
'</div>'
|
|
) % (Markup(safe_task), Markup(''.join(chatter_parts)), Markup(safe_user), Markup(timestamp))
|
|
|
|
# Duplicate attachments for the sale order so both records show them
|
|
so_att_ids = []
|
|
for att_id in attachment_ids:
|
|
att = Attachment.browse(att_id)
|
|
so_att = att.copy({
|
|
'res_model': 'sale.order',
|
|
'res_id': task.sale_order_id.id,
|
|
})
|
|
so_att_ids.append(so_att.id)
|
|
|
|
task.sale_order_id.message_post(
|
|
body=so_chatter,
|
|
message_type='comment',
|
|
subtype_xmlid='mail.mt_note',
|
|
attachment_ids=so_att_ids,
|
|
)
|
|
|
|
return {'success': True}
|
|
except Exception as e:
|
|
_logger.error(f"Add notes error: {e}")
|
|
return {'success': False, 'error': str(e)}
|
|
|
|
@http.route('/my/technician/task/<int:task_id>/action', type='json', auth='user', website=True)
|
|
def technician_task_action(self, task_id, action, latitude=None, longitude=None, accuracy=None, **kw):
|
|
"""Handle task status changes (start, complete, en_route, cancel).
|
|
Location is mandatory -- the client must send GPS coordinates."""
|
|
if not self._check_technician_access():
|
|
return {'success': False, 'error': 'Access denied'}
|
|
|
|
if not latitude or not longitude:
|
|
return {'success': False, 'error': 'Location is required. Please enable GPS and try again.'}
|
|
if not (-90 <= latitude <= 90 and -180 <= longitude <= 180):
|
|
return {'success': False, 'error': 'Invalid GPS coordinates.'}
|
|
|
|
user = request.env.user
|
|
Task = request.env['fusion.technician.task'].sudo()
|
|
|
|
try:
|
|
task = Task.browse(task_id)
|
|
if not task.exists() or (
|
|
task.technician_id.id != user.id
|
|
and user.id not in task.additional_technician_ids.ids
|
|
):
|
|
return {'success': False, 'error': 'Task not found or not assigned to you'}
|
|
|
|
request.env['fusion.technician.location'].sudo().log_location(
|
|
latitude=latitude,
|
|
longitude=longitude,
|
|
accuracy=accuracy,
|
|
)
|
|
|
|
# Push location to remote instances for cross-instance visibility
|
|
try:
|
|
request.env['fusion.task.sync.config'].sudo()._push_technician_location(
|
|
user.id, latitude, longitude, accuracy or 0)
|
|
except Exception:
|
|
pass # Non-blocking: sync failure should not block task action
|
|
|
|
location_ctx = {
|
|
'action_latitude': latitude,
|
|
'action_longitude': longitude,
|
|
'action_accuracy': accuracy or 0,
|
|
}
|
|
|
|
if action == 'en_route':
|
|
task.with_context(**location_ctx).action_start_en_route()
|
|
elif action == 'start':
|
|
task.with_context(**location_ctx).action_start_task()
|
|
elif action == 'complete':
|
|
completion_notes = kw.get('completion_notes', '')
|
|
if completion_notes:
|
|
task.completion_notes = completion_notes
|
|
task.with_context(**location_ctx).action_complete_task()
|
|
elif action == 'cancel':
|
|
task.with_context(**location_ctx).action_cancel_task()
|
|
else:
|
|
return {'success': False, 'error': f'Unknown action: {action}'}
|
|
|
|
result = {
|
|
'success': True,
|
|
'status': task.status,
|
|
'redirect_url': f'/my/technician/task/{task_id}',
|
|
}
|
|
if action == 'complete':
|
|
next_task = task.get_next_task_for_technician()
|
|
if next_task:
|
|
result['next_task_id'] = next_task.id
|
|
result['next_task_url'] = f'/my/technician/task/{next_task.id}'
|
|
result['next_task_name'] = next_task.name
|
|
result['next_task_time'] = task._float_to_time_str(next_task.time_start)
|
|
else:
|
|
result['next_task_id'] = False
|
|
result['all_done'] = True
|
|
return result
|
|
except Exception as e:
|
|
_logger.error(f"Task action error: {e}")
|
|
return {'success': False, 'error': str(e)}
|
|
|
|
@http.route('/my/technician/task/<int:task_id>/voice-transcribe', type='json', auth='user', website=True)
|
|
def technician_voice_transcribe(self, task_id, audio_data, mime_type='audio/webm', **kw):
|
|
"""Transcribe voice recording using OpenAI Whisper, translate to English."""
|
|
if not self._check_technician_access():
|
|
return {'success': False, 'error': 'Access denied'}
|
|
|
|
user = request.env.user
|
|
Task = request.env['fusion.technician.task'].sudo()
|
|
ICP = request.env['ir.config_parameter'].sudo()
|
|
|
|
task = Task.browse(task_id)
|
|
if not task.exists() or (
|
|
task.technician_id.id != user.id
|
|
and user.id not in task.additional_technician_ids.ids
|
|
):
|
|
return {'success': False, 'error': 'Task not found'}
|
|
|
|
api_key = ICP.get_param('fusion_notes.openai_api_key') or ICP.get_param('fusion_claims.ai_api_key', '')
|
|
if not api_key:
|
|
return {'success': False, 'error': 'OpenAI API key not configured'}
|
|
|
|
import base64
|
|
import tempfile
|
|
import os
|
|
import requests as http_requests
|
|
|
|
try:
|
|
# Decode audio
|
|
audio_bytes = base64.b64decode(audio_data)
|
|
ext_map = {'audio/webm': '.webm', 'audio/ogg': '.ogg', 'audio/mp4': '.m4a', 'audio/wav': '.wav'}
|
|
ext = ext_map.get(mime_type, '.webm')
|
|
|
|
with tempfile.NamedTemporaryFile(suffix=ext, delete=False) as tmp:
|
|
tmp.write(audio_bytes)
|
|
tmp_path = tmp.name
|
|
|
|
# Call Whisper API - use 'translations' endpoint to auto-translate to English
|
|
with open(tmp_path, 'rb') as f:
|
|
resp = http_requests.post(
|
|
'https://api.openai.com/v1/audio/translations',
|
|
headers={'Authorization': f'Bearer {api_key}'},
|
|
files={'file': (f'recording{ext}', f, mime_type)},
|
|
data={'model': 'whisper-1', 'response_format': 'text'},
|
|
timeout=60,
|
|
)
|
|
|
|
os.unlink(tmp_path)
|
|
|
|
if resp.status_code != 200:
|
|
return {'success': False, 'error': f'Whisper API error: {resp.text}'}
|
|
|
|
transcription = resp.text.strip()
|
|
|
|
# Store transcription and audio on task
|
|
task.write({
|
|
'voice_note_audio': audio_data,
|
|
'voice_note_transcription': transcription,
|
|
})
|
|
|
|
return {'success': True, 'transcription': transcription}
|
|
|
|
except Exception as e:
|
|
_logger.error(f"Voice transcription error: {e}")
|
|
return {'success': False, 'error': str(e)}
|
|
|
|
@http.route('/my/technician/task/<int:task_id>/ai-format', type='json', auth='user', website=True)
|
|
def technician_ai_format(self, task_id, text, **kw):
|
|
"""Use GPT to clean up and format raw notes text."""
|
|
if not self._check_technician_access():
|
|
return {'success': False, 'error': 'Access denied'}
|
|
|
|
user = request.env.user
|
|
Task = request.env['fusion.technician.task'].sudo()
|
|
ICP = request.env['ir.config_parameter'].sudo()
|
|
|
|
task = Task.browse(task_id)
|
|
if not task.exists() or (
|
|
task.technician_id.id != user.id
|
|
and user.id not in task.additional_technician_ids.ids
|
|
):
|
|
return {'success': False, 'error': 'Task not found'}
|
|
|
|
api_key = ICP.get_param('fusion_notes.openai_api_key') or ICP.get_param('fusion_claims.ai_api_key', '')
|
|
if not api_key:
|
|
return {'success': False, 'error': 'OpenAI API key not configured'}
|
|
|
|
ai_model = ICP.get_param('fusion_claims.ai_model', 'gpt-4o-mini')
|
|
|
|
import requests as http_requests
|
|
try:
|
|
task_type_label = dict(Task._fields['task_type'].selection).get(task.task_type, task.task_type)
|
|
system_prompt = (
|
|
"You are formatting a field technician's notes into clear, professional text. "
|
|
"ALWAYS output in English. If the input is in another language, translate it to English. "
|
|
"Fix grammar, spelling, and punctuation. Remove filler words. "
|
|
"Keep all facts from the original. Make it concise and professional. "
|
|
"If it mentions work done, parts used, issues, or follow-ups, organize them clearly. "
|
|
"Return plain text only (no HTML)."
|
|
)
|
|
|
|
resp = http_requests.post(
|
|
'https://api.openai.com/v1/chat/completions',
|
|
headers={'Authorization': f'Bearer {api_key}', 'Content-Type': 'application/json'},
|
|
json={
|
|
'model': ai_model,
|
|
'messages': [
|
|
{'role': 'system', 'content': system_prompt},
|
|
{'role': 'user', 'content': f'Task type: {task_type_label}\nClient: {task.partner_id.name or "N/A"}\nRaw notes:\n{text}'},
|
|
],
|
|
'temperature': 0.3,
|
|
'max_tokens': 1000,
|
|
},
|
|
timeout=30,
|
|
)
|
|
|
|
if resp.status_code == 200:
|
|
data = resp.json()
|
|
formatted = data['choices'][0]['message']['content']
|
|
return {'success': True, 'formatted_text': formatted}
|
|
else:
|
|
return {'success': False, 'error': f'AI service error ({resp.status_code})'}
|
|
except Exception as e:
|
|
_logger.error(f"AI format error: {e}")
|
|
return {'success': False, 'error': str(e)}
|
|
|
|
@http.route('/my/technician/task/<int:task_id>/voice-complete', type='json', auth='user', website=True)
|
|
def technician_voice_complete(self, task_id, transcription, latitude=None, longitude=None, accuracy=None, **kw):
|
|
"""Format transcription with GPT and complete the task."""
|
|
if not self._check_technician_access():
|
|
return {'success': False, 'error': 'Access denied'}
|
|
if not latitude or not longitude:
|
|
return {'success': False, 'error': 'Location is required. Please enable GPS and try again.'}
|
|
if not (-90 <= latitude <= 90 and -180 <= longitude <= 180):
|
|
return {'success': False, 'error': 'Invalid GPS coordinates.'}
|
|
|
|
user = request.env.user
|
|
Task = request.env['fusion.technician.task'].sudo()
|
|
ICP = request.env['ir.config_parameter'].sudo()
|
|
|
|
task = Task.browse(task_id)
|
|
if not task.exists() or (
|
|
task.technician_id.id != user.id
|
|
and user.id not in task.additional_technician_ids.ids
|
|
):
|
|
return {'success': False, 'error': 'Task not found'}
|
|
|
|
api_key = ICP.get_param('fusion_notes.openai_api_key') or ICP.get_param('fusion_claims.ai_api_key', '')
|
|
ai_model = ICP.get_param('fusion_claims.ai_model', 'gpt-4o-mini')
|
|
|
|
formatted_notes = transcription # fallback
|
|
|
|
if api_key:
|
|
import requests as http_requests
|
|
try:
|
|
task_type_label = dict(Task._fields['task_type'].selection).get(task.task_type, task.task_type)
|
|
system_prompt = (
|
|
"You are formatting a technician's voice note into a structured completion report. "
|
|
"ALWAYS output in English. If the input is in another language, translate it to English. "
|
|
"The technician recorded this after completing a field task. "
|
|
"Format it into clear, professional HTML with these sections:\n"
|
|
"<strong>Work Performed:</strong> [summary]\n"
|
|
"<strong>Parts Used:</strong> [if mentioned, otherwise 'None mentioned']\n"
|
|
"<strong>Issues Found:</strong> [if any, otherwise 'None']\n"
|
|
"<strong>Follow-up Required:</strong> [yes/no + details]\n"
|
|
"<strong>Client Feedback:</strong> [if mentioned, otherwise 'N/A']\n\n"
|
|
"Keep all facts from the original. Fix grammar. Remove filler words. "
|
|
"Use <br/> for line breaks, <strong> for labels. Keep it concise."
|
|
)
|
|
|
|
resp = http_requests.post(
|
|
'https://api.openai.com/v1/chat/completions',
|
|
headers={'Authorization': f'Bearer {api_key}', 'Content-Type': 'application/json'},
|
|
json={
|
|
'model': ai_model,
|
|
'messages': [
|
|
{'role': 'system', 'content': system_prompt},
|
|
{'role': 'user', 'content': f'Task type: {task_type_label}\nTechnician: {user.name}\nVoice note:\n{transcription}'},
|
|
],
|
|
'temperature': 0.3,
|
|
'max_tokens': 1000,
|
|
},
|
|
timeout=30,
|
|
)
|
|
|
|
if resp.status_code == 200:
|
|
data = resp.json()
|
|
formatted_notes = data['choices'][0]['message']['content']
|
|
except Exception as e:
|
|
_logger.warning(f"GPT formatting failed, using raw transcription: {e}")
|
|
|
|
# Build final HTML completion report
|
|
from markupsafe import Markup
|
|
completion_html = Markup(
|
|
f'<div class="tech-completion-report">'
|
|
f'<p><strong>Technician:</strong> {user.name}<br/>'
|
|
f'<strong>Task:</strong> {task.name} ({dict(Task._fields["task_type"].selection).get(task.task_type, task.task_type)})</p>'
|
|
f'<hr/>'
|
|
f'{formatted_notes}'
|
|
f'</div>'
|
|
)
|
|
|
|
task.write({
|
|
'completion_notes': completion_html,
|
|
'voice_note_transcription': transcription,
|
|
})
|
|
|
|
request.env['fusion.technician.location'].sudo().log_location(
|
|
latitude=latitude,
|
|
longitude=longitude,
|
|
accuracy=accuracy,
|
|
)
|
|
location_ctx = {
|
|
'action_latitude': latitude,
|
|
'action_longitude': longitude,
|
|
'action_accuracy': accuracy or 0,
|
|
}
|
|
task.with_context(**location_ctx).action_complete_task()
|
|
|
|
return {
|
|
'success': True,
|
|
'formatted_notes': formatted_notes,
|
|
'redirect_url': f'/my/technician/task/{task_id}',
|
|
}
|
|
|
|
@http.route('/my/technician/tomorrow', type='http', auth='user', website=True)
|
|
def technician_tomorrow(self, **kw):
|
|
"""Next day preparation view."""
|
|
if not self._check_technician_access():
|
|
return request.redirect('/my')
|
|
|
|
user = request.env.user
|
|
Task = request.env['fusion.technician.task'].sudo()
|
|
from datetime import timedelta
|
|
today = fields.Date.context_today(Task)
|
|
tomorrow = today + timedelta(days=1)
|
|
|
|
tomorrow_tasks = Task.search([
|
|
'|',
|
|
('technician_id', '=', user.id),
|
|
('additional_technician_ids', 'in', [user.id]),
|
|
('scheduled_date', '=', tomorrow),
|
|
('status', '!=', 'cancelled'),
|
|
], order='sequence, time_start, id')
|
|
|
|
total_travel = sum(tomorrow_tasks.mapped('travel_time_minutes'))
|
|
total_distance = sum(tomorrow_tasks.mapped('travel_distance_km'))
|
|
|
|
# Aggregate equipment needed
|
|
all_equipment = []
|
|
for t in tomorrow_tasks:
|
|
if t.equipment_needed:
|
|
all_equipment.append(f"{t.name}: {t.equipment_needed}")
|
|
|
|
values = {
|
|
'tomorrow_tasks': tomorrow_tasks,
|
|
'tomorrow_date': tomorrow,
|
|
'total_travel': total_travel,
|
|
'total_distance': total_distance,
|
|
'all_equipment': all_equipment,
|
|
'page_name': 'technician_tomorrow',
|
|
}
|
|
return request.render('fusion_authorizer_portal.portal_technician_tomorrow', values)
|
|
|
|
@http.route('/my/technician/schedule/<string:date>', type='http', auth='user', website=True)
|
|
def technician_schedule_date(self, date, **kw):
|
|
"""View schedule for a specific date."""
|
|
if not self._check_technician_access():
|
|
return request.redirect('/my')
|
|
|
|
user = request.env.user
|
|
Task = request.env['fusion.technician.task'].sudo()
|
|
|
|
try:
|
|
schedule_date = fields.Date.from_string(date)
|
|
except (ValueError, TypeError):
|
|
return request.redirect('/my/technician')
|
|
|
|
tasks = Task.search([
|
|
'|',
|
|
('technician_id', '=', user.id),
|
|
('additional_technician_ids', 'in', [user.id]),
|
|
('scheduled_date', '=', schedule_date),
|
|
('status', '!=', 'cancelled'),
|
|
], order='sequence, time_start, id')
|
|
|
|
total_travel = sum(tasks.mapped('travel_time_minutes'))
|
|
|
|
values = {
|
|
'tasks': tasks,
|
|
'schedule_date': schedule_date,
|
|
'total_travel': total_travel,
|
|
'page_name': 'technician_schedule',
|
|
}
|
|
return request.render('fusion_authorizer_portal.portal_technician_schedule_date', values)
|
|
|
|
@http.route('/my/technician/admin/map', type='http', auth='user', website=True)
|
|
def technician_location_map(self, **kw):
|
|
"""Admin map view showing latest technician locations using Google Maps."""
|
|
user = request.env.user
|
|
if not user.has_group('sales_team.group_sale_manager') and not user.has_group('sales_team.group_sale_salesman'):
|
|
return request.redirect('/my')
|
|
|
|
LocationModel = request.env['fusion.technician.location'].sudo()
|
|
locations = LocationModel.get_latest_locations()
|
|
api_key = request.env['ir.config_parameter'].sudo().get_param(
|
|
'fusion_claims.google_maps_api_key', ''
|
|
)
|
|
values = {
|
|
'locations': locations,
|
|
'google_maps_api_key': api_key,
|
|
}
|
|
return request.render('fusion_authorizer_portal.portal_technician_map', values)
|
|
|
|
@http.route('/my/technician/location/log', type='json', auth='user', website=True)
|
|
def technician_location_log(self, latitude, longitude, accuracy=None, **kw):
|
|
"""Log the technician's current GPS location."""
|
|
if not self._check_technician_access():
|
|
return {'success': False}
|
|
try:
|
|
request.env['fusion.technician.location'].sudo().log_location(
|
|
latitude=latitude,
|
|
longitude=longitude,
|
|
accuracy=accuracy,
|
|
)
|
|
return {'success': True}
|
|
except Exception as e:
|
|
_logger.warning(f"Location log error: {e}")
|
|
return {'success': False}
|
|
|
|
@http.route('/my/technician/clock-status', type='json', auth='user', website=True)
|
|
def technician_clock_status(self, **kw):
|
|
"""Check if the current technician is clocked in.
|
|
|
|
Returns {clocked_in: bool} so the JS background logger can decide
|
|
whether to track location. Replaces the fixed 9-6 hour window.
|
|
"""
|
|
if not self._check_technician_access():
|
|
return {'clocked_in': False}
|
|
try:
|
|
emp = request.env['hr.employee'].sudo().search([
|
|
('user_id', '=', request.env.user.id),
|
|
], limit=1)
|
|
if emp and emp.attendance_state == 'checked_in':
|
|
return {'clocked_in': True}
|
|
except Exception:
|
|
pass
|
|
return {'clocked_in': False}
|
|
|
|
@http.route('/my/technician/settings/start-location', type='json', auth='user', website=True)
|
|
def technician_save_start_location(self, address='', **kw):
|
|
"""Save the technician's personal start location."""
|
|
if not self._check_technician_access():
|
|
return {'success': False, 'error': 'Access denied'}
|
|
try:
|
|
request.env.user.sudo().write({
|
|
'x_fc_start_address': address.strip() if address else False,
|
|
})
|
|
return {'success': True}
|
|
except Exception as e:
|
|
_logger.warning("Error saving start location: %s", e)
|
|
return {'success': False, 'error': str(e)}
|
|
|
|
@http.route('/my/technician/push/subscribe', type='json', auth='user', website=True)
|
|
def technician_push_subscribe(self, endpoint, p256dh, auth, **kw):
|
|
"""Register a push notification subscription."""
|
|
user = request.env.user
|
|
PushSub = request.env['fusion.push.subscription'].sudo()
|
|
browser_info = request.httprequest.headers.get('User-Agent', '')[:200]
|
|
sub = PushSub.register_subscription(user.id, endpoint, p256dh, auth, browser_info)
|
|
return {'success': True, 'subscription_id': sub.id}
|
|
|
|
# Keep legacy delivery routes for backward compatibility
|
|
@http.route(['/my/technician/deliveries', '/my/technician/deliveries/page/<int:page>'], type='http', auth='user', website=True)
|
|
def technician_deliveries(self, page=1, search='', filter_status='all', **kw):
|
|
"""Legacy: List of deliveries for the technician (redirects to tasks)."""
|
|
return request.redirect('/my/technician/tasks?filter_status=all')
|
|
|
|
@http.route('/my/technician/delivery/<int:order_id>', type='http', auth='user', website=True)
|
|
def technician_delivery_detail(self, order_id, **kw):
|
|
"""View a specific delivery for technician (legacy, still works)."""
|
|
if not self._check_technician_access():
|
|
return request.redirect('/my')
|
|
|
|
user = request.env.user
|
|
try:
|
|
order = request.env['sale.order'].sudo().browse(order_id)
|
|
if not order.exists() or user.id not in order.x_fc_delivery_technician_ids.ids:
|
|
raise AccessError(_('You do not have access to this delivery.'))
|
|
except (AccessError, MissingError):
|
|
return request.redirect('/my/technician/tasks')
|
|
|
|
values = {
|
|
'order': order,
|
|
'page_name': 'technician_delivery_detail',
|
|
}
|
|
return request.render('fusion_authorizer_portal.portal_technician_delivery_detail_legacy', values)
|
|
|
|
# ==================== POD SIGNATURE CAPTURE ====================
|
|
|
|
@http.route('/my/pod/<int:order_id>', type='http', auth='user', website=True)
|
|
def pod_signature_page(self, order_id, **kw):
|
|
"""POD signature capture page - accessible by technicians and sales reps"""
|
|
partner = request.env.user.partner_id
|
|
user = request.env.user
|
|
|
|
try:
|
|
order = request.env['sale.order'].sudo().browse(order_id)
|
|
if not order.exists():
|
|
raise MissingError(_('Order not found.'))
|
|
|
|
# Check access - technician (via delivery list or task assignment), sales rep, or internal staff
|
|
has_access = False
|
|
user_role = None
|
|
|
|
# Technician: check delivery technician list on the order
|
|
if partner.is_technician_portal and user.id in order.x_fc_delivery_technician_ids.ids:
|
|
has_access = True
|
|
user_role = 'technician'
|
|
|
|
# Technician: also check if user is assigned to a task linked to this order
|
|
if not has_access and partner.is_technician_portal:
|
|
task_count = request.env['fusion.technician.task'].sudo().search_count([
|
|
('sale_order_id', '=', order.id),
|
|
'|',
|
|
('technician_id', '=', user.id),
|
|
('additional_technician_ids', 'in', [user.id]),
|
|
])
|
|
if task_count:
|
|
has_access = True
|
|
user_role = 'technician'
|
|
|
|
# Internal users with field staff flag can always collect POD
|
|
if not has_access and not user.share:
|
|
has_access = True
|
|
user_role = 'technician'
|
|
|
|
# Sales rep: own orders
|
|
if not has_access:
|
|
if partner.is_sales_rep_portal and order.user_id.id == user.id:
|
|
has_access = True
|
|
user_role = 'sales_rep'
|
|
elif order.user_id.id == user.id:
|
|
has_access = True
|
|
user_role = 'sales_rep'
|
|
|
|
if not has_access:
|
|
raise AccessError(_('You do not have access to this order.'))
|
|
|
|
except (AccessError, MissingError) as e:
|
|
_logger.warning(f"POD access denied for user {user.id} on order {order_id}: {e}")
|
|
return request.redirect('/my')
|
|
|
|
# Get delivery address
|
|
delivery_address = order.partner_shipping_id or order.partner_id
|
|
|
|
values = {
|
|
'order': order,
|
|
'delivery_address': delivery_address,
|
|
'user_role': user_role,
|
|
'has_existing_signature': bool(order.x_fc_pod_signature),
|
|
'page_name': 'pod_signature',
|
|
}
|
|
|
|
return request.render('fusion_authorizer_portal.portal_pod_signature', values)
|
|
|
|
@http.route('/my/pod/<int:order_id>/sign', type='json', auth='user', methods=['POST'])
|
|
def pod_save_signature(self, order_id, client_name, signature_data, signature_date=None, **kw):
|
|
"""Save POD signature via AJAX"""
|
|
partner = request.env.user.partner_id
|
|
user = request.env.user
|
|
|
|
try:
|
|
order = request.env['sale.order'].sudo().browse(order_id)
|
|
if not order.exists():
|
|
return {'success': False, 'error': 'Order not found'}
|
|
|
|
# Check access - same logic as pod_signature_page
|
|
has_access = False
|
|
if partner.is_technician_portal and user.id in order.x_fc_delivery_technician_ids.ids:
|
|
has_access = True
|
|
elif partner.is_technician_portal:
|
|
task_count = request.env['fusion.technician.task'].sudo().search_count([
|
|
('sale_order_id', '=', order.id),
|
|
('technician_id', '=', user.id),
|
|
])
|
|
if task_count:
|
|
has_access = True
|
|
if not has_access and not user.share:
|
|
has_access = True
|
|
if not has_access:
|
|
if partner.is_sales_rep_portal and order.user_id.id == user.id:
|
|
has_access = True
|
|
elif order.user_id.id == user.id:
|
|
has_access = True
|
|
|
|
if not has_access:
|
|
return {'success': False, 'error': 'Access denied'}
|
|
|
|
if not client_name or not client_name.strip():
|
|
return {'success': False, 'error': 'Client name is required'}
|
|
|
|
if not signature_data:
|
|
return {'success': False, 'error': 'Signature is required'}
|
|
|
|
# Process signature data (remove data URL prefix if present)
|
|
if ',' in signature_data:
|
|
signature_data = signature_data.split(',')[1]
|
|
|
|
# Parse signature date if provided
|
|
from datetime import datetime
|
|
sig_date = None
|
|
if signature_date:
|
|
try:
|
|
sig_date = datetime.strptime(signature_date, '%Y-%m-%d').date()
|
|
except ValueError:
|
|
pass # Leave as None if invalid
|
|
|
|
# Determine if this is an ADP sale
|
|
is_adp = order.x_fc_sale_type in ('adp', 'adp_odsp')
|
|
|
|
# Check if there's already an existing POD signature (for chatter logic)
|
|
had_existing_signature = bool(order.x_fc_pod_signature)
|
|
|
|
# Save signature data
|
|
order.write({
|
|
'x_fc_pod_signature': signature_data,
|
|
'x_fc_pod_client_name': client_name.strip(),
|
|
'x_fc_pod_signature_date': sig_date,
|
|
'x_fc_pod_signed_by_user_id': user.id,
|
|
'x_fc_pod_signed_datetime': datetime.now(),
|
|
})
|
|
|
|
# Generate the signed POD PDF
|
|
# For ADP: save to x_fc_proof_of_delivery field
|
|
# For non-ADP: don't save to field, just generate for chatter
|
|
pdf_content = self._generate_signed_pod_pdf(order, save_to_field=is_adp)
|
|
|
|
# Post to chatter
|
|
from markupsafe import Markup
|
|
date_str = sig_date.strftime('%B %d, %Y') if sig_date else 'Not specified'
|
|
pod_type = "ADP Proof of Delivery" if is_adp else "Proof of Delivery"
|
|
|
|
if had_existing_signature:
|
|
# Update - post note about the update with new attachment
|
|
chatter_body = Markup(f'''
|
|
<div class="alert alert-info">
|
|
<p><strong><i class="fa fa-refresh"></i> {pod_type} Updated</strong></p>
|
|
<ul>
|
|
<li><strong>Client Name:</strong> {client_name.strip()}</li>
|
|
<li><strong>Signature Date:</strong> {date_str}</li>
|
|
<li><strong>Updated By:</strong> {user.name}</li>
|
|
</ul>
|
|
<p><em>The POD document has been replaced with a new signed version.</em></p>
|
|
</div>
|
|
''')
|
|
# For non-ADP updates, still attach the new PDF
|
|
if not is_adp and pdf_content:
|
|
attachment = request.env['ir.attachment'].sudo().create({
|
|
'name': f'POD_{order.name.replace("/", "_")}.pdf',
|
|
'type': 'binary',
|
|
'datas': base64.b64encode(pdf_content),
|
|
'res_model': 'sale.order',
|
|
'res_id': order.id,
|
|
'mimetype': 'application/pdf',
|
|
})
|
|
order.message_post(
|
|
body=chatter_body,
|
|
message_type='notification',
|
|
subtype_xmlid='mail.mt_note',
|
|
attachment_ids=[attachment.id],
|
|
)
|
|
else:
|
|
order.message_post(body=chatter_body, message_type='notification', subtype_xmlid='mail.mt_note')
|
|
else:
|
|
# New POD - post with attachment
|
|
chatter_body = Markup(f'''
|
|
<div class="alert alert-success">
|
|
<p><strong><i class="fa fa-check-circle"></i> {pod_type} Signed</strong></p>
|
|
<ul>
|
|
<li><strong>Client Name:</strong> {client_name.strip()}</li>
|
|
<li><strong>Signature Date:</strong> {date_str}</li>
|
|
<li><strong>Collected By:</strong> {user.name}</li>
|
|
</ul>
|
|
</div>
|
|
''')
|
|
|
|
# Create attachment for the chatter (always for new POD)
|
|
if pdf_content:
|
|
attachment = request.env['ir.attachment'].sudo().create({
|
|
'name': f'POD_{order.name.replace("/", "_")}.pdf',
|
|
'type': 'binary',
|
|
'datas': base64.b64encode(pdf_content),
|
|
'res_model': 'sale.order',
|
|
'res_id': order.id,
|
|
'mimetype': 'application/pdf',
|
|
})
|
|
order.message_post(
|
|
body=chatter_body,
|
|
message_type='notification',
|
|
subtype_xmlid='mail.mt_note',
|
|
attachment_ids=[attachment.id],
|
|
)
|
|
else:
|
|
order.message_post(body=chatter_body, message_type='notification', subtype_xmlid='mail.mt_note')
|
|
|
|
return {
|
|
'success': True,
|
|
'message': 'Signature saved successfully',
|
|
'redirect_url': f'/my/technician/delivery/{order_id}' if partner.is_technician_portal else f'/my/sales/case/{order_id}',
|
|
}
|
|
|
|
except Exception as e:
|
|
_logger.error(f"Error saving POD signature: {e}")
|
|
return {'success': False, 'error': str(e)}
|
|
|
|
# ==================== TASK-LEVEL POD SIGNATURE ====================
|
|
|
|
@http.route('/my/technician/task/<int:task_id>/pod', type='http', auth='user', website=True)
|
|
def task_pod_signature_page(self, task_id, **kw):
|
|
"""Task-level POD signature capture page (works for all tasks including shadow)."""
|
|
if not self._check_technician_access():
|
|
return request.redirect('/my')
|
|
|
|
user = request.env.user
|
|
Task = request.env['fusion.technician.task'].sudo()
|
|
|
|
try:
|
|
task = Task.browse(task_id)
|
|
if not task.exists() or (
|
|
task.technician_id.id != user.id
|
|
and user.id not in task.additional_technician_ids.ids
|
|
):
|
|
raise AccessError(_('You do not have access to this task.'))
|
|
except (AccessError, MissingError):
|
|
return request.redirect('/my/technician/tasks')
|
|
|
|
values = {
|
|
'task': task,
|
|
'has_existing_signature': bool(task.pod_signature),
|
|
'page_name': 'task_pod_signature',
|
|
}
|
|
return request.render('fusion_authorizer_portal.portal_task_pod_signature', values)
|
|
|
|
@http.route('/my/technician/task/<int:task_id>/pod/sign', type='json', auth='user', methods=['POST'])
|
|
def task_pod_save_signature(self, task_id, client_name, signature_data, signature_date=None, **kw):
|
|
"""Save POD signature directly on a task."""
|
|
if not self._check_technician_access():
|
|
return {'success': False, 'error': 'Access denied'}
|
|
|
|
user = request.env.user
|
|
Task = request.env['fusion.technician.task'].sudo()
|
|
|
|
try:
|
|
task = Task.browse(task_id)
|
|
if not task.exists() or (
|
|
task.technician_id.id != user.id
|
|
and user.id not in task.additional_technician_ids.ids
|
|
):
|
|
return {'success': False, 'error': 'Task not found'}
|
|
|
|
if not client_name or not client_name.strip():
|
|
return {'success': False, 'error': 'Client name is required'}
|
|
if not signature_data:
|
|
return {'success': False, 'error': 'Signature is required'}
|
|
|
|
if ',' in signature_data:
|
|
signature_data = signature_data.split(',')[1]
|
|
|
|
from datetime import datetime as dt_datetime
|
|
sig_date = None
|
|
if signature_date:
|
|
try:
|
|
sig_date = dt_datetime.strptime(signature_date, '%Y-%m-%d').date()
|
|
except ValueError:
|
|
pass
|
|
|
|
task.write({
|
|
'pod_signature': signature_data,
|
|
'pod_client_name': client_name.strip(),
|
|
'pod_signature_date': sig_date,
|
|
'pod_signed_by_user_id': user.id,
|
|
'pod_signed_datetime': fields.Datetime.now(),
|
|
})
|
|
|
|
if task.sale_order_id:
|
|
task.sale_order_id.write({
|
|
'x_fc_pod_signature': signature_data,
|
|
'x_fc_pod_client_name': client_name.strip(),
|
|
'x_fc_pod_signature_date': sig_date,
|
|
'x_fc_pod_signed_by_user_id': user.id,
|
|
'x_fc_pod_signed_datetime': fields.Datetime.now(),
|
|
})
|
|
|
|
return {
|
|
'success': True,
|
|
'message': 'Signature saved successfully',
|
|
'redirect_url': f'/my/technician/task/{task_id}',
|
|
}
|
|
|
|
except Exception as e:
|
|
_logger.error(f"Error saving task POD signature: {e}")
|
|
return {'success': False, 'error': str(e)}
|
|
|
|
def _generate_signed_pod_pdf(self, order, save_to_field=True):
|
|
"""Generate a signed POD PDF with the signature embedded.
|
|
|
|
Args:
|
|
order: The sale.order record
|
|
save_to_field: If True, save to x_fc_proof_of_delivery field (for ADP orders)
|
|
|
|
Returns:
|
|
bytes: The PDF content, or None if generation failed
|
|
"""
|
|
try:
|
|
# Determine which report to use based on sale type
|
|
is_adp = order.x_fc_sale_type in ('adp', 'adp_odsp')
|
|
|
|
if is_adp:
|
|
report = request.env.ref('fusion_claims.action_report_proof_of_delivery')
|
|
else:
|
|
report = request.env.ref('fusion_claims.action_report_proof_of_delivery_standard')
|
|
|
|
# Render the POD report (signature is now embedded in the template)
|
|
pdf_content, _ = report.sudo()._render_qweb_pdf(
|
|
report.id, [order.id]
|
|
)
|
|
|
|
# For ADP orders, save to the x_fc_proof_of_delivery field
|
|
if save_to_field and is_adp:
|
|
order.write({
|
|
'x_fc_proof_of_delivery': base64.b64encode(pdf_content),
|
|
'x_fc_proof_of_delivery_filename': f'POD_{order.name.replace("/", "_")}.pdf',
|
|
})
|
|
|
|
_logger.info(f"Generated signed POD PDF for order {order.name} (ADP: {is_adp})")
|
|
return pdf_content
|
|
|
|
except Exception as e:
|
|
_logger.error(f"Error generating POD PDF for {order.name}: {e}")
|
|
return None
|
|
|
|
# =========================================================================
|
|
# ACCESSIBILITY ASSESSMENT ROUTES
|
|
# =========================================================================
|
|
|
|
@http.route('/my/accessibility', type='http', auth='user', website=True)
|
|
def accessibility_assessment_selector(self, **kw):
|
|
"""Show the accessibility assessment type selector"""
|
|
partner = request.env.user.partner_id
|
|
|
|
if not partner.is_sales_rep_portal and not partner.is_authorizer:
|
|
return request.redirect('/my')
|
|
|
|
# Get Google Maps API key
|
|
ICP = request.env['ir.config_parameter'].sudo()
|
|
google_maps_api_key = ICP.get_param('fusion_claims.google_maps_api_key', '')
|
|
|
|
values = {
|
|
'page_name': 'accessibility_selector',
|
|
'google_maps_api_key': google_maps_api_key,
|
|
}
|
|
return request.render('fusion_authorizer_portal.portal_accessibility_selector', values)
|
|
|
|
@http.route('/my/accessibility/list', type='http', auth='user', website=True)
|
|
def accessibility_assessment_list(self, page=1, **kw):
|
|
"""List all accessibility assessments for the current user (sales rep or authorizer)"""
|
|
partner = request.env.user.partner_id
|
|
|
|
if not partner.is_sales_rep_portal and not partner.is_authorizer:
|
|
return request.redirect('/my')
|
|
|
|
Assessment = request.env['fusion.accessibility.assessment'].sudo()
|
|
|
|
# Build domain based on role
|
|
if partner.is_authorizer and partner.is_sales_rep_portal:
|
|
domain = ['|', ('authorizer_id', '=', partner.id), ('sales_rep_id', '=', request.env.user.id)]
|
|
elif partner.is_authorizer:
|
|
domain = [('authorizer_id', '=', partner.id)]
|
|
else:
|
|
domain = [('sales_rep_id', '=', request.env.user.id)]
|
|
|
|
# Pagination
|
|
assessment_count = Assessment.search_count(domain)
|
|
pager = portal_pager(
|
|
url='/my/accessibility/list',
|
|
total=assessment_count,
|
|
page=page,
|
|
step=20,
|
|
)
|
|
|
|
assessments = Assessment.search(
|
|
domain,
|
|
order='assessment_date desc, id desc',
|
|
limit=20,
|
|
offset=pager['offset'],
|
|
)
|
|
|
|
values = {
|
|
'page_name': 'accessibility_list',
|
|
'assessments': assessments,
|
|
'pager': pager,
|
|
}
|
|
return request.render('fusion_authorizer_portal.portal_accessibility_list', values)
|
|
|
|
@http.route('/my/accessibility/stairlift/straight', type='http', auth='user', website=True)
|
|
def accessibility_stairlift_straight(self, **kw):
|
|
"""Straight stair lift assessment form"""
|
|
return self._render_accessibility_form('stairlift_straight', 'Straight Stair Lift')
|
|
|
|
@http.route('/my/accessibility/stairlift/curved', type='http', auth='user', website=True)
|
|
def accessibility_stairlift_curved(self, **kw):
|
|
"""Curved stair lift assessment form"""
|
|
return self._render_accessibility_form('stairlift_curved', 'Curved Stair Lift')
|
|
|
|
@http.route('/my/accessibility/vpl', type='http', auth='user', website=True)
|
|
def accessibility_vpl(self, **kw):
|
|
"""Vertical Platform Lift assessment form"""
|
|
return self._render_accessibility_form('vpl', 'Vertical Platform Lift')
|
|
|
|
@http.route('/my/accessibility/ceiling-lift', type='http', auth='user', website=True)
|
|
def accessibility_ceiling_lift(self, **kw):
|
|
"""Ceiling Lift assessment form"""
|
|
return self._render_accessibility_form('ceiling_lift', 'Ceiling Lift')
|
|
|
|
@http.route('/my/accessibility/ramp', type='http', auth='user', website=True)
|
|
def accessibility_ramp(self, **kw):
|
|
"""Custom Ramp assessment form"""
|
|
return self._render_accessibility_form('ramp', 'Custom Ramp')
|
|
|
|
@http.route('/my/accessibility/bathroom', type='http', auth='user', website=True)
|
|
def accessibility_bathroom(self, **kw):
|
|
"""Bathroom Modification assessment form"""
|
|
return self._render_accessibility_form('bathroom', 'Bathroom Modification')
|
|
|
|
@http.route('/my/accessibility/tub-cutout', type='http', auth='user', website=True)
|
|
def accessibility_tub_cutout(self, **kw):
|
|
"""Tub Cutout assessment form"""
|
|
return self._render_accessibility_form('tub_cutout', 'Tub Cutout')
|
|
|
|
def _render_accessibility_form(self, assessment_type, title):
|
|
"""Render an accessibility assessment form"""
|
|
partner = request.env.user.partner_id
|
|
|
|
if not partner.is_sales_rep_portal and not partner.is_authorizer:
|
|
return request.redirect('/my')
|
|
|
|
# Get Google Maps API key
|
|
ICP = request.env['ir.config_parameter'].sudo()
|
|
google_maps_api_key = ICP.get_param('fusion_claims.google_maps_api_key', '')
|
|
|
|
from datetime import date
|
|
|
|
values = {
|
|
'page_name': f'accessibility_{assessment_type}',
|
|
'assessment_type': assessment_type,
|
|
'title': title,
|
|
'google_maps_api_key': google_maps_api_key,
|
|
'today': date.today().isoformat(),
|
|
}
|
|
|
|
# Route to specific template based on type
|
|
template_map = {
|
|
'stairlift_straight': 'fusion_authorizer_portal.portal_accessibility_stairlift_straight',
|
|
'stairlift_curved': 'fusion_authorizer_portal.portal_accessibility_stairlift_curved',
|
|
'vpl': 'fusion_authorizer_portal.portal_accessibility_vpl',
|
|
'ceiling_lift': 'fusion_authorizer_portal.portal_accessibility_ceiling',
|
|
'ramp': 'fusion_authorizer_portal.portal_accessibility_ramp',
|
|
'bathroom': 'fusion_authorizer_portal.portal_accessibility_bathroom',
|
|
'tub_cutout': 'fusion_authorizer_portal.portal_accessibility_tub_cutout',
|
|
}
|
|
|
|
template = template_map.get(assessment_type, 'fusion_authorizer_portal.portal_accessibility_selector')
|
|
return request.render(template, values)
|
|
|
|
@http.route('/my/accessibility/save', type='json', auth='user', methods=['POST'], csrf=True)
|
|
def accessibility_assessment_save(self, **post):
|
|
"""Save an accessibility assessment and optionally create a Sale Order"""
|
|
partner = request.env.user.partner_id
|
|
|
|
if not partner.is_sales_rep_portal and not partner.is_authorizer:
|
|
return {'success': False, 'error': 'Access denied'}
|
|
|
|
try:
|
|
Assessment = request.env['fusion.accessibility.assessment'].sudo()
|
|
|
|
assessment_type = post.get('assessment_type')
|
|
if not assessment_type:
|
|
return {'success': False, 'error': 'Assessment type is required'}
|
|
|
|
# Build assessment values
|
|
vals = {
|
|
'assessment_type': assessment_type,
|
|
'sales_rep_id': request.env.user.id,
|
|
'client_name': post.get('client_name', '').strip(),
|
|
'client_address': post.get('client_address', '').strip(),
|
|
'client_unit': post.get('client_unit', '').strip(),
|
|
'client_address_street': post.get('client_address_street', '').strip(),
|
|
'client_address_city': post.get('client_address_city', '').strip(),
|
|
'client_address_province': post.get('client_address_province', '').strip(),
|
|
'client_address_postal': post.get('client_address_postal', '').strip(),
|
|
'client_phone': post.get('client_phone', '').strip(),
|
|
'client_email': post.get('client_email', '').strip(),
|
|
'notes': post.get('notes', '').strip(),
|
|
}
|
|
|
|
# Parse assessment date
|
|
assessment_date = post.get('assessment_date')
|
|
if assessment_date:
|
|
from datetime import date as dt_date
|
|
try:
|
|
vals['assessment_date'] = dt_date.fromisoformat(assessment_date)
|
|
except ValueError:
|
|
vals['assessment_date'] = dt_date.today()
|
|
|
|
# Add type-specific fields
|
|
if assessment_type == 'stairlift_straight':
|
|
vals.update(self._parse_stairlift_straight_fields(post))
|
|
elif assessment_type == 'stairlift_curved':
|
|
vals.update(self._parse_stairlift_curved_fields(post))
|
|
elif assessment_type == 'vpl':
|
|
vals.update(self._parse_vpl_fields(post))
|
|
elif assessment_type == 'ceiling_lift':
|
|
vals.update(self._parse_ceiling_lift_fields(post))
|
|
elif assessment_type == 'ramp':
|
|
vals.update(self._parse_ramp_fields(post))
|
|
elif assessment_type == 'bathroom':
|
|
vals.update(self._parse_bathroom_fields(post))
|
|
elif assessment_type == 'tub_cutout':
|
|
vals.update(self._parse_tub_cutout_fields(post))
|
|
|
|
# Set authorizer if the current user is an authorizer, or from the linked sale order
|
|
if partner.is_authorizer:
|
|
vals['authorizer_id'] = partner.id
|
|
|
|
# Create the assessment
|
|
assessment = Assessment.create(vals)
|
|
_logger.info(f"Created accessibility assessment {assessment.reference} by {request.env.user.name}")
|
|
|
|
# Handle photo attachments - General photos
|
|
photos = post.get('photos', [])
|
|
if photos:
|
|
self._attach_accessibility_photos(assessment, photos, category='general')
|
|
|
|
# Handle top landing photos (for curved stair lifts)
|
|
top_landing_photos = post.get('top_landing_photos', [])
|
|
if top_landing_photos:
|
|
self._attach_accessibility_photos(assessment, top_landing_photos, category='top_landing')
|
|
|
|
# Handle bottom landing photos (for curved stair lifts)
|
|
bottom_landing_photos = post.get('bottom_landing_photos', [])
|
|
if bottom_landing_photos:
|
|
self._attach_accessibility_photos(assessment, bottom_landing_photos, category='bottom_landing')
|
|
|
|
# Handle video attachment
|
|
video_data = post.get('assessment_video')
|
|
video_filename = post.get('assessment_video_filename')
|
|
if video_data:
|
|
self._attach_accessibility_video(assessment, video_data, video_filename)
|
|
|
|
# Complete the assessment and create Sale Order if requested
|
|
create_sale_order = post.get('create_sale_order', True)
|
|
if create_sale_order:
|
|
sale_order = assessment.action_complete()
|
|
return {
|
|
'success': True,
|
|
'assessment_id': assessment.id,
|
|
'assessment_ref': assessment.reference,
|
|
'sale_order_id': sale_order.id,
|
|
'sale_order_name': sale_order.name,
|
|
'message': f'Assessment {assessment.reference} completed. Sale Order {sale_order.name} created.',
|
|
'redirect_url': f'/my/sales/case/{sale_order.id}',
|
|
}
|
|
else:
|
|
return {
|
|
'success': True,
|
|
'assessment_id': assessment.id,
|
|
'assessment_ref': assessment.reference,
|
|
'message': f'Assessment {assessment.reference} saved as draft.',
|
|
'redirect_url': '/my/accessibility/list',
|
|
}
|
|
|
|
except Exception as e:
|
|
_logger.error(f"Error saving accessibility assessment: {e}")
|
|
return {'success': False, 'error': str(e)}
|
|
|
|
def _parse_stairlift_straight_fields(self, post):
|
|
"""Parse straight stair lift specific fields"""
|
|
return {
|
|
'stair_steps': int(post.get('stair_steps', 0) or 0),
|
|
'stair_nose_to_nose': float(post.get('stair_nose_to_nose', 0) or 0),
|
|
'stair_side': post.get('stair_side') or None,
|
|
'stair_style': post.get('stair_style') or None,
|
|
'stair_power_swivel_upstairs': post.get('stair_power_swivel_upstairs') == 'true',
|
|
'stair_power_folding_footrest': post.get('stair_power_folding_footrest') == 'true',
|
|
'stair_manual_length_override': float(post.get('stair_manual_length_override', 0) or 0),
|
|
}
|
|
|
|
def _parse_stairlift_curved_fields(self, post):
|
|
"""Parse curved stair lift specific fields"""
|
|
return {
|
|
'stair_curved_steps': int(post.get('stair_curved_steps', 0) or 0),
|
|
'stair_curves_count': int(post.get('stair_curves_count', 0) or 0),
|
|
'stair_top_landing_type': post.get('stair_top_landing_type') or 'none',
|
|
'stair_bottom_landing_type': post.get('stair_bottom_landing_type') or 'none',
|
|
'top_overrun_custom_length': float(post.get('top_overrun_custom_length', 0) or 0),
|
|
'bottom_overrun_custom_length': float(post.get('bottom_overrun_custom_length', 0) or 0),
|
|
'stair_power_swivel_upstairs': post.get('stair_power_swivel_upstairs') == 'true',
|
|
'stair_power_swivel_downstairs': post.get('stair_power_swivel_downstairs') == 'true',
|
|
'stair_auto_folding_footrest': post.get('stair_auto_folding_footrest') == 'true',
|
|
'stair_auto_folding_hinge': post.get('stair_auto_folding_hinge') == 'true',
|
|
'stair_auto_folding_seat': post.get('stair_auto_folding_seat') == 'true',
|
|
'stair_custom_color': post.get('stair_custom_color') == 'true',
|
|
'stair_additional_charging': post.get('stair_additional_charging') == 'true',
|
|
'stair_charging_with_remote': post.get('stair_charging_with_remote') == 'true',
|
|
'stair_curved_manual_override': float(post.get('stair_curved_manual_override', 0) or 0),
|
|
}
|
|
|
|
def _parse_vpl_fields(self, post):
|
|
"""Parse VPL specific fields"""
|
|
return {
|
|
'vpl_room_width': float(post.get('vpl_room_width', 0) or 0),
|
|
'vpl_room_depth': float(post.get('vpl_room_depth', 0) or 0),
|
|
'vpl_rise_height': float(post.get('vpl_rise_height', 0) or 0),
|
|
'vpl_has_existing_platform': post.get('vpl_has_existing_platform') == 'true',
|
|
'vpl_concrete_depth': float(post.get('vpl_concrete_depth', 0) or 0),
|
|
'vpl_model_type': post.get('vpl_model_type') or None,
|
|
'vpl_has_nearby_plug': post.get('vpl_has_nearby_plug') == 'true',
|
|
'vpl_needs_plug_install': post.get('vpl_needs_plug_install') == 'true',
|
|
'vpl_needs_certification': post.get('vpl_needs_certification') == 'true',
|
|
'vpl_certification_notes': post.get('vpl_certification_notes', '').strip(),
|
|
}
|
|
|
|
def _parse_ceiling_lift_fields(self, post):
|
|
"""Parse ceiling lift specific fields"""
|
|
return {
|
|
'ceiling_track_length': float(post.get('ceiling_track_length', 0) or 0),
|
|
'ceiling_movement_type': post.get('ceiling_movement_type') or None,
|
|
'ceiling_charging_throughout': post.get('ceiling_charging_throughout') == 'true',
|
|
'ceiling_carry_bar': post.get('ceiling_carry_bar') == 'true',
|
|
'ceiling_additional_slings': int(post.get('ceiling_additional_slings', 0) or 0),
|
|
}
|
|
|
|
def _parse_ramp_fields(self, post):
|
|
"""Parse ramp specific fields"""
|
|
return {
|
|
'ramp_height': float(post.get('ramp_height', 0) or 0),
|
|
'ramp_ground_incline': float(post.get('ramp_ground_incline', 0) or 0),
|
|
'ramp_at_door': post.get('ramp_at_door') == 'true',
|
|
'ramp_handrail_height': float(post.get('ramp_handrail_height', 32) or 32),
|
|
'ramp_manual_override': float(post.get('ramp_manual_override', 0) or 0),
|
|
}
|
|
|
|
def _parse_bathroom_fields(self, post):
|
|
"""Parse bathroom modification specific fields"""
|
|
return {
|
|
'bathroom_description': post.get('bathroom_description', '').strip(),
|
|
}
|
|
|
|
def _parse_tub_cutout_fields(self, post):
|
|
"""Parse tub cutout specific fields"""
|
|
return {
|
|
'tub_internal_height': float(post.get('tub_internal_height', 0) or 0),
|
|
'tub_external_height': float(post.get('tub_external_height', 0) or 0),
|
|
'tub_additional_supplies': post.get('tub_additional_supplies', '').strip(),
|
|
}
|
|
|
|
def _attach_accessibility_photos(self, assessment, photos, category='general'):
|
|
"""Attach photos to the accessibility assessment
|
|
|
|
Args:
|
|
assessment: The assessment record
|
|
photos: List of base64 encoded photo data
|
|
category: Photo category (general, top_landing, bottom_landing)
|
|
"""
|
|
Attachment = request.env['ir.attachment'].sudo()
|
|
|
|
# Category prefix for file naming
|
|
category_prefixes = {
|
|
'general': 'Photo',
|
|
'top_landing': 'TopLanding',
|
|
'bottom_landing': 'BottomLanding',
|
|
}
|
|
prefix = category_prefixes.get(category, 'Photo')
|
|
|
|
for i, photo_data in enumerate(photos):
|
|
if not photo_data:
|
|
continue
|
|
|
|
# Handle base64 data URL format
|
|
if ',' in photo_data:
|
|
photo_data = photo_data.split(',')[1]
|
|
|
|
try:
|
|
attachment = Attachment.create({
|
|
'name': f'{prefix}_{i+1}_{assessment.reference}.jpg',
|
|
'type': 'binary',
|
|
'datas': photo_data,
|
|
'res_model': 'fusion.accessibility.assessment',
|
|
'res_id': assessment.id,
|
|
'mimetype': 'image/jpeg',
|
|
'description': f'{category.replace("_", " ").title()} photo for {assessment.reference}',
|
|
})
|
|
_logger.info(f"Attached {category} photo {i+1} to assessment {assessment.reference}")
|
|
except Exception as e:
|
|
_logger.warning(f"Failed to attach {category} photo {i+1}: {e}")
|
|
|
|
def _attach_accessibility_video(self, assessment, video_data, video_filename=None):
|
|
"""Attach a video to the accessibility assessment
|
|
|
|
Args:
|
|
assessment: The assessment record
|
|
video_data: Base64 encoded video data
|
|
video_filename: Original filename (optional)
|
|
"""
|
|
if not video_data:
|
|
return
|
|
|
|
Attachment = request.env['ir.attachment'].sudo()
|
|
|
|
# Handle base64 data URL format
|
|
mimetype = 'video/mp4'
|
|
if isinstance(video_data, str) and ',' in video_data:
|
|
# Extract mimetype from data URL
|
|
header = video_data.split(',')[0]
|
|
if 'video/' in header:
|
|
mimetype = header.split(':')[1].split(';')[0]
|
|
video_data = video_data.split(',')[1]
|
|
|
|
# Determine file extension from mimetype
|
|
extension_map = {
|
|
'video/mp4': '.mp4',
|
|
'video/webm': '.webm',
|
|
'video/quicktime': '.mov',
|
|
'video/x-msvideo': '.avi',
|
|
}
|
|
extension = extension_map.get(mimetype, '.mp4')
|
|
|
|
filename = video_filename or f'Video_{assessment.reference}{extension}'
|
|
|
|
try:
|
|
attachment = Attachment.create({
|
|
'name': filename,
|
|
'type': 'binary',
|
|
'datas': video_data,
|
|
'res_model': 'fusion.accessibility.assessment',
|
|
'res_id': assessment.id,
|
|
'mimetype': mimetype,
|
|
'description': f'Assessment video for {assessment.reference}',
|
|
})
|
|
_logger.info(f"Attached video to assessment {assessment.reference}")
|
|
except Exception as e:
|
|
_logger.warning(f"Failed to attach video to assessment {assessment.reference}: {e}")
|
|
|
|
# =================================================================
|
|
# RENTAL PICKUP INSPECTION (added by fusion_rental)
|
|
# =================================================================
|
|
|
|
@http.route(
|
|
'/my/technician/rental-inspection/<int:task_id>',
|
|
type='http', auth='user', website=True,
|
|
)
|
|
def rental_inspection_page(self, task_id, **kw):
|
|
"""Render the rental pickup inspection form for the technician."""
|
|
user = request.env.user
|
|
task = request.env['fusion.technician.task'].sudo().browse(task_id)
|
|
|
|
if (
|
|
not task.exists()
|
|
or (task.technician_id.id != user.id
|
|
and user.id not in task.additional_technician_ids.ids)
|
|
or task.task_type != 'pickup'
|
|
):
|
|
return request.redirect('/my')
|
|
|
|
return request.render(
|
|
'fusion_rental.portal_rental_inspection',
|
|
{
|
|
'task': task,
|
|
'order': task.sale_order_id,
|
|
'page_name': 'rental_inspection',
|
|
},
|
|
)
|
|
|
|
@http.route(
|
|
'/my/technician/rental-inspection/<int:task_id>/submit',
|
|
type='json', auth='user', methods=['POST'],
|
|
)
|
|
def rental_inspection_submit(self, task_id, **kwargs):
|
|
"""Save the rental inspection results."""
|
|
user = request.env.user
|
|
task = request.env['fusion.technician.task'].sudo().browse(task_id)
|
|
|
|
if (
|
|
not task.exists()
|
|
or (task.technician_id.id != user.id
|
|
and user.id not in task.additional_technician_ids.ids)
|
|
or task.task_type != 'pickup'
|
|
):
|
|
return {'success': False, 'error': 'Access denied.'}
|
|
|
|
condition = kwargs.get('condition', '')
|
|
notes = kwargs.get('notes', '')
|
|
photo_ids = kwargs.get('photo_ids', [])
|
|
|
|
if not condition:
|
|
return {'success': False, 'error': 'Please select a condition.'}
|
|
|
|
vals = {
|
|
'rental_inspection_condition': condition,
|
|
'rental_inspection_notes': notes,
|
|
'rental_inspection_completed': True,
|
|
}
|
|
if photo_ids:
|
|
vals['rental_inspection_photo_ids'] = [(6, 0, photo_ids)]
|
|
task.write(vals)
|
|
|
|
return {
|
|
'success': True,
|
|
'message': 'Inspection saved. You can now complete the task.',
|
|
}
|