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

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

View File

@@ -26,6 +26,7 @@ class AuthorizerPortal(CustomerPortal):
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
@@ -724,7 +725,7 @@ class AuthorizerPortal(CustomerPortal):
'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)
@@ -1090,14 +1091,60 @@ class AuthorizerPortal(CustomerPortal):
_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 not partner.is_technician_portal:
return False
return True
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):
@@ -1159,6 +1206,8 @@ class AuthorizerPortal(CustomerPortal):
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,
@@ -1174,6 +1223,7 @@ class AuthorizerPortal(CustomerPortal):
'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)
@@ -1423,11 +1473,17 @@ class AuthorizerPortal(CustomerPortal):
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, **kw):
"""Handle task status changes (start, complete, en_route, cancel)."""
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()
@@ -1439,21 +1495,39 @@ class AuthorizerPortal(CustomerPortal):
):
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.action_start_en_route()
task.with_context(**location_ctx).action_start_en_route()
elif action == 'start':
task.action_start_task()
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.action_complete_task()
task.with_context(**location_ctx).action_complete_task()
elif action == 'cancel':
task.action_cancel_task()
task.with_context(**location_ctx).action_cancel_task()
else:
return {'success': False, 'error': f'Unknown action: {action}'}
# For completion, also return next task info
result = {
'success': True,
'status': task.status,
@@ -1600,10 +1674,14 @@ class AuthorizerPortal(CustomerPortal):
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, **kw):
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()
@@ -1675,7 +1753,18 @@ class AuthorizerPortal(CustomerPortal):
'completion_notes': completion_html,
'voice_note_transcription': transcription,
})
task.action_complete_task()
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,
@@ -1788,6 +1877,25 @@ class AuthorizerPortal(CustomerPortal):
_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."""
@@ -2055,6 +2163,94 @@ class AuthorizerPortal(CustomerPortal):
_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.