From a3e85a23eff6e2bc0afd982cc5e9f866fae77466 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 1 Mar 2026 14:42:49 -0500 Subject: [PATCH] changes --- fusion_authorizer_portal/__init__.py | 26 ++ fusion_authorizer_portal/__manifest__.py | 7 +- .../controllers/__init__.py | 3 +- .../controllers/portal_main.py | 88 +++++ .../controllers/portal_schedule.py | 327 ++++++++++++++++ .../data/appointment_invite_data.xml | 13 + .../migrations/19.0.2.3.0/end-migrate.py | 36 ++ .../migrations/19.0.2.4.0/end-migrate.py | 36 ++ .../migrations/19.0.2.5.0/end-migrate.py | 36 ++ .../static/src/js/portal_schedule_booking.js | 343 +++++++++++++++++ .../views/portal_schedule.xml | 348 ++++++++++++++++++ .../views/portal_technician_templates.xml | 58 +-- .../views/portal_templates.xml | 245 ++++++++++++ fusion_claims/data/ir_cron_data.xml | 2 +- fusion_claims/models/task_sync.py | 232 +++++++++++- fusion_claims/models/technician_location.py | 19 +- fusion_claims/models/technician_task.py | 289 +++++++++++---- .../static/src/js/fusion_task_map_view.js | 13 +- fusion_clock/__manifest__.py | 1 - fusion_clock/controllers/clock_api.py | 31 +- fusion_clock/models/clock_location.py | 47 +++ .../static/src/js/fusion_clock_kiosk.js | 16 +- .../static/src/js/fusion_clock_portal.js | 23 +- .../static/src/js/fusion_clock_portal_fab.js | 21 +- .../static/src/js/fusion_clock_systray.js | 21 +- fusion_clock/views/clock_location_views.xml | 6 + fusion_rental/data/mail_template_data.xml | 70 ++-- fusion_rental/models/sale_order.py | 121 ++++-- 28 files changed, 2283 insertions(+), 195 deletions(-) create mode 100644 fusion_authorizer_portal/controllers/portal_schedule.py create mode 100644 fusion_authorizer_portal/data/appointment_invite_data.xml create mode 100644 fusion_authorizer_portal/migrations/19.0.2.3.0/end-migrate.py create mode 100644 fusion_authorizer_portal/migrations/19.0.2.4.0/end-migrate.py create mode 100644 fusion_authorizer_portal/migrations/19.0.2.5.0/end-migrate.py create mode 100644 fusion_authorizer_portal/static/src/js/portal_schedule_booking.js create mode 100644 fusion_authorizer_portal/views/portal_schedule.xml diff --git a/fusion_authorizer_portal/__init__.py b/fusion_authorizer_portal/__init__.py index c3d410ea..d95dc367 100644 --- a/fusion_authorizer_portal/__init__.py +++ b/fusion_authorizer_portal/__init__.py @@ -2,3 +2,29 @@ from . import models from . import controllers + + +def _reactivate_views(env): + """Ensure all module views are active after install/update. + + Odoo silently deactivates inherited views when an xpath fails + validation (e.g. parent view structure changed between versions). + Once deactivated, subsequent -u runs never reactivate them. + This hook prevents that from silently breaking the portal. + """ + views = env['ir.ui.view'].sudo().search([ + ('key', 'like', 'fusion_authorizer_portal.%'), + ('active', '=', False), + ]) + if views: + views.write({'active': True}) + env.cr.execute(""" + SELECT key FROM ir_ui_view + WHERE key LIKE 'fusion_authorizer_portal.%%' + AND id = ANY(%s) + """, [views.ids]) + keys = [r[0] for r in env.cr.fetchall()] + import logging + logging.getLogger(__name__).warning( + "Reactivated %d deactivated views: %s", len(keys), keys + ) diff --git a/fusion_authorizer_portal/__manifest__.py b/fusion_authorizer_portal/__manifest__.py index b9776076..2e3431b7 100644 --- a/fusion_authorizer_portal/__manifest__.py +++ b/fusion_authorizer_portal/__manifest__.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- { 'name': 'Fusion Authorizer & Sales Portal', - 'version': '19.0.2.2.0', + 'version': '19.0.2.5.0', 'category': 'Sales/Portal', 'summary': 'Portal for Authorizers (OTs) and Sales Reps with Assessment Forms', 'description': """ @@ -50,6 +50,7 @@ This module provides external portal access for: 'website', 'mail', 'calendar', + 'appointment', 'knowledge', 'fusion_claims', ], @@ -62,6 +63,7 @@ This module provides external portal access for: 'data/portal_menu_data.xml', 'data/ir_actions_server_data.xml', 'data/welcome_articles.xml', + 'data/appointment_invite_data.xml', # Views 'views/res_partner_views.xml', 'views/sale_order_views.xml', @@ -77,6 +79,7 @@ This module provides external portal access for: 'views/portal_technician_templates.xml', 'views/portal_book_assessment.xml', 'views/portal_repair_form.xml', + 'views/portal_schedule.xml', ], 'assets': { 'web.assets_backend': [ @@ -93,9 +96,11 @@ This module provides external portal access for: 'fusion_authorizer_portal/static/src/js/pdf_field_editor.js', 'fusion_authorizer_portal/static/src/js/technician_push.js', 'fusion_authorizer_portal/static/src/js/technician_location.js', + 'fusion_authorizer_portal/static/src/js/portal_schedule_booking.js', ], }, 'images': ['static/description/icon.png'], + 'post_init_hook': '_reactivate_views', 'installable': True, 'application': False, 'auto_install': False, diff --git a/fusion_authorizer_portal/controllers/__init__.py b/fusion_authorizer_portal/controllers/__init__.py index 25941d4d..92e669f9 100644 --- a/fusion_authorizer_portal/controllers/__init__.py +++ b/fusion_authorizer_portal/controllers/__init__.py @@ -3,4 +3,5 @@ from . import portal_main from . import portal_assessment from . import pdf_editor -from . import portal_repair \ No newline at end of file +from . import portal_repair +from . import portal_schedule \ No newline at end of file diff --git a/fusion_authorizer_portal/controllers/portal_main.py b/fusion_authorizer_portal/controllers/portal_main.py index e923f3f9..0e62b4a0 100644 --- a/fusion_authorizer_portal/controllers/portal_main.py +++ b/fusion_authorizer_portal/controllers/portal_main.py @@ -2137,6 +2137,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//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//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. diff --git a/fusion_authorizer_portal/controllers/portal_schedule.py b/fusion_authorizer_portal/controllers/portal_schedule.py new file mode 100644 index 00000000..49f793e1 --- /dev/null +++ b/fusion_authorizer_portal/controllers/portal_schedule.py @@ -0,0 +1,327 @@ +# -*- coding: utf-8 -*- + +from odoo import http, _, fields +from odoo.http import request +from odoo.addons.portal.controllers.portal import CustomerPortal +from odoo.exceptions import AccessError, ValidationError +from datetime import datetime, timedelta +import json +import logging +import pytz + +_logger = logging.getLogger(__name__) + + +class PortalSchedule(CustomerPortal): + """Portal controller for appointment scheduling and calendar management.""" + + def _get_schedule_values(self): + """Common values for schedule pages.""" + 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') + gradient = 'linear-gradient(135deg, %s 0%%, %s 60%%, %s 100%%)' % (g_start, g_mid, g_end) + google_maps_api_key = ICP.get_param('fusion_claims.google_maps_api_key', '') + + return { + 'portal_gradient': gradient, + 'google_maps_api_key': google_maps_api_key, + } + + def _get_user_timezone(self): + tz_name = request.env.user.tz or 'America/Toronto' + try: + return pytz.timezone(tz_name) + except pytz.exceptions.UnknownTimeZoneError: + return pytz.timezone('America/Toronto') + + def _get_appointment_types(self): + """Get appointment types available to the current user.""" + return request.env['appointment.type'].sudo().search([ + ('staff_user_ids', 'in', [request.env.user.id]), + ]) + + @http.route(['/my/schedule'], type='http', auth='user', website=True) + def schedule_page(self, **kw): + """Schedule overview: upcoming appointments and shareable link.""" + partner = request.env.user.partner_id + user = request.env.user + now = fields.Datetime.now() + + upcoming_events = request.env['calendar.event'].sudo().search([ + ('partner_ids', 'in', [partner.id]), + ('start', '>=', now), + ], order='start asc', limit=20) + + today_events = request.env['calendar.event'].sudo().search([ + ('partner_ids', 'in', [partner.id]), + ('start', '>=', now.replace(hour=0, minute=0, second=0)), + ('start', '<', (now + timedelta(days=1)).replace(hour=0, minute=0, second=0)), + ], order='start asc') + + invite = request.env['appointment.invite'].sudo().search([ + ('staff_user_ids', 'in', [user.id]), + ], limit=1) + share_url = invite.book_url if invite else '' + + appointment_types = self._get_appointment_types() + tz = self._get_user_timezone() + + values = self._get_schedule_values() + values.update({ + 'page_name': 'schedule', + 'upcoming_events': upcoming_events, + 'today_events': today_events, + 'share_url': share_url, + 'appointment_types': appointment_types, + 'user_tz': tz, + 'now': now, + }) + return request.render('fusion_authorizer_portal.portal_schedule_page', values) + + @http.route(['/my/schedule/book'], type='http', auth='user', website=True) + def schedule_book(self, appointment_type_id=None, **kw): + """Booking form for a new appointment.""" + appointment_types = self._get_appointment_types() + if not appointment_types: + return request.redirect('/my/schedule') + + if appointment_type_id: + selected_type = request.env['appointment.type'].sudo().browse(int(appointment_type_id)) + if not selected_type.exists(): + selected_type = appointment_types[0] + else: + selected_type = appointment_types[0] + + values = self._get_schedule_values() + values.update({ + 'page_name': 'schedule_book', + 'appointment_types': appointment_types, + 'selected_type': selected_type, + 'now': fields.Datetime.now(), + 'error': kw.get('error'), + 'success': kw.get('success'), + }) + return request.render('fusion_authorizer_portal.portal_schedule_book', values) + + @http.route('/my/schedule/available-slots', type='json', auth='user', website=True) + def schedule_available_slots(self, appointment_type_id, selected_date=None, **kw): + """JSON-RPC endpoint: return available time slots for a date.""" + appointment_type = request.env['appointment.type'].sudo().browse(int(appointment_type_id)) + if not appointment_type.exists(): + return {'error': 'Appointment type not found', 'slots': []} + + user = request.env.user + tz_name = user.tz or 'America/Toronto' + tz = self._get_user_timezone() + + ref_date = fields.Datetime.now() + slot_data = appointment_type._get_appointment_slots( + timezone=tz_name, + filter_users=request.env['res.users'].sudo().browse(user.id), + asked_capacity=1, + reference_date=ref_date, + ) + + filtered_slots = [] + target_date = None + if selected_date: + try: + target_date = datetime.strptime(selected_date, '%Y-%m-%d').date() + except ValueError: + return {'error': 'Invalid date format', 'slots': []} + + for month_data in slot_data: + for week in month_data.get('weeks', []): + for day_info in week: + if not day_info: + continue + day = day_info.get('day') + if target_date and day != target_date: + continue + for slot in day_info.get('slots', []): + slot_dt_str = slot.get('datetime') + if not slot_dt_str: + continue + filtered_slots.append({ + 'datetime': slot_dt_str, + 'start_hour': slot.get('start_hour', ''), + 'end_hour': slot.get('end_hour', ''), + 'duration': slot.get('slot_duration', str(appointment_type.appointment_duration)), + 'staff_user_id': slot.get('staff_user_id', user.id), + }) + + available_dates = [] + if not target_date: + seen = set() + for month_data in slot_data: + for week in month_data.get('weeks', []): + for day_info in week: + if not day_info: + continue + day = day_info.get('day') + if day and day_info.get('slots') and str(day) not in seen: + seen.add(str(day)) + available_dates.append(str(day)) + + return { + 'slots': filtered_slots, + 'available_dates': sorted(available_dates), + 'duration': appointment_type.appointment_duration, + 'timezone': tz_name, + } + + @http.route('/my/schedule/week-events', type='json', auth='user', website=True) + def schedule_week_events(self, selected_date, **kw): + """Return the user's calendar events for the Mon-Sun week containing selected_date.""" + try: + target = datetime.strptime(selected_date, '%Y-%m-%d').date() + except (ValueError, TypeError): + return {'error': 'Invalid date format', 'events': [], 'week_days': []} + + monday = target - timedelta(days=target.weekday()) + sunday = monday + timedelta(days=6) + + partner = request.env.user.partner_id + tz = self._get_user_timezone() + + monday_start_local = tz.localize(datetime.combine(monday, datetime.min.time())) + sunday_end_local = tz.localize(datetime.combine(sunday, datetime.max.time())) + monday_start_utc = monday_start_local.astimezone(pytz.utc).replace(tzinfo=None) + sunday_end_utc = sunday_end_local.astimezone(pytz.utc).replace(tzinfo=None) + + events = request.env['calendar.event'].sudo().search([ + ('partner_ids', 'in', [partner.id]), + ('start', '>=', monday_start_utc), + ('start', '<=', sunday_end_utc), + ], order='start asc') + + event_list = [] + for ev in events: + start_utc = ev.start + stop_utc = ev.stop + start_local = pytz.utc.localize(start_utc).astimezone(tz) + stop_local = pytz.utc.localize(stop_utc).astimezone(tz) + event_list.append({ + 'name': ev.name or '', + 'start': start_local.strftime('%Y-%m-%d %H:%M'), + 'end': stop_local.strftime('%Y-%m-%d %H:%M'), + 'start_time': start_local.strftime('%I:%M %p'), + 'end_time': stop_local.strftime('%I:%M %p'), + 'day_of_week': start_local.weekday(), + 'date': start_local.strftime('%Y-%m-%d'), + 'location': ev.location or '', + 'duration': ev.duration, + }) + + day_labels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] + week_days = [] + for i in range(7): + day = monday + timedelta(days=i) + week_days.append({ + 'label': day_labels[i], + 'date': day.strftime('%Y-%m-%d'), + 'day_num': day.day, + 'is_selected': day == target, + }) + + return { + 'events': event_list, + 'week_days': week_days, + 'selected_date': selected_date, + } + + @http.route('/my/schedule/book/submit', type='http', auth='user', website=True, methods=['POST']) + def schedule_book_submit(self, **post): + """Process the booking form submission.""" + appointment_type_id = int(post.get('appointment_type_id', 0)) + appointment_type = request.env['appointment.type'].sudo().browse(appointment_type_id) + if not appointment_type.exists(): + return request.redirect('/my/schedule/book?error=Invalid+appointment+type') + + client_name = (post.get('client_name') or '').strip() + client_street = (post.get('client_street') or '').strip() + client_city = (post.get('client_city') or '').strip() + client_province = (post.get('client_province') or '').strip() + client_postal = (post.get('client_postal') or '').strip() + notes = (post.get('notes') or '').strip() + slot_datetime = (post.get('slot_datetime') or '').strip() + slot_duration = post.get('slot_duration', str(appointment_type.appointment_duration)) + + if not client_name or not slot_datetime: + return request.redirect('/my/schedule/book?error=Client+name+and+time+slot+are+required') + + user = request.env.user + tz = self._get_user_timezone() + + try: + start_dt_naive = datetime.strptime(slot_datetime, '%Y-%m-%d %H:%M:%S') + start_dt_local = tz.localize(start_dt_naive) + start_dt_utc = start_dt_local.astimezone(pytz.utc).replace(tzinfo=None) + except (ValueError, Exception) as e: + _logger.error("Failed to parse slot datetime %s: %s", slot_datetime, e) + return request.redirect('/my/schedule/book?error=Invalid+time+slot') + + duration = float(slot_duration) + stop_dt_utc = start_dt_utc + timedelta(hours=duration) + + is_valid = appointment_type._check_appointment_is_valid_slot( + staff_user=user, + resources=request.env['appointment.resource'], + asked_capacity=1, + timezone=str(tz), + start_dt=start_dt_utc, + duration=duration, + allday=False, + ) + if not is_valid: + return request.redirect('/my/schedule/book?error=This+slot+is+no+longer+available.+Please+choose+another+time.') + + address_parts = [p for p in [client_street, client_city, client_province, client_postal] if p] + location = ', '.join(address_parts) + + description_lines = [] + if client_name: + description_lines.append(f"Client: {client_name}") + if location: + description_lines.append(f"Address: {location}") + if notes: + description_lines.append(f"Notes: {notes}") + description = '\n'.join(description_lines) + + event_name = f"{client_name} - {appointment_type.name}" + + booking_line_values = [{ + 'appointment_user_id': user.id, + 'capacity_reserved': 1, + 'capacity_used': 1, + }] + + try: + event_vals = appointment_type._prepare_calendar_event_values( + asked_capacity=1, + booking_line_values=booking_line_values, + description=description, + duration=duration, + allday=False, + appointment_invite=request.env['appointment.invite'], + guests=request.env['res.partner'], + name=event_name, + customer=user.partner_id, + staff_user=user, + start=start_dt_utc, + stop=stop_dt_utc, + ) + event_vals['location'] = location + event = request.env['calendar.event'].sudo().create(event_vals) + + _logger.info( + "Appointment booked: %s at %s (event ID: %s)", + event_name, start_dt_utc, event.id, + ) + except Exception as e: + _logger.error("Failed to create appointment: %s", e) + return request.redirect('/my/schedule/book?error=Failed+to+create+appointment.+Please+try+again.') + + return request.redirect('/my/schedule?success=Appointment+booked+successfully') diff --git a/fusion_authorizer_portal/data/appointment_invite_data.xml b/fusion_authorizer_portal/data/appointment_invite_data.xml new file mode 100644 index 00000000..5d3be764 --- /dev/null +++ b/fusion_authorizer_portal/data/appointment_invite_data.xml @@ -0,0 +1,13 @@ + + + + + + + book-appointment + + + + diff --git a/fusion_authorizer_portal/migrations/19.0.2.3.0/end-migrate.py b/fusion_authorizer_portal/migrations/19.0.2.3.0/end-migrate.py new file mode 100644 index 00000000..81461694 --- /dev/null +++ b/fusion_authorizer_portal/migrations/19.0.2.3.0/end-migrate.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +"""Reactivate any views that Odoo silently deactivated. + +Odoo deactivates inherited views when xpath validation fails (e.g. parent +view structure changed between versions). Once deactivated, subsequent +``-u`` runs never reactivate them. This end-migration script catches +that scenario on every version bump. +""" +import logging + +_logger = logging.getLogger(__name__) + +MODULE = 'fusion_authorizer_portal' + + +def migrate(cr, version): + if not version: + return + + cr.execute(""" + UPDATE ir_ui_view v + SET active = true + FROM ir_model_data d + WHERE d.res_id = v.id + AND d.model = 'ir.ui.view' + AND d.module = %s + AND v.active = false + RETURNING v.id, v.name, v.key + """, [MODULE]) + + rows = cr.fetchall() + if rows: + _logger.warning( + "Reactivated %d deactivated views for %s: %s", + len(rows), MODULE, [r[2] or r[1] for r in rows], + ) diff --git a/fusion_authorizer_portal/migrations/19.0.2.4.0/end-migrate.py b/fusion_authorizer_portal/migrations/19.0.2.4.0/end-migrate.py new file mode 100644 index 00000000..81461694 --- /dev/null +++ b/fusion_authorizer_portal/migrations/19.0.2.4.0/end-migrate.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +"""Reactivate any views that Odoo silently deactivated. + +Odoo deactivates inherited views when xpath validation fails (e.g. parent +view structure changed between versions). Once deactivated, subsequent +``-u`` runs never reactivate them. This end-migration script catches +that scenario on every version bump. +""" +import logging + +_logger = logging.getLogger(__name__) + +MODULE = 'fusion_authorizer_portal' + + +def migrate(cr, version): + if not version: + return + + cr.execute(""" + UPDATE ir_ui_view v + SET active = true + FROM ir_model_data d + WHERE d.res_id = v.id + AND d.model = 'ir.ui.view' + AND d.module = %s + AND v.active = false + RETURNING v.id, v.name, v.key + """, [MODULE]) + + rows = cr.fetchall() + if rows: + _logger.warning( + "Reactivated %d deactivated views for %s: %s", + len(rows), MODULE, [r[2] or r[1] for r in rows], + ) diff --git a/fusion_authorizer_portal/migrations/19.0.2.5.0/end-migrate.py b/fusion_authorizer_portal/migrations/19.0.2.5.0/end-migrate.py new file mode 100644 index 00000000..81461694 --- /dev/null +++ b/fusion_authorizer_portal/migrations/19.0.2.5.0/end-migrate.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +"""Reactivate any views that Odoo silently deactivated. + +Odoo deactivates inherited views when xpath validation fails (e.g. parent +view structure changed between versions). Once deactivated, subsequent +``-u`` runs never reactivate them. This end-migration script catches +that scenario on every version bump. +""" +import logging + +_logger = logging.getLogger(__name__) + +MODULE = 'fusion_authorizer_portal' + + +def migrate(cr, version): + if not version: + return + + cr.execute(""" + UPDATE ir_ui_view v + SET active = true + FROM ir_model_data d + WHERE d.res_id = v.id + AND d.model = 'ir.ui.view' + AND d.module = %s + AND v.active = false + RETURNING v.id, v.name, v.key + """, [MODULE]) + + rows = cr.fetchall() + if rows: + _logger.warning( + "Reactivated %d deactivated views for %s: %s", + len(rows), MODULE, [r[2] or r[1] for r in rows], + ) diff --git a/fusion_authorizer_portal/static/src/js/portal_schedule_booking.js b/fusion_authorizer_portal/static/src/js/portal_schedule_booking.js new file mode 100644 index 00000000..40e0c1c5 --- /dev/null +++ b/fusion_authorizer_portal/static/src/js/portal_schedule_booking.js @@ -0,0 +1,343 @@ +(function () { + 'use strict'; + + var dateInput = document.getElementById('bookingDate'); + var slotsContainer = document.getElementById('slotsContainer'); + var slotsGrid = document.getElementById('slotsGrid'); + var slotsLoading = document.getElementById('slotsLoading'); + var noSlots = document.getElementById('noSlots'); + var slotDatetimeInput = document.getElementById('slotDatetime'); + var slotDurationInput = document.getElementById('slotDuration'); + var submitBtn = document.getElementById('btnSubmitBooking'); + var typeSelect = document.getElementById('appointmentTypeSelect'); + var selectedSlotBtn = null; + + var weekContainer = document.getElementById('weekCalendarContainer'); + var weekLoading = document.getElementById('weekCalendarLoading'); + var weekGrid = document.getElementById('weekCalendarGrid'); + var weekHeader = document.getElementById('weekCalendarHeader'); + var weekBody = document.getElementById('weekCalendarBody'); + var weekEmpty = document.getElementById('weekCalendarEmpty'); + + function getAppointmentTypeId() { + if (typeSelect) return typeSelect.value; + var hidden = document.querySelector('input[name="appointment_type_id"]'); + return hidden ? hidden.value : null; + } + + function escapeHtml(str) { + var div = document.createElement('div'); + div.textContent = str; + return div.innerHTML; + } + + function truncate(str, max) { + if (!str) return ''; + return str.length > max ? str.substring(0, max) + '...' : str; + } + + function fetchWeekEvents(date) { + if (!weekContainer || !date) return; + + weekContainer.style.display = 'block'; + weekLoading.style.display = 'block'; + weekGrid.style.display = 'none'; + weekEmpty.style.display = 'none'; + + fetch('/my/schedule/week-events', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'call', + params: { selected_date: date }, + }), + }) + .then(function (resp) { return resp.json(); }) + .then(function (data) { + weekLoading.style.display = 'none'; + var result = data.result || {}; + var events = result.events || []; + var weekDays = result.week_days || []; + + if (result.error || !weekDays.length) { + weekEmpty.style.display = 'block'; + return; + } + + renderWeekCalendar(weekDays, events, date); + }) + .catch(function () { + weekLoading.style.display = 'none'; + weekEmpty.textContent = 'Failed to load calendar. Please try again.'; + weekEmpty.style.display = 'block'; + }); + } + + function renderWeekCalendar(weekDays, events, selectedDate) { + weekHeader.innerHTML = ''; + weekBody.innerHTML = ''; + + var eventsByDate = {}; + events.forEach(function (ev) { + if (!eventsByDate[ev.date]) eventsByDate[ev.date] = []; + eventsByDate[ev.date].push(ev); + }); + + var hasAnyEvents = events.length > 0; + + weekDays.forEach(function (day) { + var isSelected = day.date === selectedDate; + var isWeekend = day.label === 'Sat' || day.label === 'Sun'; + var dayEvents = eventsByDate[day.date] || []; + + var headerCell = document.createElement('div'); + headerCell.className = 'text-center py-2 flex-fill'; + headerCell.style.cssText = 'min-width: 0; font-size: 12px; border-right: 1px solid #dee2e6;'; + if (isSelected) { + headerCell.style.backgroundColor = '#e8f4fd'; + } + if (isWeekend) { + headerCell.style.opacity = '0.6'; + } + + var labelEl = document.createElement('div'); + labelEl.className = 'fw-semibold text-muted'; + labelEl.textContent = day.label; + + var numEl = document.createElement('div'); + numEl.className = isSelected ? 'fw-bold text-primary' : 'fw-semibold'; + numEl.style.fontSize = '14px'; + numEl.textContent = day.day_num; + + headerCell.appendChild(labelEl); + headerCell.appendChild(numEl); + weekHeader.appendChild(headerCell); + + var bodyCell = document.createElement('div'); + bodyCell.className = 'flex-fill p-1'; + bodyCell.style.cssText = 'min-width: 0; min-height: 70px; border-right: 1px solid #dee2e6; overflow: hidden;'; + if (isSelected) { + bodyCell.style.backgroundColor = '#f0f8ff'; + } + + if (dayEvents.length) { + dayEvents.forEach(function (ev) { + var card = document.createElement('div'); + card.className = 'mb-1 px-1 py-1 rounded'; + card.style.cssText = 'font-size: 11px; background: #eef6ff; border-left: 3px solid #3a8fb7; overflow: hidden; cursor: default;'; + card.title = ev.start_time + ' - ' + ev.end_time + '\n' + ev.name + (ev.location ? '\n' + ev.location : ''); + + var timeEl = document.createElement('div'); + timeEl.className = 'fw-semibold text-primary'; + timeEl.style.fontSize = '10px'; + timeEl.textContent = ev.start_time; + + var nameEl = document.createElement('div'); + nameEl.className = 'text-truncate'; + nameEl.style.fontSize = '10px'; + nameEl.textContent = truncate(ev.name, 18); + + card.appendChild(timeEl); + card.appendChild(nameEl); + bodyCell.appendChild(card); + }); + } + + weekBody.appendChild(bodyCell); + }); + + if (hasAnyEvents) { + weekGrid.style.display = 'block'; + weekEmpty.style.display = 'none'; + } else { + weekGrid.style.display = 'none'; + weekEmpty.style.display = 'block'; + } + } + + function fetchSlots(date) { + var typeId = getAppointmentTypeId(); + if (!typeId || !date) return; + + slotsContainer.style.display = 'block'; + slotsLoading.style.display = 'block'; + slotsGrid.innerHTML = ''; + noSlots.style.display = 'none'; + slotDatetimeInput.value = ''; + if (submitBtn) submitBtn.disabled = true; + selectedSlotBtn = null; + + fetch('/my/schedule/available-slots', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + jsonrpc: '2.0', + method: 'call', + params: { + appointment_type_id: parseInt(typeId), + selected_date: date, + }, + }), + }) + .then(function (resp) { return resp.json(); }) + .then(function (data) { + slotsLoading.style.display = 'none'; + var result = data.result || {}; + var slots = result.slots || []; + + if (result.error) { + noSlots.textContent = result.error; + noSlots.style.display = 'block'; + return; + } + + if (!slots.length) { + noSlots.style.display = 'block'; + return; + } + + var morningSlots = []; + var afternoonSlots = []; + slots.forEach(function (slot) { + var hour = parseInt(slot.start_hour); + if (isNaN(hour)) { + var match = slot.start_hour.match(/(\d+)/); + hour = match ? parseInt(match[1]) : 0; + if (slot.start_hour.toLowerCase().indexOf('pm') > -1 && hour !== 12) hour += 12; + if (slot.start_hour.toLowerCase().indexOf('am') > -1 && hour === 12) hour = 0; + } + if (hour < 12) { + morningSlots.push(slot); + } else { + afternoonSlots.push(slot); + } + }); + + function renderGroup(label, icon, groupSlots) { + if (!groupSlots.length) return; + var header = document.createElement('div'); + header.className = 'w-100 mt-2 mb-1'; + header.innerHTML = '' + label + ''; + slotsGrid.appendChild(header); + + groupSlots.forEach(function (slot) { + var btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'btn btn-outline-primary btn-sm slot-btn'; + btn.style.cssText = 'min-width: 100px; border-radius: 8px; padding: 8px 14px;'; + btn.textContent = slot.start_hour; + btn.dataset.datetime = slot.datetime; + btn.dataset.duration = slot.duration; + btn.addEventListener('click', function () { + if (selectedSlotBtn) { + selectedSlotBtn.classList.remove('btn-primary'); + selectedSlotBtn.classList.add('btn-outline-primary'); + } + btn.classList.remove('btn-outline-primary'); + btn.classList.add('btn-primary'); + selectedSlotBtn = btn; + slotDatetimeInput.value = slot.datetime; + slotDurationInput.value = slot.duration; + if (submitBtn) submitBtn.disabled = false; + }); + slotsGrid.appendChild(btn); + }); + } + + renderGroup('Morning', 'fa-sun-o', morningSlots); + renderGroup('Afternoon', 'fa-cloud', afternoonSlots); + }) + .catch(function (err) { + slotsLoading.style.display = 'none'; + noSlots.textContent = 'Failed to load slots. Please try again.'; + noSlots.style.display = 'block'; + }); + } + + if (dateInput) { + dateInput.addEventListener('change', function () { + var val = this.value; + fetchWeekEvents(val); + fetchSlots(val); + }); + } + + if (typeSelect) { + typeSelect.addEventListener('change', function () { + if (dateInput && dateInput.value) { + fetchSlots(dateInput.value); + } + }); + } + + var bookingForm = document.getElementById('bookingForm'); + if (bookingForm) { + bookingForm.addEventListener('submit', function (e) { + if (!slotDatetimeInput || !slotDatetimeInput.value) { + e.preventDefault(); + alert('Please select a time slot before booking.'); + return false; + } + var clientName = bookingForm.querySelector('input[name="client_name"]'); + if (!clientName || !clientName.value.trim()) { + e.preventDefault(); + alert('Please enter the client name.'); + return false; + } + if (submitBtn) { + submitBtn.disabled = true; + submitBtn.innerHTML = ' Booking...'; + } + }); + } + + window.initScheduleAddressAutocomplete = function () { + var streetInput = document.getElementById('clientStreet'); + if (!streetInput) return; + + var autocomplete = new google.maps.places.Autocomplete(streetInput, { + componentRestrictions: { country: 'ca' }, + types: ['address'], + }); + + autocomplete.addListener('place_changed', function () { + var place = autocomplete.getPlace(); + if (!place.address_components) return; + + var streetNumber = ''; + var streetName = ''; + var city = ''; + var province = ''; + var postalCode = ''; + + for (var i = 0; i < place.address_components.length; i++) { + var component = place.address_components[i]; + var types = component.types; + + if (types.indexOf('street_number') > -1) { + streetNumber = component.long_name; + } else if (types.indexOf('route') > -1) { + streetName = component.long_name; + } else if (types.indexOf('locality') > -1) { + city = component.long_name; + } else if (types.indexOf('administrative_area_level_1') > -1) { + province = component.long_name; + } else if (types.indexOf('postal_code') > -1) { + postalCode = component.long_name; + } + } + + streetInput.value = (streetNumber + ' ' + streetName).trim(); + var cityInput = document.getElementById('clientCity'); + if (cityInput) cityInput.value = city; + var provInput = document.getElementById('clientProvince'); + if (provInput) provInput.value = province; + var postalInput = document.getElementById('clientPostal'); + if (postalInput) postalInput.value = postalCode; + }); + }; + +})(); diff --git a/fusion_authorizer_portal/views/portal_schedule.xml b/fusion_authorizer_portal/views/portal_schedule.xml new file mode 100644 index 00000000..eed387c7 --- /dev/null +++ b/fusion_authorizer_portal/views/portal_schedule.xml @@ -0,0 +1,348 @@ + + + + + + + + + + + + diff --git a/fusion_authorizer_portal/views/portal_technician_templates.xml b/fusion_authorizer_portal/views/portal_technician_templates.xml index 7e5c4bc4..072891ba 100644 --- a/fusion_authorizer_portal/views/portal_technician_templates.xml +++ b/fusion_authorizer_portal/views/portal_technician_templates.xml @@ -86,7 +86,7 @@ - -

+

@@ -103,7 +103,7 @@ class="tech-action-btn tech-btn-complete"> Complete - Call @@ -128,7 +128,7 @@ -

+

min drive @@ -192,7 +192,7 @@ - +

@@ -525,7 +525,7 @@ - +
@@ -620,14 +620,14 @@ Navigate - - + + Call - - + + Text @@ -665,10 +665,10 @@
@@ -718,8 +718,8 @@
- - + +
@@ -727,14 +727,16 @@
Proof of Delivery
- + + + Signature collected - + Re-collect Signature - + Collect Signature @@ -846,6 +848,11 @@ t-att-data-task-id="task.id"> Start + Start +
- +
- +
@@ -1551,7 +1563,7 @@ - +
diff --git a/fusion_authorizer_portal/views/portal_templates.xml b/fusion_authorizer_portal/views/portal_templates.xml index 2b50eba1..740c0202 100644 --- a/fusion_authorizer_portal/views/portal_templates.xml +++ b/fusion_authorizer_portal/views/portal_templates.xml @@ -189,6 +189,23 @@
+ + +
@@ -3929,4 +3946,232 @@ + + + + + diff --git a/fusion_claims/data/ir_cron_data.xml b/fusion_claims/data/ir_cron_data.xml index 8beb2efb..819b6794 100644 --- a/fusion_claims/data/ir_cron_data.xml +++ b/fusion_claims/data/ir_cron_data.xml @@ -163,7 +163,7 @@ code model._cron_pull_remote_tasks() - 5 + 2 minutes True diff --git a/fusion_claims/models/task_sync.py b/fusion_claims/models/task_sync.py index e9851191..fdcee2d7 100644 --- a/fusion_claims/models/task_sync.py +++ b/fusion_claims/models/task_sync.py @@ -30,9 +30,13 @@ SYNC_TASK_FIELDS = [ 'task_type', 'status', 'scheduled_date', 'time_start', 'time_end', 'duration_hours', 'address_street', 'address_street2', 'address_city', 'address_zip', - 'address_lat', 'address_lng', 'priority', 'partner_id', + 'address_state_id', 'address_buzz_code', + 'address_lat', 'address_lng', 'priority', 'partner_id', 'partner_phone', + 'pod_required', 'description', ] +TERMINAL_STATUSES = ('completed', 'cancelled') + class FusionTaskSyncConfig(models.Model): _name = 'fusion.task.sync.config' @@ -268,17 +272,58 @@ class FusionTaskSyncConfig(models.Model): self._rpc('fusion.technician.task', 'write', [existing, {'status': 'cancelled', 'active': False}], ctx) + @api.model + def _push_shadow_status(self, shadow_tasks): + """Push local status changes on shadow tasks back to their source instance. + + When a tech completes (or cancels) a shadow task locally, update the + original task on the remote instance so both sides stay in sync. + """ + configs = self.sudo().search([('active', '=', True)]) + config_by_instance = {c.instance_id: c for c in configs} + ctx = {'context': {'skip_task_sync': True, 'skip_travel_recalc': True}} + + for task in shadow_tasks: + config = config_by_instance.get(task.x_fc_sync_source) + if not config or not task.x_fc_sync_remote_id: + continue + try: + update_vals = {'status': task.status} + if task.status == 'completed' and task.completion_datetime: + update_vals['completion_datetime'] = str(task.completion_datetime) + config._rpc( + 'fusion.technician.task', 'write', + [[task.x_fc_sync_remote_id], update_vals], ctx) + _logger.info( + "Pushed status '%s' for shadow task %s back to %s (remote id %d)", + task.status, task.name, config.name, task.x_fc_sync_remote_id) + if task.status == 'completed': + try: + config._rpc( + 'fusion.technician.task', + '_notify_scheduler_on_completion', + [[task.x_fc_sync_remote_id]]) + except Exception: + _logger.warning( + "Could not trigger completion notification on remote for %s", + task.name) + except Exception: + _logger.exception( + "Failed to push status for shadow task %s to %s", + task.name, config.name) + # ------------------------------------------------------------------ # PULL: cron-based full reconciliation # ------------------------------------------------------------------ @api.model def _cron_pull_remote_tasks(self): - """Cron job: pull tasks from all active remote instances.""" + """Cron job: pull tasks and technician locations from all active remote instances.""" configs = self.sudo().search([('active', '=', True)]) for config in configs: try: config._pull_tasks_from_remote() + config._pull_technician_locations() config.sudo().write({ 'last_sync': fields.Datetime.now(), 'last_sync_error': False, @@ -288,7 +333,11 @@ class FusionTaskSyncConfig(models.Model): config.sudo().write({'last_sync_error': str(e)}) def _pull_tasks_from_remote(self): - """Pull all active tasks for matched technicians from the remote instance.""" + """Pull all active tasks for matched technicians from the remote instance. + + After syncing, recalculates travel chains for all affected tech+date + combos so route planning accounts for both local and shadow tasks. + """ self.ensure_one() local_syncid_to_uid = self._get_local_syncid_to_uid() if not local_syncid_to_uid: @@ -325,6 +374,8 @@ class FusionTaskSyncConfig(models.Model): skip_task_sync=True, skip_travel_recalc=True) remote_uuids = set() + affected_combos = set() + for rt in remote_tasks: sync_uuid = rt.get('x_fc_sync_uuid') if not sync_uuid: @@ -340,6 +391,12 @@ class FusionTaskSyncConfig(models.Model): partner_raw = rt.get('partner_id') client_name = partner_raw[1] if isinstance(partner_raw, (list, tuple)) and len(partner_raw) > 1 else '' + client_phone = rt.get('partner_phone', '') or '' + + state_raw = rt.get('address_state_id') + state_name = '' + if isinstance(state_raw, (list, tuple)) and len(state_raw) > 1: + state_name = state_raw[1] # Map additional technicians from remote to local local_additional_ids = [] @@ -352,6 +409,8 @@ class FusionTaskSyncConfig(models.Model): if local_add_uid: local_additional_ids.append(local_add_uid) + sched_date = rt.get('scheduled_date') + vals = { 'x_fc_sync_uuid': sync_uuid, 'x_fc_sync_source': self.instance_id, @@ -361,7 +420,7 @@ class FusionTaskSyncConfig(models.Model): 'additional_technician_ids': [(6, 0, local_additional_ids)], 'task_type': rt.get('task_type', 'delivery'), 'status': rt.get('status', 'scheduled'), - 'scheduled_date': rt.get('scheduled_date'), + 'scheduled_date': sched_date, 'time_start': rt.get('time_start', 9.0), 'time_end': rt.get('time_end', 10.0), 'duration_hours': rt.get('duration_hours', 1.0), @@ -369,19 +428,36 @@ class FusionTaskSyncConfig(models.Model): 'address_street2': rt.get('address_street2', ''), 'address_city': rt.get('address_city', ''), 'address_zip': rt.get('address_zip', ''), + 'address_buzz_code': rt.get('address_buzz_code', ''), 'address_lat': rt.get('address_lat', 0), 'address_lng': rt.get('address_lng', 0), 'priority': rt.get('priority', 'normal'), + 'pod_required': rt.get('pod_required', False), + 'description': rt.get('description', ''), 'x_fc_sync_client_name': client_name, + 'x_fc_sync_client_phone': client_phone, } + if state_name: + state_rec = self.env['res.country.state'].sudo().search( + [('name', '=', state_name)], limit=1) + if state_rec: + vals['address_state_id'] = state_rec.id + existing = Task.search([('x_fc_sync_uuid', '=', sync_uuid)], limit=1) if existing: + if existing.status in TERMINAL_STATUSES: + vals.pop('status', None) existing.write(vals) else: vals['sale_order_id'] = False Task.create([vals]) + if sched_date: + affected_combos.add((local_uid, sched_date)) + for add_uid in local_additional_ids: + affected_combos.add((add_uid, sched_date)) + stale_shadows = Task.search([ ('x_fc_sync_source', '=', self.instance_id), ('x_fc_sync_uuid', 'not in', list(remote_uuids)), @@ -389,10 +465,149 @@ class FusionTaskSyncConfig(models.Model): ('active', '=', True), ]) if stale_shadows: + for st in stale_shadows: + if st.scheduled_date and st.technician_id: + affected_combos.add((st.technician_id.id, st.scheduled_date)) + for tech in st.additional_technician_ids: + if st.scheduled_date: + affected_combos.add((tech.id, st.scheduled_date)) stale_shadows.write({'active': False, 'status': 'cancelled'}) _logger.info("Deactivated %d stale shadow tasks from %s", len(stale_shadows), self.instance_id) + if affected_combos: + today = fields.Date.today() + today_str = str(today) + future_combos = set() + for tid, d in affected_combos: + if not d: + continue + d_str = str(d) if not isinstance(d, str) else d + if d_str >= today_str: + future_combos.add((tid, d_str)) + if future_combos: + TaskModel = self.env['fusion.technician.task'].sudo() + try: + ungeocode = TaskModel.search([ + ('x_fc_sync_source', '=', self.instance_id), + ('active', '=', True), + ('scheduled_date', '>=', today_str), + ('status', 'not in', ['cancelled']), + '|', + ('address_lat', '=', 0), ('address_lat', '=', False), + ]) + geocoded = 0 + for shadow in ungeocode: + if shadow.address_display: + if shadow.with_context(skip_travel_recalc=True)._geocode_address(): + geocoded += 1 + if geocoded: + _logger.info("Geocoded %d shadow tasks from %s", + geocoded, self.name) + except Exception: + _logger.exception( + "Shadow task geocoding after sync from %s failed", self.name) + + try: + TaskModel._recalculate_combos_travel(future_combos) + _logger.info( + "Recalculated travel for %d tech+date combos after sync from %s", + len(future_combos), self.name) + except Exception: + _logger.exception( + "Travel recalculation after sync from %s failed", self.name) + + # ------------------------------------------------------------------ + # PULL: technician locations from remote instance + # ------------------------------------------------------------------ + + def _pull_technician_locations(self): + """Pull latest GPS locations for matched technicians from the remote instance. + + Creates local location records with source='sync' so the map view + shows technician positions from both instances. Only keeps the single + most recent synced location per technician (replaces older synced + records to avoid clutter). + """ + self.ensure_one() + local_syncid_to_uid = self._get_local_syncid_to_uid() + if not local_syncid_to_uid: + return + + remote_map = self._get_remote_tech_map() + if not remote_map: + return + + matched_sync_ids = set(local_syncid_to_uid.keys()) & set(remote_map.keys()) + if not matched_sync_ids: + return + + remote_tech_ids = [remote_map[sid] for sid in matched_sync_ids] + remote_syncid_by_uid = {v: k for k, v in remote_map.items()} + + remote_locations = self._rpc( + 'fusion.technician.location', 'search_read', + [[ + ('user_id', 'in', remote_tech_ids), + ('logged_at', '>', str(fields.Datetime.subtract( + fields.Datetime.now(), hours=24))), + ('source', '!=', 'sync'), + ]], + { + 'fields': ['user_id', 'latitude', 'longitude', + 'accuracy', 'logged_at'], + 'order': 'logged_at desc', + }) + + if not remote_locations: + return + + Location = self.env['fusion.technician.location'].sudo() + + seen_techs = set() + synced_count = 0 + for rloc in remote_locations: + remote_uid_raw = rloc['user_id'] + remote_uid = (remote_uid_raw[0] + if isinstance(remote_uid_raw, (list, tuple)) + else remote_uid_raw) + if remote_uid in seen_techs: + continue + seen_techs.add(remote_uid) + + sync_id = remote_syncid_by_uid.get(remote_uid) + local_uid = local_syncid_to_uid.get(sync_id) if sync_id else None + if not local_uid: + continue + + lat = rloc.get('latitude', 0) + lng = rloc.get('longitude', 0) + if not lat or not lng: + continue + + old_synced = Location.search([ + ('user_id', '=', local_uid), + ('source', '=', 'sync'), + ('sync_instance', '=', self.instance_id), + ]) + if old_synced: + old_synced.unlink() + + Location.create({ + 'user_id': local_uid, + 'latitude': lat, + 'longitude': lng, + 'accuracy': rloc.get('accuracy', 0), + 'logged_at': rloc.get('logged_at', fields.Datetime.now()), + 'source': 'sync', + 'sync_instance': self.instance_id, + }) + synced_count += 1 + + if synced_count: + _logger.info("Synced %d technician location(s) from %s", + synced_count, self.name) + # ------------------------------------------------------------------ # CLEANUP # ------------------------------------------------------------------ @@ -419,6 +634,7 @@ class FusionTaskSyncConfig(models.Model): """Manually trigger a full sync for this config.""" self.ensure_one() self._pull_tasks_from_remote() + self._pull_technician_locations() self.sudo().write({ 'last_sync': fields.Datetime.now(), 'last_sync_error': False, @@ -426,12 +642,18 @@ class FusionTaskSyncConfig(models.Model): shadow_count = self.env['fusion.technician.task'].sudo().search_count([ ('x_fc_sync_source', '=', self.instance_id), ]) + loc_count = self.env['fusion.technician.location'].sudo().search_count([ + ('source', '=', 'sync'), + ('sync_instance', '=', self.instance_id), + ]) return { 'type': 'ir.actions.client', 'tag': 'display_notification', 'params': { 'title': 'Sync Complete', - 'message': f'Synced from {self.name}. {shadow_count} shadow task(s) now visible.', + 'message': (f'Synced from {self.name}. ' + f'{shadow_count} shadow task(s), ' + f'{loc_count} technician location(s) visible.'), 'type': 'success', 'sticky': False, }, diff --git a/fusion_claims/models/technician_location.py b/fusion_claims/models/technician_location.py index c680fb2e..f2c79b2b 100644 --- a/fusion_claims/models/technician_location.py +++ b/fusion_claims/models/technician_location.py @@ -48,7 +48,12 @@ class FusionTechnicianLocation(models.Model): source = fields.Selection([ ('portal', 'Portal'), ('app', 'Mobile App'), + ('sync', 'Synced'), ], string='Source', default='portal') + sync_instance = fields.Char( + 'Sync Instance', index=True, + help='Source instance ID if synced (e.g. westin, mobility)', + ) @api.model def log_location(self, latitude, longitude, accuracy=None): @@ -63,18 +68,27 @@ class FusionTechnicianLocation(models.Model): @api.model def get_latest_locations(self): - """Get the most recent location for each technician (for map view).""" + """Get the most recent location for each technician (for map view). + + Includes both local GPS pings and synced locations from remote + instances, so the map shows all shared technicians regardless of + which Odoo instance they are clocked into. + """ self.env.cr.execute(""" SELECT DISTINCT ON (user_id) - user_id, latitude, longitude, accuracy, logged_at + user_id, latitude, longitude, accuracy, logged_at, + COALESCE(sync_instance, '') AS sync_instance FROM fusion_technician_location WHERE logged_at > NOW() - INTERVAL '24 hours' ORDER BY user_id, logged_at DESC """) rows = self.env.cr.dictfetchall() + local_id = self.env['ir.config_parameter'].sudo().get_param( + 'fusion_claims.sync_instance_id', '') result = [] for row in rows: user = self.env['res.users'].sudo().browse(row['user_id']) + src = row.get('sync_instance') or local_id result.append({ 'user_id': row['user_id'], 'name': user.name, @@ -82,6 +96,7 @@ class FusionTechnicianLocation(models.Model): 'longitude': row['longitude'], 'accuracy': row['accuracy'], 'logged_at': str(row['logged_at']), + 'sync_instance': src, }) return result diff --git a/fusion_claims/models/technician_task.py b/fusion_claims/models/technician_task.py index 22865490..774dd1b4 100644 --- a/fusion_claims/models/technician_task.py +++ b/fusion_claims/models/technician_task.py @@ -95,6 +95,17 @@ class FusionTechnicianTask(models.Model): 'Synced Client Name', readonly=True, help='Client name from the remote instance (shadow tasks only)', ) + x_fc_sync_client_phone = fields.Char( + 'Synced Client Phone', readonly=True, + help='Client phone from the remote instance (shadow tasks only)', + ) + + client_display_name = fields.Char( + compute='_compute_client_display', string='Client Name (Display)', + ) + client_display_phone = fields.Char( + compute='_compute_client_display', string='Client Phone (Display)', + ) x_fc_source_label = fields.Char( 'Source', compute='_compute_is_shadow', store=True, @@ -108,6 +119,17 @@ class FusionTechnicianTask(models.Model): task.x_fc_is_shadow = bool(task.x_fc_sync_source) task.x_fc_source_label = task.x_fc_sync_source or local_id + @api.depends('x_fc_sync_source', 'x_fc_sync_client_name', + 'x_fc_sync_client_phone', 'partner_id') + def _compute_client_display(self): + for task in self: + if task.x_fc_sync_source: + task.client_display_name = task.x_fc_sync_client_name or task.name or '' + task.client_display_phone = task.x_fc_sync_client_phone or '' + else: + task.client_display_name = task.partner_id.name if task.partner_id else '' + task.client_display_phone = task.partner_id.phone if task.partner_id else '' + technician_id = fields.Many2one( 'res.users', string='Technician', @@ -288,6 +310,14 @@ class FusionTechnicianTask(models.Model): help='Combined end datetime for calendar display', ) + calendar_event_id = fields.Many2one( + 'calendar.event', + string='Calendar Event', + copy=False, + ondelete='set null', + help='Linked calendar event for external calendar sync', + ) + # Schedule info helper for the form schedule_info_html = fields.Html( string='Schedule Info', @@ -377,6 +407,17 @@ class FusionTechnicianTask(models.Model): default=False, help='Proof of Delivery signature required', ) + pod_signature = fields.Binary( + string='POD Signature', attachment=True, + ) + pod_client_name = fields.Char(string='POD Signer Name') + pod_signature_date = fields.Date(string='POD Signature Date') + pod_signed_by_user_id = fields.Many2one( + 'res.users', string='POD Collected By', readonly=True, + ) + pod_signed_datetime = fields.Datetime( + string='POD Collected At', readonly=True, + ) # ------------------------------------------------------------------ # COMPLETION @@ -1442,11 +1483,22 @@ class FusionTechnicianTask(models.Model): local_records = records.filtered(lambda r: not r.x_fc_sync_source) if local_records and not self.env.context.get('skip_task_sync'): self.env['fusion.task.sync.config']._push_tasks(local_records, 'create') + # Sync to calendar for external calendar integrations + records._sync_calendar_event() return records def write(self, vals): if self.env.context.get('skip_travel_recalc'): - return super().write(vals) + res = super().write(vals) + if ('status' in vals and vals['status'] in ('completed', 'cancelled') + and not self.env.context.get('skip_task_sync')): + shadow_records = self.filtered(lambda r: r.x_fc_sync_source) + if shadow_records: + self.env['fusion.task.sync.config']._push_shadow_status(shadow_records) + local_records = self.filtered(lambda r: not r.x_fc_sync_source) + if local_records: + self.env['fusion.task.sync.config']._push_tasks(local_records, 'write') + return res # Safety: ensure time_end is consistent when start/duration change # but time_end wasn't sent (readonly field in view may not save) @@ -1516,8 +1568,63 @@ class FusionTechnicianTask(models.Model): local_records = self.filtered(lambda r: not r.x_fc_sync_source) if local_records: self.env['fusion.task.sync.config']._push_tasks(local_records, 'write') + if 'status' in vals and vals['status'] in ('completed', 'cancelled'): + shadow_records = self.filtered(lambda r: r.x_fc_sync_source) + if shadow_records: + self.env['fusion.task.sync.config']._push_shadow_status(shadow_records) + # Re-sync calendar event when schedule fields change + cal_fields = {'scheduled_date', 'time_start', 'time_end', + 'duration_hours', 'technician_id', 'task_type', + 'partner_id', 'address_street', 'address_city', 'notes'} + if cal_fields & set(vals.keys()): + self._sync_calendar_event() return res + def _sync_calendar_event(self): + """Create or update a linked calendar.event for external calendar sync. + + Only syncs tasks that have a scheduled date and an assigned technician. + Uses sudo() because portal users should not need calendar write access. + """ + CalendarEvent = self.env['calendar.event'].sudo() + for task in self: + if not task.datetime_start or not task.datetime_end or not task.technician_id: + if task.calendar_event_id: + task.calendar_event_id.unlink() + task.with_context(skip_travel_recalc=True).write({'calendar_event_id': False}) + continue + + partner = task.partner_id or task.sale_order_id.partner_id if task.sale_order_id else task.partner_id + client_name = partner.name if partner else '' + type_label = dict(self._fields['task_type'].selection).get(task.task_type, task.task_type or '') + + event_name = f"{type_label}: {client_name}" if client_name else f"{type_label} - {task.name}" + location_parts = [task.address_street, task.address_city] + location = ', '.join(p for p in location_parts if p) or '' + + description_parts = [] + if task.sale_order_id: + description_parts.append(f"SO: {task.sale_order_id.name}") + if task.notes: + description_parts.append(task.notes) + + vals = { + 'name': event_name, + 'start': task.datetime_start, + 'stop': task.datetime_end, + 'user_id': task.technician_id.id, + 'location': location, + 'partner_ids': [(6, 0, [task.technician_id.partner_id.id])], + 'show_as': 'busy', + 'description': '\n'.join(description_parts), + } + + if task.calendar_event_id: + task.calendar_event_id.write(vals) + else: + event = CalendarEvent.create(vals) + task.with_context(skip_travel_recalc=True).write({'calendar_event_id': event.id}) + @api.model def _fill_address_vals(self, vals, partner): """Helper to fill address vals dict from a partner record.""" @@ -1674,19 +1781,43 @@ class FusionTechnicianTask(models.Model): return 0.0, 0.0 def _recalculate_combos_travel(self, combos): - """Recalculate travel for a set of (tech_id, date) combinations.""" + """Recalculate travel for a set of (tech_id, date) combinations. + + Start-point priority per technician (for today only): + 1. Actual GPS from today's fusion_clock check-in + 2. Personal start address (x_fc_start_address) + 3. Company default HQ address + For future dates, only 2 and 3 apply. + """ ICP = self.env['ir.config_parameter'].sudo() enabled = ICP.get_param('fusion_claims.google_distance_matrix_enabled', False) if not enabled: return api_key = self._get_google_maps_api_key() - # Cache geocoded start addresses per technician to avoid repeated API calls start_coords_cache = {} + today = fields.Date.today() + today_str = str(today) + + today_tech_ids = {tid for tid, d in combos + if tid and str(d) == today_str} + clock_locations = {} + if today_tech_ids: + clock_locations = self._get_clock_in_locations(today_tech_ids, today) for tech_id, date in combos: if not tech_id or not date: continue + + cache_key = (tech_id, str(date)) + if cache_key not in start_coords_cache: + if str(date) == today_str and tech_id in clock_locations: + cl = clock_locations[tech_id] + start_coords_cache[cache_key] = (cl['lat'], cl['lng']) + else: + addr = self._get_technician_start_address(tech_id) + start_coords_cache[cache_key] = self._geocode_address_string(addr, api_key) + all_day_tasks = self.sudo().search([ '|', ('technician_id', '=', tech_id), @@ -1697,12 +1828,7 @@ class FusionTechnicianTask(models.Model): if not all_day_tasks: continue - # Get this technician's start location (personal or company default) - if tech_id not in start_coords_cache: - addr = self._get_technician_start_address(tech_id) - start_coords_cache[tech_id] = self._geocode_address_string(addr, api_key) - - prev_lat, prev_lng = start_coords_cache[tech_id] + prev_lat, prev_lng = start_coords_cache[cache_key] for i, task in enumerate(all_day_tasks): if not (task.address_lat and task.address_lng): task._geocode_address() @@ -1710,7 +1836,7 @@ class FusionTechnicianTask(models.Model): if prev_lat and prev_lng and task.address_lat and task.address_lng: task.with_context(skip_travel_recalc=True)._calculate_travel_time(prev_lat, prev_lng) travel_vals['previous_task_id'] = all_day_tasks[i - 1].id if i > 0 else False - travel_vals['travel_origin'] = 'Start Location' if i == 0 else f'Task {all_day_tasks[i - 1].name}' + travel_vals['travel_origin'] = 'Clock-In Location' if i == 0 and str(date) == today_str and tech_id in clock_locations else ('Start Location' if i == 0 else f'Task {all_day_tasks[i - 1].name}') if travel_vals: task.with_context(skip_travel_recalc=True).write(travel_vals) prev_lat = task.address_lat or prev_lat @@ -2044,53 +2170,66 @@ class FusionTechnicianTask(models.Model): ) def _notify_scheduler_on_completion(self): - """Send an Odoo notification to whoever created/scheduled the task.""" + """Send an Odoo notification to the person who scheduled the task. + + Shadow tasks skip this -- the push-back to the source instance + triggers the notification there where the real scheduler exists. + """ self.ensure_one() - # Notify the task creator (scheduler) if they're not the technician - if self.create_uid and self.create_uid not in self.all_technician_ids: - task_type_label = dict(self._fields['task_type'].selection).get(self.task_type, self.task_type) - task_url = f'/web#id={self.id}&model=fusion.technician.task&view_type=form' - client_name = self.partner_id.name or 'N/A' - order = self.sale_order_id or self.purchase_order_id - case_ref = order.name if order else '' - # Build address string - addr_parts = [p for p in [ - self.address_street, - self.address_street2, - self.address_city, - self.address_state_id.name if self.address_state_id else '', - self.address_zip, - ] if p] - address_str = ', '.join(addr_parts) or 'No address' - # Build subject - subject = f'Task Completed: {client_name}' - if case_ref: - subject += f' ({case_ref})' - body = Markup( - f'
' - f'

' - f'{task_type_label} Completed

' - f'' - f'' - f'' - f'' - f'' - f'' - f'' - f'' - f'' - f'' - f'' - f'
Client:{client_name}
Case:{case_ref or "N/A"}
Task:{self.name}
Technician(s):{self.all_technician_names or self.technician_id.name}
Location:{address_str}
' - f'

View Task

' - f'
' - ) - # Use Odoo's internal notification system - self.env['mail.thread'].sudo().message_notify( - partner_ids=[self.create_uid.partner_id.id], - body=body, - subject=subject, - ) + if self.x_fc_sync_source: + return + + recipient = None + if self.sale_order_id and self.sale_order_id.user_id: + recipient = self.sale_order_id.user_id + elif self.purchase_order_id and self.purchase_order_id.user_id: + recipient = self.purchase_order_id.user_id + elif self.create_uid: + recipient = self.create_uid + + if not recipient or recipient in self.all_technician_ids: + return + + task_type_label = dict(self._fields['task_type'].selection).get(self.task_type, self.task_type) + task_url = f'/web#id={self.id}&model=fusion.technician.task&view_type=form' + client_name = self.client_display_name or 'N/A' + order = self.sale_order_id or self.purchase_order_id + case_ref = order.name if order else '' + addr_parts = [p for p in [ + self.address_street, + self.address_street2, + self.address_city, + self.address_state_id.name if self.address_state_id else '', + self.address_zip, + ] if p] + address_str = ', '.join(addr_parts) or 'No address' + subject = f'Task Completed: {client_name}' + if case_ref: + subject += f' ({case_ref})' + body = Markup( + f'
' + f'

' + f'{task_type_label} Completed

' + f'' + f'' + f'' + f'' + f'' + f'' + f'' + f'' + f'' + f'' + f'' + f'
Client:{client_name}
Case:{case_ref or "N/A"}
Task:{self.name}
Technician(s):{self.all_technician_names or self.technician_id.name}
Location:{address_str}
' + f'

View Task

' + f'
' + ) + self.env['mail.thread'].sudo().message_notify( + partner_ids=[recipient.partner_id.id], + body=body, + subject=subject, + ) # ------------------------------------------------------------------ # TASK EMAIL NOTIFICATIONS @@ -2483,7 +2622,13 @@ class FusionTechnicianTask(models.Model): @api.model def _get_clock_in_locations(self, tech_ids, today): - """Get today's clock-in lat/lng from fusion_clock if installed.""" + """Get today's clock-in lat/lng from fusion_clock if installed. + + Uses the technician's actual GPS position at the moment they clocked + in (from the activity log), not the geofenced location's fixed + coordinates. Falls back to the geofence center if no activity-log + GPS is available. + """ result = {} try: module = self.env['ir.module.module'].sudo().search([ @@ -2498,6 +2643,7 @@ class FusionTechnicianTask(models.Model): try: Attendance = self.env['hr.attendance'].sudo() Employee = self.env['hr.employee'].sudo() + ActivityLog = self.env['fusion.clock.activity.log'].sudo() except KeyError: return result @@ -2522,12 +2668,31 @@ class FusionTechnicianTask(models.Model): uid = emp_to_user.get(att.employee_id.id) if not uid or uid in result: continue - loc = att.x_fclk_location_id if hasattr(att, 'x_fclk_location_id') else False - if loc and loc.latitude and loc.longitude: + + lat, lng, address = 0, 0, '' + + log = ActivityLog.search([ + ('attendance_id', '=', att.id), + ('log_type', '=', 'clock_in'), + ('latitude', '!=', 0), + ('longitude', '!=', 0), + ], limit=1) + if log: + lat, lng = log.latitude, log.longitude + loc = att.x_fclk_location_id if hasattr(att, 'x_fclk_location_id') else False + address = (loc.address or loc.name) if loc else '' + + if not lat or not lng: + loc = att.x_fclk_location_id if hasattr(att, 'x_fclk_location_id') else False + if loc and loc.latitude and loc.longitude: + lat, lng = loc.latitude, loc.longitude + address = loc.address or loc.name or '' + + if lat and lng: result[uid] = { - 'lat': loc.latitude, - 'lng': loc.longitude, - 'address': loc.address or loc.name or '', + 'lat': lat, + 'lng': lng, + 'address': address, 'source': 'clock_in', } diff --git a/fusion_claims/static/src/js/fusion_task_map_view.js b/fusion_claims/static/src/js/fusion_task_map_view.js index 46aa850e..9dbd43cc 100644 --- a/fusion_claims/static/src/js/fusion_task_map_view.js +++ b/fusion_claims/static/src/js/fusion_task_map_view.js @@ -496,15 +496,21 @@ export class FusionTaskMapController extends Component { if (!loc.latitude || !loc.longitude) continue; const pos = { lat: loc.latitude, lng: loc.longitude }; const initials = initialsOf(loc.name); + const src = loc.sync_instance || this.localInstanceId || ""; + const isRemote = src && src !== this.localInstanceId; + const pinColor = isRemote + ? (SOURCE_COLORS[src] || "#6c757d") + : "#1d4ed8"; + const srcLabel = src ? src.charAt(0).toUpperCase() + src.slice(1) : ""; const svg = `` + - `` + + `` + `${initials}` + ``; const marker = new google.maps.Marker({ position: pos, map: this.map, - title: loc.name, + title: loc.name + (isRemote ? ` [${srcLabel}]` : ""), icon: { url: "data:image/svg+xml;charset=UTF-8," + encodeURIComponent(svg), scaledSize: new google.maps.Size(44, 44), @@ -515,8 +521,9 @@ export class FusionTaskMapController extends Component { marker.addListener("click", () => { this.infoWindow.setContent(`
-
+
${loc.name} + ${srcLabel ? `${srcLabel}` : ""}
Last seen: ${loc.logged_at || "Unknown"}
diff --git a/fusion_clock/__manifest__.py b/fusion_clock/__manifest__.py index 3f9437e6..0e5fe43b 100644 --- a/fusion_clock/__manifest__.py +++ b/fusion_clock/__manifest__.py @@ -81,7 +81,6 @@ Integrates natively with Odoo's hr.attendance module for full payroll compatibil 'web.assets_frontend': [ 'fusion_clock/static/src/css/portal_clock.css', 'fusion_clock/static/src/js/fusion_clock_portal.js', - 'fusion_clock/static/src/js/fusion_clock_portal_fab.js', 'fusion_clock/static/src/js/fusion_clock_kiosk.js', ], 'web.assets_backend': [ diff --git a/fusion_clock/controllers/clock_api.py b/fusion_clock/controllers/clock_api.py index 67fe3c67..20036481 100644 --- a/fusion_clock/controllers/clock_api.py +++ b/fusion_clock/controllers/clock_api.py @@ -57,29 +57,34 @@ class FusionClockAPI(http.Controller): if not locations: return False, 0, 'no_locations', 'gps' + gps_available = latitude != 0 or longitude != 0 + geocoded = locations.filtered(lambda l: l.latitude and l.longitude and not (l.latitude == 0.0 and l.longitude == 0.0)) - if not geocoded: - return False, 0, 'no_geocoded', 'gps' - nearest_location = False nearest_distance = float('inf') - for loc in geocoded: - dist = haversine_distance(latitude, longitude, loc.latitude, loc.longitude) - if dist <= loc.radius: - return loc, dist, None, 'gps' - if dist < nearest_distance: - nearest_distance = dist - nearest_location = loc + if gps_available and geocoded: + for loc in geocoded: + dist = haversine_distance(latitude, longitude, loc.latitude, loc.longitude) + if dist <= loc.radius: + return loc, dist, None, 'gps' + if dist < nearest_distance: + nearest_distance = dist - # IP fallback check + # IP fallback -- try when GPS is unavailable OR GPS is outside all geofences ICP = request.env['ir.config_parameter'].sudo() - if ICP.get_param('fusion_clock.enable_ip_fallback', 'False') == 'True' and client_ip: + if client_ip: for loc in locations: if loc.check_ip_whitelist(client_ip): return loc, 0, None, 'ip' + if not gps_available: + return False, 0, 'gps_unavailable', 'gps' + + if not geocoded: + return False, 0, 'no_geocoded', 'gps' + return False, nearest_distance, 'outside', 'gps' def _location_error_message(self, error_type, distance=0): @@ -87,6 +92,8 @@ class FusionClockAPI(http.Controller): return 'No clock locations configured. Ask your manager to set up locations in Fusion Clock > Locations.' elif error_type == 'no_geocoded': return 'Clock locations exist but have no GPS coordinates. Ask your manager to geocode them.' + elif error_type == 'gps_unavailable': + return 'Could not determine your location. Please enable GPS/location services in your browser and device settings, then try again.' else: dist_str = f"{int(distance)}m" if distance < 1000 else f"{distance/1000:.1f}km" return f'You are {dist_str} away from the nearest clock location. Please clock in/out within the allowed area.' diff --git a/fusion_clock/models/clock_location.py b/fusion_clock/models/clock_location.py index 14c968db..db1e0d25 100644 --- a/fusion_clock/models/clock_location.py +++ b/fusion_clock/models/clock_location.py @@ -125,6 +125,53 @@ class FusionClockLocation(models.Model): return False return False + def action_detect_ip(self): + """Detect the current public IP and append it to the whitelist.""" + self.ensure_one() + try: + resp = requests.get('https://ipapi.co/json/', timeout=10) + data = resp.json() + ip = data.get('ip', '') + if not ip: + raise UserError(_("Could not detect public IP.")) + except requests.exceptions.RequestException as e: + raise UserError(_("Network error detecting IP: %s") % str(e)) + + existing = (self.ip_whitelist or '').strip() + existing_lines = [l.strip() for l in existing.split('\n') if l.strip()] if existing else [] + if ip in existing_lines: + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': _('Already Whitelisted'), + 'message': _('%s is already in the whitelist.') % ip, + 'type': 'warning', + 'sticky': False, + }, + } + + existing_lines.append(ip) + self.ip_whitelist = '\n'.join(existing_lines) + city = data.get('city', '') + org = data.get('org', '') + detail = f"{ip}" + if city: + detail += f" ({city}" + if org: + detail += f" - {org}" + detail += ")" + return { + 'type': 'ir.actions.client', + 'tag': 'display_notification', + 'params': { + 'title': _('IP Detected & Added'), + 'message': _('Added %s to the whitelist.') % detail, + 'type': 'success', + 'sticky': False, + }, + } + def action_geocode_address(self): """Geocode the address to get lat/lng using Google Geocoding API. Falls back to Nominatim (OpenStreetMap) if Google fails. diff --git a/fusion_clock/static/src/js/fusion_clock_kiosk.js b/fusion_clock/static/src/js/fusion_clock_kiosk.js index 7bb55ab9..c72743e3 100644 --- a/fusion_clock/static/src/js/fusion_clock_kiosk.js +++ b/fusion_clock/static/src/js/fusion_clock_kiosk.js @@ -180,7 +180,21 @@ export class FusionClockKiosk extends Interaction { lat = pos.coords.latitude; lng = pos.coords.longitude; } catch { - // GPS unavailable on kiosk device + // Native GPS unavailable -- try IP geolocation + } + if (lat === 0 && lng === 0) { + try { + const ipResp = await fetch("https://ipapi.co/json/"); + if (ipResp.ok) { + const ipData = await ipResp.json(); + if (ipData.latitude && ipData.longitude) { + lat = ipData.latitude; + lng = ipData.longitude; + } + } + } catch { + // IP geolocation also unavailable + } } const resp = await fetch("/fusion_clock/kiosk/clock", { diff --git a/fusion_clock/static/src/js/fusion_clock_portal.js b/fusion_clock/static/src/js/fusion_clock_portal.js index fc00ef2a..3f063c76 100644 --- a/fusion_clock/static/src/js/fusion_clock_portal.js +++ b/fusion_clock/static/src/js/fusion_clock_portal.js @@ -199,15 +199,22 @@ export class FusionClockPortal extends Interaction { (pos) => { this._performClockAction(pos.coords.latitude, pos.coords.longitude, pos.coords.accuracy); }, - (err) => { + async () => { + let lat = 0, lng = 0; + try { + const ipResp = await fetch("https://ipapi.co/json/"); + if (ipResp.ok) { + const ipData = await ipResp.json(); + if (ipData.latitude && ipData.longitude) { + lat = ipData.latitude; + lng = ipData.longitude; + } + } + } catch { + // IP geolocation also unavailable + } this._hideGPSOverlay(); - let msg = "Could not get your location. "; - if (err.code === 1) msg += "Please allow location access."; - else if (err.code === 2) msg += "Location unavailable."; - else if (err.code === 3) msg += "Location request timed out."; - this._showToast(msg, "error"); - this._shakeButton(); - btn.disabled = false; + this._performClockAction(lat, lng, lat ? 5000 : 0); }, { enableHighAccuracy: true, timeout: 15000, maximumAge: 0 } ); diff --git a/fusion_clock/static/src/js/fusion_clock_portal_fab.js b/fusion_clock/static/src/js/fusion_clock_portal_fab.js index eba2ded4..4a03d979 100644 --- a/fusion_clock/static/src/js/fusion_clock_portal_fab.js +++ b/fusion_clock/static/src/js/fusion_clock_portal_fab.js @@ -398,10 +398,23 @@ export class FusionClockPortalFAB extends Interaction { lat = pos.coords.latitude; lng = pos.coords.longitude; acc = pos.coords.accuracy; - } catch (geoErr) { - this._showError("Location access denied. Please enable GPS."); - if (this.actionBtn) this.actionBtn.disabled = false; - return; + } catch { + // Native GPS unavailable (common on desktops) -- try IP geolocation + } + } + if (lat === 0 && lng === 0) { + try { + const ipResp = await fetch("https://ipapi.co/json/"); + if (ipResp.ok) { + const ipData = await ipResp.json(); + if (ipData.latitude && ipData.longitude) { + lat = ipData.latitude; + lng = ipData.longitude; + acc = 5000; + } + } + } catch { + // IP geolocation also unavailable } } diff --git a/fusion_clock/static/src/js/fusion_clock_systray.js b/fusion_clock/static/src/js/fusion_clock_systray.js index 12583baf..b753a061 100644 --- a/fusion_clock/static/src/js/fusion_clock_systray.js +++ b/fusion_clock/static/src/js/fusion_clock_systray.js @@ -134,10 +134,23 @@ export class FusionClockFAB extends Component { lat = pos.coords.latitude; lng = pos.coords.longitude; acc = pos.coords.accuracy; - } catch (geoErr) { - this.state.error = "Location access denied."; - this.state.loading = false; - return; + } catch { + // Native GPS unavailable (common on desktops) -- try IP geolocation + } + } + if (lat === 0 && lng === 0) { + try { + const ipResp = await fetch("https://ipapi.co/json/"); + if (ipResp.ok) { + const ipData = await ipResp.json(); + if (ipData.latitude && ipData.longitude) { + lat = ipData.latitude; + lng = ipData.longitude; + acc = 5000; + } + } + } catch { + // IP geolocation also unavailable } } diff --git a/fusion_clock/views/clock_location_views.xml b/fusion_clock/views/clock_location_views.xml index 591d4d6d..194f6553 100644 --- a/fusion_clock/views/clock_location_views.xml +++ b/fusion_clock/views/clock_location_views.xml @@ -52,6 +52,12 @@ +
+
diff --git a/fusion_rental/data/mail_template_data.xml b/fusion_rental/data/mail_template_data.xml index 2b5970af..c7c5c67f 100644 --- a/fusion_rental/data/mail_template_data.xml +++ b/fusion_rental/data/mail_template_data.xml @@ -8,7 +8,8 @@ Rental: Renewal Reminder {{ object.company_id.name }} - Rental {{ object.name }} Renews Soon - {{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }} + {{ (object.company_id.email_formatted or object.user_id.email_formatted or user.email_formatted) }} + {{ object.partner_id.id }}
@@ -40,7 +41,7 @@
{{ object.partner_id.lang }} - + @@ -50,7 +51,8 @@ Rental: Renewal Confirmation {{ object.company_id.name }} - Rental {{ object.name }} Renewed - {{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }} + {{ (object.company_id.email_formatted or object.user_id.email_formatted or user.email_formatted) }} + {{ object.partner_id.id }}
@@ -80,7 +82,7 @@
{{ object.partner_id.lang }} - + @@ -90,7 +92,8 @@ Rental: Payment Receipt {{ object.company_id.name }} - Payment Receipt {{ object.name }} - {{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }} + {{ (object.company_id.email_formatted or object.user_id.email_formatted or user.email_formatted) }} + {{ object.partner_id.id }}
@@ -119,7 +122,7 @@
{{ object.partner_id.lang }} - + @@ -129,7 +132,8 @@ Rental: Cancellation Confirmed {{ object.company_id.name }} - Cancellation Received {{ object.name }} - {{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }} + {{ (object.company_id.email_formatted or object.user_id.email_formatted or user.email_formatted) }} + {{ object.partner_id.id }}
@@ -152,7 +156,7 @@
{{ object.partner_id.lang }} - + @@ -162,7 +166,8 @@ Rental: Agreement for Signing {{ object.company_id.name }} - Rental Agreement {{ object.name }} - {{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }} + {{ (object.company_id.email_formatted or object.user_id.email_formatted or user.email_formatted) }} + {{ object.partner_id.id }}
@@ -194,7 +199,7 @@
{{ object.partner_id.lang }} - + @@ -204,7 +209,8 @@ Rental: Signed Agreement Copy {{ object.company_id.name }} - Your Signed Rental Agreement {{ object.name }} - {{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }} + {{ (object.company_id.email_formatted or object.user_id.email_formatted or user.email_formatted) }} + {{ object.partner_id.id }}
@@ -235,7 +241,7 @@
{{ object.partner_id.lang }} - + @@ -245,7 +251,8 @@ Rental: Purchase Conversion Offer {{ object.company_id.name }} - Make Your Rental Yours {{ object.name }} - {{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }} + {{ (object.company_id.email_formatted or object.user_id.email_formatted or user.email_formatted) }} + {{ object.partner_id.id }}
@@ -278,7 +285,7 @@
{{ object.partner_id.lang }} - + @@ -288,7 +295,8 @@ Rental: Security Deposit Refund Initiated {{ object.company_id.name }} - Security Deposit Refund Processing {{ object.name }} - {{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }} + {{ (object.company_id.email_formatted or object.user_id.email_formatted or user.email_formatted) }} + {{ object.partner_id.id }}
@@ -318,7 +326,7 @@
{{ object.partner_id.lang }} - + @@ -328,7 +336,8 @@ Rental: Security Deposit Refund Complete {{ object.company_id.name }} - Security Deposit Refunded {{ object.name }} - {{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }} + {{ (object.company_id.email_formatted or object.user_id.email_formatted or user.email_formatted) }} + {{ object.partner_id.id }}
@@ -359,7 +368,7 @@
{{ object.partner_id.lang }} - + @@ -369,7 +378,8 @@ Rental: Invoice + Payment Receipt {{ object.company_id.name }} - Invoice & Payment Confirmation {{ object.name }} - {{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }} + {{ (object.company_id.email_formatted or object.user_id.email_formatted or user.email_formatted) }} + {{ object.partner_id.id }}
@@ -412,7 +422,7 @@
{{ object.partner_id.lang }} - + @@ -422,7 +432,8 @@ Rental: Damage Notification {{ object.company_id.name }} - Rental Inspection Update {{ object.name }} - {{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }} + {{ (object.company_id.email_formatted or object.user_id.email_formatted or user.email_formatted) }} + {{ object.partner_id.id }}
@@ -450,7 +461,7 @@
{{ object.partner_id.lang }} - + @@ -460,7 +471,8 @@ Rental: Thank You + Review {{ object.company_id.name }} - Thank You {{ object.name }} - {{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }} + {{ (object.company_id.email_formatted or object.user_id.email_formatted or user.email_formatted) }} + {{ object.partner_id.id }}
@@ -493,7 +505,7 @@
{{ object.partner_id.lang }} - + @@ -503,7 +515,8 @@ Rental: Card Reauthorization Request {{ object.company_id.name }} - Update Payment Card {{ object.name }} - {{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }} + {{ (object.company_id.email_formatted or object.user_id.email_formatted or user.email_formatted) }} + {{ object.partner_id.id }}
@@ -529,7 +542,7 @@
{{ object.partner_id.lang }} - + @@ -539,7 +552,8 @@ Rental: Card Updated Confirmation {{ object.company_id.name }} - Payment Card Updated {{ object.name }} - {{ (object.user_id.email_formatted or object.company_id.email_formatted or user.email_formatted) }} + {{ (object.company_id.email_formatted or object.user_id.email_formatted or user.email_formatted) }} + {{ object.partner_id.id }}
@@ -568,7 +582,7 @@
{{ object.partner_id.lang }} - + diff --git a/fusion_rental/models/sale_order.py b/fusion_rental/models/sale_order.py index cd79ee36..d58c6460 100644 --- a/fusion_rental/models/sale_order.py +++ b/fusion_rental/models/sale_order.py @@ -623,6 +623,7 @@ class SaleOrder(models.Model): raise_if_not_found=False, ) if not template: + _logger.error("Renewal confirmation template not found for %s", self.name) return attachment_ids = [] @@ -635,14 +636,18 @@ class SaleOrder(models.Model): invoice, 'Renewal', ) - template.with_context( - payment_ok=payment_ok, - renewal_log=renewal_log, - ).send_mail( - self.id, - force_send=True, - email_values={'attachment_ids': attachment_ids} if attachment_ids else {}, - ) + try: + template.with_context( + payment_ok=payment_ok, + renewal_log=renewal_log, + ).send_mail( + self.id, + force_send=True, + email_values={'attachment_ids': attachment_ids} if attachment_ids else {}, + ) + _logger.warning("Renewal confirmation email sent for %s", self.name) + except Exception as e: + _logger.error("Failed to send renewal confirmation email for %s: %s", self.name, e) def _send_payment_receipt_email(self, invoice, transaction): """Send payment receipt email after successful collection.""" @@ -912,7 +917,13 @@ class SaleOrder(models.Model): raise_if_not_found=False, ) if template: - template.send_mail(self.id, force_send=True) + try: + template.send_mail(self.id, force_send=True) + _logger.warning("Rental agreement email sent for %s", self.name) + except Exception as e: + _logger.error("Failed to send agreement email for %s: %s", self.name, e) + else: + _logger.error("Agreement email template not found for %s", self.name) self.message_post(body=_("Rental agreement sent to customer for signing.")) def action_send_card_reauthorization(self): @@ -1308,6 +1319,7 @@ class SaleOrder(models.Model): raise_if_not_found=False, ) if not template: + _logger.error("Deposit refund initiated template not found for %s", self.name) return attachment_ids = [] @@ -1317,11 +1329,15 @@ class SaleOrder(models.Model): credit_note, 'Deposit Credit Note', ) - template.send_mail( - self.id, - force_send=True, - email_values={'attachment_ids': attachment_ids} if attachment_ids else {}, - ) + try: + template.send_mail( + self.id, + force_send=True, + email_values={'attachment_ids': attachment_ids} if attachment_ids else {}, + ) + _logger.warning("Deposit refund initiated email sent for %s", self.name) + except Exception as e: + _logger.error("Failed to send deposit refund initiated email for %s: %s", self.name, e) def _send_deposit_refund_email(self): """Send the security deposit refund completion email with credit note and receipt.""" @@ -1331,6 +1347,7 @@ class SaleOrder(models.Model): raise_if_not_found=False, ) if not template: + _logger.error("Deposit refund template not found for %s", self.name) return attachment_ids = [] @@ -1342,11 +1359,15 @@ class SaleOrder(models.Model): receipt_ids = self._find_poynt_receipt_attachments(credit_note) attachment_ids.extend(receipt_ids) - template.send_mail( - self.id, - force_send=True, - email_values={'attachment_ids': attachment_ids} if attachment_ids else {}, - ) + try: + template.send_mail( + self.id, + force_send=True, + email_values={'attachment_ids': attachment_ids} if attachment_ids else {}, + ) + _logger.warning("Deposit refund email sent for %s", self.name) + except Exception as e: + _logger.error("Failed to send deposit refund email for %s: %s", self.name, e) def _send_invoice_with_receipt(self, invoice, invoice_type=''): """Send invoice email with the invoice PDF and payment receipt attached. @@ -1361,6 +1382,7 @@ class SaleOrder(models.Model): raise_if_not_found=False, ) if not template: + _logger.error("Invoice receipt email template not found for %s", self.name) return type_label = { @@ -1374,14 +1396,24 @@ class SaleOrder(models.Model): receipt_ids = self._find_poynt_receipt_attachments(invoice) attachment_ids.extend(receipt_ids) - template.with_context( - rental_invoice=invoice, - rental_invoice_type=invoice_type, - ).send_mail( - self.id, - force_send=True, - email_values={'attachment_ids': attachment_ids} if attachment_ids else {}, - ) + try: + template.with_context( + rental_invoice=invoice, + rental_invoice_type=invoice_type, + ).send_mail( + self.id, + force_send=True, + email_values={'attachment_ids': attachment_ids} if attachment_ids else {}, + ) + _logger.warning( + "Invoice receipt email sent for %s (%s) with %d attachments", + self.name, type_label, len(attachment_ids), + ) + except Exception as e: + _logger.error( + "Failed to send invoice receipt email for %s (%s): %s", + self.name, type_label, e, + ) # ================================================================= # Email Attachment Helpers @@ -1605,11 +1637,17 @@ class SaleOrder(models.Model): raise_if_not_found=False, ) if template: - template.send_mail( - self.id, - force_send=True, - email_values={'attachment_ids': [attachment.id]}, - ) + try: + template.send_mail( + self.id, + force_send=True, + email_values={'attachment_ids': [attachment.id]}, + ) + _logger.warning("Signed agreement email sent for %s", self.name) + except Exception as e: + _logger.error("Failed to send signed agreement email for %s: %s", self.name, e) + else: + _logger.error("Signed agreement email template not found for %s", self.name) def action_preview_rental_agreement(self): """Open the rental agreement PDF in a preview dialog.""" @@ -1702,14 +1740,19 @@ class SaleOrder(models.Model): raise_if_not_found=False, ) if not template: + _logger.error("Thank you email template not found for %s", self.name) return attachment_ids = self._generate_agreement_attachment_ids() - template.with_context( - google_review_url=self._get_google_review_url(), - ).send_mail( - self.id, - force_send=True, - email_values={'attachment_ids': attachment_ids} if attachment_ids else {}, - ) + try: + template.with_context( + google_review_url=self._get_google_review_url(), + ).send_mail( + self.id, + force_send=True, + email_values={'attachment_ids': attachment_ids} if attachment_ids else {}, + ) + _logger.warning("Thank you email sent for %s", self.name) + except Exception as e: + _logger.error("Failed to send thank you email for %s: %s", self.name, e)