From bc724868080895b5b7f7fbf5f022ebba1a1ed1c8 Mon Sep 17 00:00:00 2001 From: gsinghpal Date: Sun, 12 Apr 2026 20:22:09 -0400 Subject: [PATCH] feat(fusion_tasks): copy from Entech Plating, remove sync system MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Forked fusion_tasks module into fusion-plating repo for EN Tech delivery dispatch. Removed: - models/task_sync.py (748 lines — cross-instance sync) - views/task_sync_views.xml - __pycache__ directories Co-Authored-By: Claude Opus 4.6 (1M context) --- fusion-plating/fusion_tasks/__init__.py | 36 + fusion-plating/fusion_tasks/__manifest__.py | 38 + .../data/ir_config_parameter_data.xml | 50 + .../fusion_tasks/data/ir_cron_data.xml | 78 + .../fusion_tasks/models/__init__.py | 13 + .../models/email_builder_mixin.py | 241 ++ .../fusion_tasks/models/push_subscription.py | 73 + .../fusion_tasks/models/res_company.py | 14 + .../models/res_config_settings.py | 73 + .../fusion_tasks/models/res_partner.py | 79 + .../fusion_tasks/models/res_users.py | 26 + .../models/technician_location.py | 131 + .../fusion_tasks/models/technician_task.py | 3028 +++++++++++++++++ .../fusion_tasks/security/ir.model.access.csv | 12 + .../fusion_tasks/security/security.xml | 103 + .../fusion_tasks/static/description/icon.png | Bin 0 -> 43989 bytes .../static/src/css/fusion_task_map_view.scss | 488 +++ .../static/src/js/fusion_task_map_view.js | 1200 +++++++ .../static/src/xml/fusion_task_map_view.xml | 255 ++ .../views/res_config_settings_views.xml | 156 + .../views/technician_location_views.xml | 102 + .../views/technician_task_views.xml | 507 +++ 22 files changed, 6703 insertions(+) create mode 100644 fusion-plating/fusion_tasks/__init__.py create mode 100644 fusion-plating/fusion_tasks/__manifest__.py create mode 100644 fusion-plating/fusion_tasks/data/ir_config_parameter_data.xml create mode 100644 fusion-plating/fusion_tasks/data/ir_cron_data.xml create mode 100644 fusion-plating/fusion_tasks/models/__init__.py create mode 100644 fusion-plating/fusion_tasks/models/email_builder_mixin.py create mode 100644 fusion-plating/fusion_tasks/models/push_subscription.py create mode 100644 fusion-plating/fusion_tasks/models/res_company.py create mode 100644 fusion-plating/fusion_tasks/models/res_config_settings.py create mode 100644 fusion-plating/fusion_tasks/models/res_partner.py create mode 100644 fusion-plating/fusion_tasks/models/res_users.py create mode 100644 fusion-plating/fusion_tasks/models/technician_location.py create mode 100644 fusion-plating/fusion_tasks/models/technician_task.py create mode 100644 fusion-plating/fusion_tasks/security/ir.model.access.csv create mode 100644 fusion-plating/fusion_tasks/security/security.xml create mode 100644 fusion-plating/fusion_tasks/static/description/icon.png create mode 100644 fusion-plating/fusion_tasks/static/src/css/fusion_task_map_view.scss create mode 100644 fusion-plating/fusion_tasks/static/src/js/fusion_task_map_view.js create mode 100644 fusion-plating/fusion_tasks/static/src/xml/fusion_task_map_view.xml create mode 100644 fusion-plating/fusion_tasks/views/res_config_settings_views.xml create mode 100644 fusion-plating/fusion_tasks/views/technician_location_views.xml create mode 100644 fusion-plating/fusion_tasks/views/technician_task_views.xml diff --git a/fusion-plating/fusion_tasks/__init__.py b/fusion-plating/fusion_tasks/__init__.py new file mode 100644 index 00000000..86043519 --- /dev/null +++ b/fusion-plating/fusion_tasks/__init__.py @@ -0,0 +1,36 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +from . import models + + +def _fusion_tasks_post_init(env): + """Post-install hook for fusion_tasks. + + 1. Sets default ICP values (upsert - safe if keys already exist). + 2. Adds all active internal users to group_field_technician so + the Field Service menus are visible immediately after install. + """ + ICP = env['ir.config_parameter'].sudo() + defaults = { + 'fusion_claims.google_maps_api_key': '', + 'fusion_claims.store_open_hour': '9.0', + 'fusion_claims.store_close_hour': '18.0', + 'fusion_claims.push_enabled': 'False', + 'fusion_claims.push_advance_minutes': '30', + 'fusion_claims.sync_instance_id': '', + 'fusion_claims.technician_start_address': '', + } + for key, default_value in defaults.items(): + if not ICP.get_param(key): + ICP.set_param(key, default_value) + + # Add all active internal users to Field Technician group + ft_group = env.ref('fusion_tasks.group_field_technician', raise_if_not_found=False) + if ft_group: + internal_users = env['res.users'].search([ + ('active', '=', True), + ('share', '=', False), + ]) + ft_group.write({'user_ids': [(4, u.id) for u in internal_users]}) diff --git a/fusion-plating/fusion_tasks/__manifest__.py b/fusion-plating/fusion_tasks/__manifest__.py new file mode 100644 index 00000000..717e31ce --- /dev/null +++ b/fusion-plating/fusion_tasks/__manifest__.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +{ + 'name': 'Fusion Tasks', + 'version': '19.0.1.0.0', + 'category': 'Services/Field Service', + 'summary': 'Technician scheduling, route planning, GPS tracking, and cross-instance sync.', + 'author': 'Nexa Systems Inc.', + 'website': 'https://www.nexasystems.ca', + 'license': 'OPL-1', + 'depends': [ + 'base', + 'mail', + 'calendar', + 'sales_team', + ], + 'data': [ + 'security/security.xml', + 'security/ir.model.access.csv', + 'data/ir_cron_data.xml', + 'views/technician_task_views.xml', + 'views/task_sync_views.xml', + 'views/technician_location_views.xml', + 'views/res_config_settings_views.xml', + ], + 'post_init_hook': '_fusion_tasks_post_init', + 'assets': { + 'web.assets_backend': [ + 'fusion_tasks/static/src/css/fusion_task_map_view.scss', + 'fusion_tasks/static/src/js/fusion_task_map_view.js', + 'fusion_tasks/static/src/xml/fusion_task_map_view.xml', + ], + }, + 'installable': True, + 'application': True, +} diff --git a/fusion-plating/fusion_tasks/data/ir_config_parameter_data.xml b/fusion-plating/fusion_tasks/data/ir_config_parameter_data.xml new file mode 100644 index 00000000..6ca041be --- /dev/null +++ b/fusion-plating/fusion_tasks/data/ir_config_parameter_data.xml @@ -0,0 +1,50 @@ + + + + + + + + fusion_claims.google_maps_api_key + + + + + + fusion_claims.store_open_hour + 9.0 + + + fusion_claims.store_close_hour + 18.0 + + + + + fusion_claims.push_enabled + False + + + fusion_claims.push_advance_minutes + 30 + + + + + fusion_claims.sync_instance_id + + + + + + fusion_claims.technician_start_address + + + + + diff --git a/fusion-plating/fusion_tasks/data/ir_cron_data.xml b/fusion-plating/fusion_tasks/data/ir_cron_data.xml new file mode 100644 index 00000000..abdd009b --- /dev/null +++ b/fusion-plating/fusion_tasks/data/ir_cron_data.xml @@ -0,0 +1,78 @@ + + + + + + + + Fusion Tasks: Calculate Technician Travel Times + + code + model._cron_calculate_travel_times() + 15 + minutes + True + + + + + Fusion Tasks: Technician Push Notifications + + code + model._cron_send_push_notifications() + 15 + minutes + True + + + + + Fusion Tasks: Sync Remote Tasks (Pull) + + code + model._cron_pull_remote_tasks() + 2 + minutes + True + + + + + Fusion Tasks: Cleanup Old Shadow Tasks + + code + model._cron_cleanup_old_shadows() + 1 + days + True + + + + + + Fusion Tasks: Check Late Technician Arrivals + + code + model._cron_check_late_arrivals() + 10 + minutes + True + + + + + Fusion Tasks: Cleanup Old Locations + + code + model._cron_cleanup_old_locations() + 1 + days + True + + + + + diff --git a/fusion-plating/fusion_tasks/models/__init__.py b/fusion-plating/fusion_tasks/models/__init__.py new file mode 100644 index 00000000..ecfb3fa6 --- /dev/null +++ b/fusion-plating/fusion_tasks/models/__init__.py @@ -0,0 +1,13 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +from . import email_builder_mixin +from . import res_partner +from . import res_company +from . import res_users +from . import res_config_settings +from . import technician_task +from . import task_sync +from . import technician_location +from . import push_subscription diff --git a/fusion-plating/fusion_tasks/models/email_builder_mixin.py b/fusion-plating/fusion_tasks/models/email_builder_mixin.py new file mode 100644 index 00000000..a762dafe --- /dev/null +++ b/fusion-plating/fusion_tasks/models/email_builder_mixin.py @@ -0,0 +1,241 @@ +# -*- coding: utf-8 -*- +# Fusion Claims - Professional Email Builder Mixin +# Provides consistent, dark/light mode safe email templates across all modules. + +from odoo import models + + +class FusionEmailBuilderMixin(models.AbstractModel): + _name = 'fusion.email.builder.mixin' + _description = 'Fusion Email Builder Mixin' + + # ------------------------------------------------------------------ + # Color constants + # ------------------------------------------------------------------ + _EMAIL_COLORS = { + 'info': '#2B6CB0', + 'success': '#38a169', + 'attention': '#d69e2e', + 'urgent': '#c53030', + } + + # ------------------------------------------------------------------ + # Public API + # ------------------------------------------------------------------ + + def _email_build( + self, + title, + summary, + sections=None, + note=None, + note_color=None, + email_type='info', + attachments_note=None, + button_url=None, + button_text='View Case Details', + sender_name=None, + extra_html='', + ): + """Build a complete professional email HTML string. + + Args: + title: Email heading (e.g. "Application Approved") + summary: One-sentence summary HTML (may contain tags) + sections: list of (heading, rows) where rows is list of (label, value) + e.g. [('Case Details', [('Client', 'John'), ('Case', 'S30073')])] + note: Optional note/next-steps text (plain or HTML) + note_color: Override left-border color for note (default uses email_type) + email_type: 'info' | 'success' | 'attention' | 'urgent' + attachments_note: Optional string listing attached files + button_url: Optional CTA button URL + button_text: CTA button label + sender_name: Name for sign-off (defaults to current user) + extra_html: Any additional HTML to insert before sign-off + """ + accent = self._EMAIL_COLORS.get(email_type, self._EMAIL_COLORS['info']) + company = self._get_company_info() + + parts = [] + # -- Wrapper open + accent bar (no forced bg/color so it adapts to dark/light) + parts.append( + f'
' + f'
' + f'
' + ) + + # -- Company name (accent color works in both themes) + parts.append( + f'

{company["name"]}

' + ) + + # -- Title (inherits text color from container) + parts.append( + f'

{title}

' + ) + + # -- Summary (muted via opacity) + parts.append( + f'

{summary}

' + ) + + # -- Sections (details tables) + if sections: + for heading, rows in sections: + parts.append(self._email_section(heading, rows)) + + # -- Note / Next Steps + if note: + nc = note_color or accent + parts.append(self._email_note(note, nc)) + + # -- Extra HTML + if extra_html: + parts.append(extra_html) + + # -- Attachment note + if attachments_note: + parts.append(self._email_attachment_note(attachments_note)) + + # -- CTA Button + if button_url: + parts.append(self._email_button(button_url, button_text, accent)) + + # -- Sign-off + signer = sender_name or (self.env.user.name if self.env.user else '') + parts.append( + f'

' + f'Best regards,
' + f'{signer}
' + f'{company["name"]}

' + ) + + # -- Close content card + parts.append('
') + + # -- Footer + footer_parts = [company['name']] + if company['phone']: + footer_parts.append(company['phone']) + if company['email']: + footer_parts.append(company['email']) + footer_text = ' · '.join(footer_parts) + + parts.append( + f'
' + f'

' + f'{footer_text}
' + f'This is an automated notification from the ADP Claims Management System.

' + f'
' + ) + + # -- Close wrapper + parts.append('
') + + return ''.join(parts) + + # ------------------------------------------------------------------ + # Building blocks + # ------------------------------------------------------------------ + + def _email_section(self, heading, rows): + """Build a labeled details table section. + + Args: + heading: Section title (e.g. "Case Details") + rows: list of (label, value) tuples. Value can be plain text or HTML. + """ + if not rows: + return '' + + html = ( + '' + f'' + ) + + for label, value in rows: + if value is None or value == '' or value is False: + continue + html += ( + f'' + f'' + f'' + f'' + ) + + html += '
{heading}
{label}{value}
' + return html + + def _email_note(self, text, color='#2B6CB0'): + """Build a left-border accent note block.""" + return ( + f'
' + f'

{text}

' + f'
' + ) + + def _email_button(self, url, text='View Case Details', color='#2B6CB0'): + """Build a centered CTA button.""" + return ( + f'

' + f'{text}

' + ) + + def _email_attachment_note(self, description): + """Build a dashed-border attachment callout. + + Args: + description: e.g. "ADP Application (PDF), XML Data File" + """ + return ( + f'
' + f'

' + f'Attached: {description}

' + f'
' + ) + + def _email_status_badge(self, label, color='#2B6CB0'): + """Return an inline status badge/pill HTML snippet.""" + bg_map = { + '#38a169': 'rgba(56,161,105,0.12)', + '#2B6CB0': 'rgba(43,108,176,0.12)', + '#d69e2e': 'rgba(214,158,46,0.12)', + '#c53030': 'rgba(197,48,48,0.12)', + } + bg = bg_map.get(color, 'rgba(43,108,176,0.12)') + return ( + f'' + f'{label}' + ) + + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _get_company_info(self): + """Return company name, phone, email for email templates.""" + company = getattr(self, 'company_id', None) or self.env.company + return { + 'name': company.name or 'Our Company', + 'phone': company.phone or '', + 'email': company.email or '', + } + + def _email_is_enabled(self): + """Check if email notifications are enabled in settings.""" + ICP = self.env['ir.config_parameter'].sudo() + val = ICP.get_param('fusion_claims.enable_email_notifications', 'True') + return val.lower() in ('true', '1', 'yes') diff --git a/fusion-plating/fusion_tasks/models/push_subscription.py b/fusion-plating/fusion_tasks/models/push_subscription.py new file mode 100644 index 00000000..19f90338 --- /dev/null +++ b/fusion-plating/fusion_tasks/models/push_subscription.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +""" +Web Push Subscription model for storing browser push notification subscriptions. +""" + +from odoo import models, fields, api +import logging + +_logger = logging.getLogger(__name__) + + +class FusionPushSubscription(models.Model): + _name = 'fusion.push.subscription' + _description = 'Web Push Subscription' + _order = 'create_date desc' + + user_id = fields.Many2one( + 'res.users', + string='User', + required=True, + ondelete='cascade', + index=True, + ) + endpoint = fields.Text( + string='Endpoint URL', + required=True, + ) + p256dh_key = fields.Text( + string='P256DH Key', + required=True, + ) + auth_key = fields.Text( + string='Auth Key', + required=True, + ) + browser_info = fields.Char( + string='Browser Info', + help='User agent or browser identification', + ) + active = fields.Boolean( + default=True, + ) + + _constraints = [ + models.Constraint( + 'unique(endpoint)', + 'This push subscription endpoint already exists.', + ), + ] + + @api.model + def register_subscription(self, user_id, endpoint, p256dh_key, auth_key, browser_info=None): + """Register or update a push subscription.""" + existing = self.sudo().search([('endpoint', '=', endpoint)], limit=1) + if existing: + existing.write({ + 'user_id': user_id, + 'p256dh_key': p256dh_key, + 'auth_key': auth_key, + 'browser_info': browser_info or existing.browser_info, + 'active': True, + }) + return existing + return self.sudo().create({ + 'user_id': user_id, + 'endpoint': endpoint, + 'p256dh_key': p256dh_key, + 'auth_key': auth_key, + 'browser_info': browser_info, + }) diff --git a/fusion-plating/fusion_tasks/models/res_company.py b/fusion-plating/fusion_tasks/models/res_company.py new file mode 100644 index 00000000..398561de --- /dev/null +++ b/fusion-plating/fusion_tasks/models/res_company.py @@ -0,0 +1,14 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +from odoo import models, fields + + +class ResCompany(models.Model): + _inherit = 'res.company' + + x_fc_google_review_url = fields.Char( + string='Google Review URL', + help='Google Business Profile review link sent to clients after service completion', + ) diff --git a/fusion-plating/fusion_tasks/models/res_config_settings.py b/fusion-plating/fusion_tasks/models/res_config_settings.py new file mode 100644 index 00000000..ec177b13 --- /dev/null +++ b/fusion-plating/fusion_tasks/models/res_config_settings.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +from odoo import models, fields + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + # Google Maps API Settings + fc_google_maps_api_key = fields.Char( + string='Google Maps API Key', + config_parameter='fusion_claims.google_maps_api_key', + help='API key for Google Maps Places autocomplete in address fields', + ) + fc_google_review_url = fields.Char( + related='company_id.x_fc_google_review_url', + readonly=False, + string='Google Review URL', + ) + + # Technician Management + fc_store_open_hour = fields.Float( + string='Store Open Time', + config_parameter='fusion_claims.store_open_hour', + help='Store opening time for technician scheduling (e.g. 9.0 = 9:00 AM)', + ) + fc_store_close_hour = fields.Float( + string='Store Close Time', + config_parameter='fusion_claims.store_close_hour', + help='Store closing time for technician scheduling (e.g. 18.0 = 6:00 PM)', + ) + fc_google_distance_matrix_enabled = fields.Boolean( + string='Enable Distance Matrix', + config_parameter='fusion_claims.google_distance_matrix_enabled', + help='Enable Google Distance Matrix API for travel time calculations between technician tasks', + ) + fc_technician_start_address = fields.Char( + string='Technician Start Address', + config_parameter='fusion_claims.technician_start_address', + help='Default start location for technician travel calculations (e.g. warehouse/office address)', + ) + fc_location_retention_days = fields.Char( + string='Location History Retention (Days)', + config_parameter='fusion_claims.location_retention_days', + help='How many days to keep technician location history. ' + 'Leave empty = 30 days (1 month). ' + '0 = delete at end of each day. ' + '1+ = keep for that many days.', + ) + + # Web Push Notifications + fc_push_enabled = fields.Boolean( + string='Enable Push Notifications', + config_parameter='fusion_claims.push_enabled', + help='Enable web push notifications for technician tasks', + ) + fc_vapid_public_key = fields.Char( + string='VAPID Public Key', + config_parameter='fusion_claims.vapid_public_key', + help='Public key for Web Push VAPID authentication (auto-generated)', + ) + fc_vapid_private_key = fields.Char( + string='VAPID Private Key', + config_parameter='fusion_claims.vapid_private_key', + help='Private key for Web Push VAPID authentication (auto-generated)', + ) + fc_push_advance_minutes = fields.Integer( + string='Notification Advance (min)', + config_parameter='fusion_claims.push_advance_minutes', + help='Send push notifications this many minutes before a scheduled task', + ) diff --git a/fusion-plating/fusion_tasks/models/res_partner.py b/fusion-plating/fusion_tasks/models/res_partner.py new file mode 100644 index 00000000..72f8e978 --- /dev/null +++ b/fusion-plating/fusion_tasks/models/res_partner.py @@ -0,0 +1,79 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +import logging +import requests +from odoo import models, fields, api + +_logger = logging.getLogger(__name__) + + +class ResPartner(models.Model): + _inherit = 'res.partner' + + x_fc_start_address = fields.Char( + string='Start Location', + help='Technician daily start location (home, warehouse, etc.). ' + 'Used as origin for first travel time calculation. ' + 'If empty, the company default HQ address is used.', + ) + x_fc_start_address_lat = fields.Float( + string='Start Latitude', digits=(10, 7), + ) + x_fc_start_address_lng = fields.Float( + string='Start Longitude', digits=(10, 7), + ) + + def _geocode_start_address(self, address): + if not address or not address.strip(): + return 0.0, 0.0 + api_key = self.env['ir.config_parameter'].sudo().get_param( + 'fusion_claims.google_maps_api_key', '') + if not api_key: + return 0.0, 0.0 + try: + resp = requests.get( + 'https://maps.googleapis.com/maps/api/geocode/json', + params={'address': address.strip(), 'key': api_key, 'region': 'ca'}, + timeout=10, + ) + data = resp.json() + if data.get('status') == 'OK' and data.get('results'): + loc = data['results'][0]['geometry']['location'] + return loc['lat'], loc['lng'] + except Exception as e: + _logger.warning("Start address geocoding failed for '%s': %s", address, e) + return 0.0, 0.0 + + @api.model_create_multi + def create(self, vals_list): + records = super().create(vals_list) + for rec, vals in zip(records, vals_list): + addr = vals.get('x_fc_start_address') + if addr: + lat, lng = rec._geocode_start_address(addr) + if lat and lng: + rec.write({ + 'x_fc_start_address_lat': lat, + 'x_fc_start_address_lng': lng, + }) + return records + + def write(self, vals): + res = super().write(vals) + if 'x_fc_start_address' in vals: + addr = vals['x_fc_start_address'] + if addr and addr.strip(): + lat, lng = self._geocode_start_address(addr) + if lat and lng: + super().write({ + 'x_fc_start_address_lat': lat, + 'x_fc_start_address_lng': lng, + }) + else: + super().write({ + 'x_fc_start_address_lat': 0.0, + 'x_fc_start_address_lng': 0.0, + }) + return res diff --git a/fusion-plating/fusion_tasks/models/res_users.py b/fusion-plating/fusion_tasks/models/res_users.py new file mode 100644 index 00000000..7d82b551 --- /dev/null +++ b/fusion-plating/fusion_tasks/models/res_users.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +from odoo import models, fields + + +class ResUsers(models.Model): + _inherit = 'res.users' + + x_fc_is_field_staff = fields.Boolean( + string='Field Staff', + default=False, + help='Check this to show the user in the Technician/Field Staff dropdown when scheduling tasks.', + ) + x_fc_start_address = fields.Char( + related='partner_id.x_fc_start_address', + readonly=False, + string='Start Location', + ) + x_fc_tech_sync_id = fields.Char( + string='Tech Sync ID', + help='Shared identifier for this technician across Odoo instances. ' + 'Must be the same value on all instances for the same person.', + copy=False, + ) diff --git a/fusion-plating/fusion_tasks/models/technician_location.py b/fusion-plating/fusion_tasks/models/technician_location.py new file mode 100644 index 00000000..f2c79b2b --- /dev/null +++ b/fusion-plating/fusion_tasks/models/technician_location.py @@ -0,0 +1,131 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +""" +Fusion Technician Location +GPS location logging for field technicians. +""" + +from odoo import models, fields, api, _ +import logging + +_logger = logging.getLogger(__name__) + + +class FusionTechnicianLocation(models.Model): + _name = 'fusion.technician.location' + _description = 'Technician Location Log' + _order = 'logged_at desc' + + user_id = fields.Many2one( + 'res.users', + string='Technician', + required=True, + index=True, + ondelete='cascade', + ) + latitude = fields.Float( + string='Latitude', + digits=(10, 7), + required=True, + ) + longitude = fields.Float( + string='Longitude', + digits=(10, 7), + required=True, + ) + accuracy = fields.Float( + string='Accuracy (m)', + help='GPS accuracy in meters', + ) + logged_at = fields.Datetime( + string='Logged At', + default=fields.Datetime.now, + required=True, + index=True, + ) + 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): + """Log the current user's location. Called from portal JS.""" + return self.sudo().create({ + 'user_id': self.env.user.id, + 'latitude': latitude, + 'longitude': longitude, + 'accuracy': accuracy or 0, + 'source': 'portal', + }) + + @api.model + def get_latest_locations(self): + """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, + 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, + 'latitude': row['latitude'], + 'longitude': row['longitude'], + 'accuracy': row['accuracy'], + 'logged_at': str(row['logged_at']), + 'sync_instance': src, + }) + return result + + @api.model + def _cron_cleanup_old_locations(self): + """Remove location logs based on configurable retention setting. + + Setting (fusion_claims.location_retention_days): + - Empty / not set => keep 30 days (default) + - "0" => delete at end of day (keep today only) + - "1" .. "N" => keep for N days + """ + ICP = self.env['ir.config_parameter'].sudo() + raw = (ICP.get_param('fusion_claims.location_retention_days') or '').strip() + + if raw == '': + retention_days = 30 # default: 1 month + else: + try: + retention_days = max(int(raw), 0) + except (ValueError, TypeError): + retention_days = 30 + + cutoff = fields.Datetime.subtract(fields.Datetime.now(), days=retention_days) + old_records = self.search([('logged_at', '<', cutoff)]) + count = len(old_records) + if count: + old_records.unlink() + _logger.info( + "Cleaned up %d technician location records (retention=%d days)", + count, retention_days, + ) diff --git a/fusion-plating/fusion_tasks/models/technician_task.py b/fusion-plating/fusion_tasks/models/technician_task.py new file mode 100644 index 00000000..768d9ca4 --- /dev/null +++ b/fusion-plating/fusion_tasks/models/technician_task.py @@ -0,0 +1,3028 @@ +# -*- coding: utf-8 -*- +# Copyright 2024-2026 Nexa Systems Inc. +# License OPL-1 (Odoo Proprietary License v1.0) + +""" +Fusion Technician Task +Scheduling and task management for field technicians. +Replaces Monday.com for technician schedule tracking. +""" + +from odoo import models, fields, api, _ +from odoo.exceptions import UserError, ValidationError +from odoo.osv import expression +from markupsafe import Markup +import logging +import json +import uuid +import requests +from datetime import datetime as dt_datetime, timedelta +import urllib.parse + +_logger = logging.getLogger(__name__) + + +class FusionTechnicianTask(models.Model): + _name = 'fusion.technician.task' + _description = 'Technician Task' + _order = 'scheduled_date, sequence, time_start, id' + _inherit = ['mail.thread', 'mail.activity.mixin', 'fusion.email.builder.mixin'] + _rec_name = 'name' + + def _compute_display_name(self): + """Richer display name: Client - Type | 9:00 AM - 10:00 AM [+2 techs].""" + type_labels = dict(self._fields['task_type'].selection) + for task in self: + client = task.x_fc_sync_client_name if task.x_fc_sync_source else (task.partner_id.name or '') + ttype = type_labels.get(task.task_type, task.task_type or '') + start = self._float_to_time_str(task.time_start) + end = self._float_to_time_str(task.time_end) + parts = [client, ttype] + label = ' - '.join(p for p in parts if p) + if start and end: + label += f' | {start} - {end}' + extra = len(task.additional_technician_ids) + if extra: + label += f' [+{extra} tech{"s" if extra > 1 else ""}]' + task.display_name = label or task.name + + # ------------------------------------------------------------------ + # STORE HOURS HELPER + # ------------------------------------------------------------------ + def _get_store_hours(self): + """Return (open_hour, close_hour) from settings. Defaults 9.0 / 18.0.""" + ICP = self.env['ir.config_parameter'].sudo() + try: + open_h = float(ICP.get_param('fusion_claims.store_open_hour', '9.0') or '9.0') + except (ValueError, TypeError): + open_h = 9.0 + try: + close_h = float(ICP.get_param('fusion_claims.store_close_hour', '18.0') or '18.0') + except (ValueError, TypeError): + close_h = 18.0 + return (open_h, close_h) + + # ------------------------------------------------------------------ + # CORE FIELDS + # ------------------------------------------------------------------ + name = fields.Char( + string='Task Reference', + required=True, + copy=False, + readonly=True, + default=lambda self: _('New'), + ) + active = fields.Boolean(default=True) + + # Cross-instance sync fields + x_fc_sync_source = fields.Char( + 'Source Instance', readonly=True, index=True, + help='Origin instance ID if this is a synced shadow task (e.g. westin, mobility)', + ) + x_fc_sync_remote_id = fields.Integer( + 'Remote Task ID', readonly=True, + help='ID of the task on the remote instance', + ) + x_fc_sync_uuid = fields.Char( + 'Sync UUID', readonly=True, index=True, copy=False, + help='Unique ID for cross-instance deduplication', + ) + x_fc_is_shadow = fields.Boolean( + 'Shadow Task', compute='_compute_is_shadow', store=True, + help='True if this task was synced from another instance', + ) + x_fc_sync_client_name = fields.Char( + '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, + ) + + @api.depends('x_fc_sync_source') + def _compute_is_shadow(self): + local_id = self.env['ir.config_parameter'].sudo().get_param( + 'fusion_claims.sync_instance_id', '') + for task in self: + 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', + required=True, + tracking=True, + domain="[('x_fc_is_field_staff', '=', True)]", + help='Lead technician responsible for this task', + ) + technician_name = fields.Char( + related='technician_id.name', + string='Technician Name', + store=True, + ) + additional_technician_ids = fields.Many2many( + 'res.users', + 'technician_task_additional_tech_rel', + 'task_id', + 'user_id', + string='Additional Technicians', + domain="[('x_fc_is_field_staff', '=', True)]", + tracking=True, + help='Additional technicians assigned to assist on this task', + ) + all_technician_ids = fields.Many2many( + 'res.users', + compute='_compute_all_technician_ids', + string='All Technicians', + help='Lead + additional technicians combined', + ) + additional_tech_count = fields.Integer( + compute='_compute_all_technician_ids', + string='Extra Techs', + ) + all_technician_names = fields.Char( + compute='_compute_all_technician_ids', + string='All Technician Names', + ) + + @api.depends('technician_id', 'additional_technician_ids') + def _compute_all_technician_ids(self): + for task in self: + all_techs = task.technician_id | task.additional_technician_ids + task.all_technician_ids = all_techs + task.additional_tech_count = len(task.additional_technician_ids) + task.all_technician_names = ', '.join(all_techs.mapped('name')) + + + task_type = fields.Selection([ + ('delivery', 'Delivery'), + ('repair', 'Repair'), + ('pickup', 'Pickup'), + ('troubleshoot', 'Troubleshooting'), + ('assessment', 'Assessment'), + ('installation', 'Installation'), + ('maintenance', 'Maintenance'), + ('ltc_visit', 'LTC Visit'), + ('other', 'Other'), + ], string='Task Type', required=True, default='delivery', tracking=True) + + # ------------------------------------------------------------------ + # SCHEDULING + # ------------------------------------------------------------------ + scheduled_date = fields.Date( + string='Scheduled Date', + tracking=True, + default=fields.Date.context_today, + index=True, + ) + time_start = fields.Float( + string='Start Time', + help='Start time in hours (e.g. 9.5 = 9:30 AM)', + default=9.0, + ) + time_end = fields.Float( + string='End Time', + help='End time in hours (e.g. 10.5 = 10:30 AM)', + default=10.0, + ) + time_start_display = fields.Char( + string='Start', + compute='_compute_time_displays', + ) + time_end_display = fields.Char( + string='End', + compute='_compute_time_displays', + ) + # Legacy 12h selection fields -- kept for DB compatibility, hidden on form + time_start_12h = fields.Selection( + selection='_get_time_selection', + string='Start Time (12h)', + compute='_compute_time_12h', + inverse='_inverse_time_start_12h', + store=True, + ) + time_end_12h = fields.Selection( + selection='_get_time_selection', + string='End Time (12h)', + compute='_compute_time_12h', + inverse='_inverse_time_end_12h', + store=True, + ) + sequence = fields.Integer( + string='Sequence', + default=10, + help='Order of task within the day', + ) + duration_hours = fields.Float( + string='Duration', + default=1.0, + help='Task duration in hours. Auto-calculates end time.', + ) + + # Task type -> default duration mapping + TASK_TYPE_DURATIONS = { + 'delivery': 1.0, + 'repair': 2.0, + 'pickup': 0.5, + 'troubleshoot': 1.5, + 'assessment': 1.5, + 'installation': 2.0, + 'maintenance': 1.5, + 'ltc_visit': 3.0, + 'other': 1.0, + } + + # Previous task travel warning banner + prev_task_summary_html = fields.Html( + string='Previous Task', + compute='_compute_prev_task_summary', + sanitize=False, + ) + + # Datetime fields for calendar view (computed from date + float time) + datetime_start = fields.Datetime( + string='Start', + compute='_compute_datetimes', + inverse='_inverse_datetime_start', + store=True, + help='Combined start datetime for calendar display', + ) + datetime_end = fields.Datetime( + string='End', + compute='_compute_datetimes', + inverse='_inverse_datetime_end', + store=True, + 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', + compute='_compute_schedule_info', + sanitize=False, + ) + + # ------------------------------------------------------------------ + # STATUS + # ------------------------------------------------------------------ + status = fields.Selection([ + ('pending', 'Pending'), + ('scheduled', 'Scheduled'), + ('en_route', 'En Route'), + ('in_progress', 'In Progress'), + ('completed', 'Completed'), + ('cancelled', 'Cancelled'), + ('rescheduled', 'Rescheduled'), + ], string='Status', default='scheduled', required=True, tracking=True, index=True) + + priority = fields.Selection([ + ('0', 'Normal'), + ('1', 'Urgent'), + ('2', 'Emergency'), + ], string='Priority', default='0') + + color = fields.Integer( + string='Color Index', + compute='_compute_color', + ) + + # ------------------------------------------------------------------ + # CLIENT / ADDRESS + # ------------------------------------------------------------------ + partner_id = fields.Many2one( + 'res.partner', + string='Client Name', + tracking=True, + help='Client for this task', + ) + partner_phone = fields.Char( + related='partner_id.phone', + string='Client Phone', + ) + + # Address fields - computed from shipping address or manually set + address_partner_id = fields.Many2one( + 'res.partner', + string='Task Address', + help='Partner record containing the task address (usually shipping address)', + ) + address_street = fields.Char(string='Street') + address_street2 = fields.Char(string='Unit/Suite #') + address_city = fields.Char(string='City') + address_state_id = fields.Many2one('res.country.state', string='Province') + address_zip = fields.Char(string='Postal Code') + address_buzz_code = fields.Char(string='Buzz Code', help='Building buzzer code for entry') + address_display = fields.Text( + string='Full Address', + compute='_compute_address_display', + ) + + # In-store flag -- uses company address instead of client address + is_in_store = fields.Boolean( + string='In Store', + default=False, + help='Task takes place at the store/office. Uses company address automatically.', + ) + + # Geocoding + address_lat = fields.Float(string='Latitude', digits=(10, 7)) + address_lng = fields.Float(string='Longitude', digits=(10, 7)) + + # ------------------------------------------------------------------ + # TASK DETAILS + # ------------------------------------------------------------------ + description = fields.Text( + string='Task Description', + required=True, + help='What needs to be done', + ) + equipment_needed = fields.Text( + string='Equipment / Materials Needed', + help='Tools and materials the technician should bring', + ) + pod_required = fields.Boolean( + string='POD Required', + 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, + ) + + # ------------------------------------------------------------------ + # CLIENT EMAIL / REVIEW OPTIONS + # ------------------------------------------------------------------ + x_fc_send_client_updates = fields.Boolean( + string='Send Client Email Updates', + default=True, + help='Send automatic emails to the client when the technician is en route and when the task is completed', + ) + x_fc_ask_google_review = fields.Boolean( + string='Request Google Review', + default=True, + help='Include a Google review request in the completion email to the client', + ) + x_fc_late_notified = fields.Boolean( + string='Late Notification Sent', + default=False, + readonly=True, + help='Internal flag: whether a late-arrival notification has already been sent for this task', + ) + + # ------------------------------------------------------------------ + # COMPLETION + # ------------------------------------------------------------------ + completion_notes = fields.Html( + string='Completion Notes', + help='Notes from the technician about what was done', + ) + completion_datetime = fields.Datetime( + string='Completed At', + tracking=True, + ) + + # GPS location captured at task actions + started_latitude = fields.Float( + string='Started Latitude', digits=(10, 7), readonly=True, + ) + started_longitude = fields.Float( + string='Started Longitude', digits=(10, 7), readonly=True, + ) + completed_latitude = fields.Float( + string='Completed Latitude', digits=(10, 7), readonly=True, + ) + completed_longitude = fields.Float( + string='Completed Longitude', digits=(10, 7), readonly=True, + ) + action_latitude = fields.Float( + string='Last Action Latitude', digits=(10, 7), readonly=True, + ) + action_longitude = fields.Float( + string='Last Action Longitude', digits=(10, 7), readonly=True, + ) + action_location_accuracy = fields.Float( + string='Location Accuracy (m)', readonly=True, + ) + + voice_note_audio = fields.Binary( + string='Voice Recording', + attachment=True, + ) + voice_note_transcription = fields.Text( + string='Voice Transcription', + ) + + # ------------------------------------------------------------------ + # TRAVEL + # ------------------------------------------------------------------ + travel_time_minutes = fields.Integer( + string='Travel Time (min)', + help='Estimated travel time from previous task in minutes', + ) + travel_distance_km = fields.Float( + string='Travel Distance (km)', + digits=(8, 1), + ) + travel_origin = fields.Char( + string='Travel From', + help='Origin address for travel calculation', + ) + previous_task_id = fields.Many2one( + 'fusion.technician.task', + string='Previous Task', + help='The task before this one in the schedule (for travel calculation)', + ) + + # ------------------------------------------------------------------ + # PUSH NOTIFICATION TRACKING + # ------------------------------------------------------------------ + push_notified = fields.Boolean( + string='Push Notified', + default=False, + help='Whether a push notification was sent for this task', + ) + push_notified_datetime = fields.Datetime( + string='Notified At', + ) + + # ------------------------------------------------------------------ + # COMPUTED FIELDS + # ------------------------------------------------------------------ + + # ------------------------------------------------------------------ + # SLOT AVAILABILITY HELPERS + # ------------------------------------------------------------------ + + def _get_calendar_busy_intervals(self, tech_id, date): + """Return busy intervals from calendar.event for a technician on a date. + + Queries events where the technician is an attendee, excluding events + already linked to a fusion task (to avoid double-counting). + Returns list of (start_float, end_float) in local time. + """ + if not tech_id or not date: + return [] + user = self.env['res.users'].browse(tech_id) + if not user.exists(): + return [] + partner_id = user.partner_id.id + + import pytz + tz = self._get_local_tz() + day_start = tz.localize(dt_datetime.combine(date, dt_datetime.min.time())) + day_end = day_start + timedelta(days=1) + day_start_utc = day_start.astimezone(pytz.utc).replace(tzinfo=None) + day_end_utc = day_end.astimezone(pytz.utc).replace(tzinfo=None) + + task_event_ids = self.sudo().search([ + ('calendar_event_id', '!=', False), + ('scheduled_date', '=', date), + ]).mapped('calendar_event_id').ids + + domain = [ + ('partner_ids', 'in', [partner_id]), + ('start', '<', day_end_utc), + ('stop', '>', day_start_utc), + ('active', '=', True), + ] + if task_event_ids: + domain.append(('id', 'not in', task_event_ids)) + + events = self.env['calendar.event'].sudo().search(domain) + intervals = [] + for ev in events: + ev_start = pytz.utc.localize(ev.start).astimezone(tz) + ev_end = pytz.utc.localize(ev.stop).astimezone(tz) + start_float = ev_start.hour + ev_start.minute / 60.0 + end_float = ev_end.hour + ev_end.minute / 60.0 + if ev_start.date() < date: + start_float = 0.0 + if ev_end.date() > date: + end_float = 24.0 + intervals.append((start_float, end_float)) + return sorted(intervals, key=lambda x: x[0]) + + def _find_next_available_slot(self, tech_id, date, preferred_start=9.0, + duration=1.0, exclude_task_id=False, + dest_lat=0, dest_lng=0): + """Find the next available time slot for a technician on a given date. + + Scans all non-cancelled tasks for that tech+date, sorts them, and + walks through the day (9 AM - 6 PM) looking for a gap that fits + the requested duration PLUS travel time from the previous task. + + :param tech_id: res.users id of the technician + :param date: date object for the day to check + :param preferred_start: float hour to start looking from (default 9.0) + :param duration: required slot length in hours (default 1.0) + :param exclude_task_id: task id to exclude (when editing an existing task) + :param dest_lat: latitude of the destination (new task location) + :param dest_lng: longitude of the destination (new task location) + :returns: (start_float, end_float) or (False, False) if fully booked + """ + STORE_OPEN, STORE_CLOSE = self._get_store_hours() + + if not tech_id or not date: + return (preferred_start, preferred_start + duration) + + domain = [ + '|', + ('technician_id', '=', tech_id), + ('additional_technician_ids', 'in', [tech_id]), + ('scheduled_date', '=', date), + ('status', 'not in', ['cancelled']), + ] + if exclude_task_id: + domain.append(('id', '!=', exclude_task_id)) + + booked = self.sudo().search(domain, order='time_start') + + # Build sorted list of (start, end, lat, lng) intervals + intervals = [] + for b in booked: + intervals.append(( + max(b.time_start, STORE_OPEN), + min(b.time_end, STORE_CLOSE), + b.address_lat or 0, + b.address_lng or 0, + )) + + for cal_start, cal_end in self._get_calendar_busy_intervals(tech_id, date): + clamped_start = max(cal_start, STORE_OPEN) + clamped_end = min(cal_end, STORE_CLOSE) + if clamped_start < clamped_end: + intervals.append((clamped_start, clamped_end, 0, 0)) + intervals.sort(key=lambda x: x[0]) + + def _travel_hours(from_lat, from_lng, to_lat, to_lng): + """Calculate travel time in hours between two locations. + Returns 0 if coordinates are missing. Rounds up to 15-min.""" + if not from_lat or not from_lng or not to_lat or not to_lng: + return 0 + travel_min = self._quick_travel_time( + from_lat, from_lng, to_lat, to_lng) + if travel_min > 0: + import math + return math.ceil(travel_min / 15.0) * 0.25 + return 0 + + def _travel_from_prev(iv_lat, iv_lng): + """Travel from a previous booked task TO the new task.""" + return _travel_hours(iv_lat, iv_lng, dest_lat, dest_lng) + + def _travel_to_next(next_lat, next_lng): + """Travel FROM the new task TO the next booked task.""" + return _travel_hours(dest_lat, dest_lng, next_lat, next_lng) + + def _check_gap_fits(cursor, dur, idx): + """Check if a slot at 'cursor' for 'dur' hours fits before + the interval at index 'idx' (accounting for travel TO that task).""" + if idx >= len(intervals): + return cursor + dur <= STORE_CLOSE + next_start, _ne, next_lat, next_lng = intervals[idx] + travel_fwd = _travel_to_next(next_lat, next_lng) + return cursor + dur + travel_fwd <= next_start + + # Walk through gaps, starting from preferred_start + cursor = max(preferred_start, STORE_OPEN) + + for i, (iv_start, iv_end, iv_lat, iv_lng) in enumerate(intervals): + if cursor + duration <= iv_start: + # Check travel time from new task end TO next booked task + if _check_gap_fits(cursor, duration, i): + return (cursor, cursor + duration) + # Not enough travel time -- try pushing start earlier or skip + # If we can't fit here, fall through to jump past this interval + # Jump past this booked interval + travel buffer from prev to new + new_cursor = max(cursor, iv_end) + travel = _travel_from_prev(iv_lat, iv_lng) + new_cursor += travel + # Snap to nearest 15 min + new_cursor = round(new_cursor * 4) / 4 + cursor = new_cursor + + # Check gap after last interval (no next task, so no forward travel needed) + if cursor + duration <= STORE_CLOSE: + return (cursor, cursor + duration) + + # No gap found from preferred_start onward -- wrap and try from start + if preferred_start > STORE_OPEN: + cursor = STORE_OPEN + for i, (iv_start, iv_end, iv_lat, iv_lng) in enumerate(intervals): + if cursor + duration <= iv_start: + if _check_gap_fits(cursor, duration, i): + return (cursor, cursor + duration) + new_cursor = max(cursor, iv_end) + travel = _travel_from_prev(iv_lat, iv_lng) + new_cursor += travel + new_cursor = round(new_cursor * 4) / 4 + cursor = new_cursor + if cursor + duration <= STORE_CLOSE: + return (cursor, cursor + duration) + + return (False, False) + + def _get_available_gaps(self, tech_id, date, exclude_task_id=False): + """Return a list of available (start, end) gaps for a technician on a date. + + Used by schedule_info_html to show green "available" badges. + Considers tasks where the tech is either lead or additional. + """ + STORE_OPEN, STORE_CLOSE = self._get_store_hours() + + if not tech_id or not date: + return [(STORE_OPEN, STORE_CLOSE)] + + domain = [ + '|', + ('technician_id', '=', tech_id), + ('additional_technician_ids', 'in', [tech_id]), + ('scheduled_date', '=', date), + ('status', 'not in', ['cancelled']), + ] + if exclude_task_id: + domain.append(('id', '!=', exclude_task_id)) + + booked = self.sudo().search(domain, order='time_start') + intervals = [(max(b.time_start, STORE_OPEN), min(b.time_end, STORE_CLOSE)) + for b in booked] + + gaps = [] + cursor = STORE_OPEN + for iv_start, iv_end in intervals: + if cursor < iv_start: + gaps.append((cursor, iv_start)) + cursor = max(cursor, iv_end) + if cursor < STORE_CLOSE: + gaps.append((cursor, STORE_CLOSE)) + return gaps + + @api.model + def _get_time_selection(self): + """Generate 12-hour time slots every 15 minutes, store hours only (9 AM - 6 PM).""" + times = [] + for hour in range(9, 18): # 9 AM to 5:45 PM + for minute in (0, 15, 30, 45): + float_val = hour + minute / 60.0 + key = f'{float_val:.2f}' + period = 'AM' if hour < 12 else 'PM' + display_hour = hour % 12 or 12 + label = f'{display_hour}:{minute:02d} {period}' + times.append((key, label)) + # Add 6:00 PM as end-time option + times.append(('18.00', '6:00 PM')) + return times + + @api.depends('time_start', 'time_end') + def _compute_time_12h(self): + """Sync the 12h selection fields from the raw float values.""" + for task in self: + task.time_start_12h = f'{(task.time_start or 9.0):.2f}' + task.time_end_12h = f'{(task.time_end or 10.0):.2f}' + + def _inverse_time_start_12h(self): + for task in self: + if task.time_start_12h: + task.time_start = float(task.time_start_12h) + + def _inverse_time_end_12h(self): + for task in self: + if task.time_end_12h: + task.time_end = float(task.time_end_12h) + + @api.depends('time_start', 'time_end') + def _compute_time_displays(self): + """Convert float hours to readable time strings.""" + for task in self: + task.time_start_display = self._float_to_time_str(task.time_start) + task.time_end_display = self._float_to_time_str(task.time_end) + + @api.onchange('task_type') + def _onchange_task_type_duration(self): + """Set default duration based on task type.""" + if self.task_type: + self.duration_hours = self.TASK_TYPE_DURATIONS.get(self.task_type, 1.0) + # Also recalculate end time + if self.time_start: + _open, close = self._get_store_hours() + self.time_end = min(self.time_start + self.duration_hours, close) + + @api.onchange('time_start', 'duration_hours') + def _onchange_compute_end_time(self): + """Auto-compute end time from start + duration. Also run overlap check.""" + if self.time_start and self.duration_hours: + _open, close = self._get_store_hours() + new_end = min(self.time_start + self.duration_hours, close) + self.time_end = new_end + # Run overlap snap if we have enough data + if self.technician_id and self.scheduled_date and self.time_start and self.time_end: + result = self._snap_if_overlap() + if result: + return result + + @api.depends('scheduled_date', 'time_start', 'time_end') + def _compute_datetimes(self): + """Combine date + float time into proper Datetime fields for calendar. + time_start/time_end are LOCAL hours; datetime_start/end must be UTC for Odoo.""" + import pytz + user_tz = self._get_local_tz() + for task in self: + if task.scheduled_date: + # Build local datetime, then convert to UTC + base = dt_datetime.combine(task.scheduled_date, dt_datetime.min.time()) + store_open, _close = task._get_store_hours() + local_start = user_tz.localize(base + timedelta(hours=task.time_start or store_open)) + local_end = user_tz.localize(base + timedelta(hours=task.time_end or (store_open + 1.0))) + task.datetime_start = local_start.astimezone(pytz.utc).replace(tzinfo=None) + task.datetime_end = local_end.astimezone(pytz.utc).replace(tzinfo=None) + else: + task.datetime_start = False + task.datetime_end = False + + def _inverse_datetime_start(self): + """When datetime_start is changed (e.g. from calendar drag), update date + time.""" + import pytz + user_tz = pytz.timezone(self.env.user.tz or 'UTC') + for task in self: + if task.datetime_start: + local_dt = pytz.utc.localize(task.datetime_start).astimezone(user_tz) + task.scheduled_date = local_dt.date() + task.time_start = local_dt.hour + local_dt.minute / 60.0 + + def _inverse_datetime_end(self): + """When datetime_end is changed (e.g. from calendar resize), update time_end.""" + import pytz + user_tz = pytz.timezone(self.env.user.tz or 'UTC') + for task in self: + if task.datetime_end: + local_dt = pytz.utc.localize(task.datetime_end).astimezone(user_tz) + task.time_end = local_dt.hour + local_dt.minute / 60.0 + + @api.depends('technician_id', 'scheduled_date') + def _compute_schedule_info(self): + """Show booked + available time slots for the technician on the selected date.""" + for task in self: + if not task.technician_id or not task.scheduled_date: + task.schedule_info_html = '' + continue + + exclude_id = task.id if task.id else 0 + # Find other tasks for the same technician+date (lead or additional) + others = self.sudo().search([ + '|', + ('technician_id', '=', task.technician_id.id), + ('additional_technician_ids', 'in', [task.technician_id.id]), + ('scheduled_date', '=', task.scheduled_date), + ('status', 'not in', ['cancelled']), + ('id', '!=', exclude_id), + ], order='time_start') + + if not others: + s_open, s_close = self._get_store_hours() + open_str = self._float_to_time_str(s_open) + close_str = self._float_to_time_str(s_close) + task.schedule_info_html = Markup( + f'
' + f' All slots available ({open_str} - {close_str})
' + ) + continue + + # Booked badges + booked_lines = [] + for o in others: + start_str = self._float_to_time_str(o.time_start) + end_str = self._float_to_time_str(o.time_end) + type_label = dict(self._fields['task_type'].selection).get(o.task_type, o.task_type) + client_name = o.partner_id.name or '' + booked_lines.append( + f'' + f'{start_str} - {end_str} ({type_label}{" - " + client_name if client_name else ""})' + f'' + ) + + # Available gaps badges + gaps = self._get_available_gaps( + task.technician_id.id, task.scheduled_date, + exclude_task_id=exclude_id, + ) + avail_lines = [] + for g_start, g_end in gaps: + # Only show gaps >= 15 min + if g_end - g_start >= 0.25: + avail_lines.append( + f'' + f'{self._float_to_time_str(g_start)} - {self._float_to_time_str(g_end)}' + f'' + ) + + html_parts = [ + '
', + ' Booked: ', + ' '.join(booked_lines), + ] + if avail_lines: + html_parts.append( + '
' + 'Available: ' + + ' '.join(avail_lines) + ) + elif not avail_lines: + html_parts.append( + '
' + 'Fully booked' + ) + html_parts.append('
') + + task.schedule_info_html = Markup(''.join(html_parts)) + + @api.depends('technician_id', 'scheduled_date', 'time_start', + 'address_lat', 'address_lng', 'address_street') + def _compute_prev_task_summary(self): + """Show previous task info + travel time warning with color coding.""" + for task in self: + if not task.technician_id or not task.scheduled_date: + task.prev_task_summary_html = '' + continue + + exclude_id = task.id if task.id else 0 + # Find the task that ends just before this one starts (lead or additional) + prev_tasks = self.sudo().search([ + '|', + ('technician_id', '=', task.technician_id.id), + ('additional_technician_ids', 'in', [task.technician_id.id]), + ('scheduled_date', '=', task.scheduled_date), + ('status', 'not in', ['cancelled']), + ('id', '!=', exclude_id), + ('time_end', '<=', task.time_start or 99.0), + ], order='time_end desc', limit=1) + + if not prev_tasks: + # Check if this is the first task of the day -- show start location info + task.prev_task_summary_html = Markup( + '
' + ' First task of the day -- ' + 'travel calculated from start location.
' + ) + continue + + prev = prev_tasks[0] + prev_start = self._float_to_time_str(prev.time_start) + prev_end = self._float_to_time_str(prev.time_end) + type_label = dict(self._fields['task_type'].selection).get( + prev.task_type, prev.task_type or '') + client_name = prev.partner_id.name or '' + prev_addr = prev.address_display or 'No address' + + # Calculate gap between prev task end and this task start + s_open, _s_close = self._get_store_hours() + gap_hours = (task.time_start or s_open) - (prev.time_end or s_open) + gap_minutes = int(gap_hours * 60) + + # Try to get travel time if both have coordinates + travel_minutes = 0 + travel_text = '' + if (prev.address_lat and prev.address_lng and + task.address_lat and task.address_lng): + travel_minutes = self._quick_travel_time( + prev.address_lat, prev.address_lng, + task.address_lat, task.address_lng, + ) + if travel_minutes > 0: + travel_text = f'{travel_minutes} min drive' + else: + travel_text = 'Could not calculate travel time' + elif prev.address_street and task.address_street: + travel_text = 'Save to calculate travel time' + else: + travel_text = 'Address missing -- cannot calculate travel' + + # Determine color coding + if travel_minutes > 0 and gap_minutes >= travel_minutes: + bg_class = 'alert-success' # Green -- enough time + icon = 'fa-check-circle' + status_text = ( + f'{gap_minutes} min gap -- enough travel time ' + f'(~{travel_minutes} min drive)' + ) + elif travel_minutes > 0 and gap_minutes > 0: + bg_class = 'alert-warning' # Yellow -- tight + icon = 'fa-exclamation-triangle' + status_text = ( + f'{gap_minutes} min gap -- tight! ' + f'Travel is ~{travel_minutes} min drive' + ) + elif travel_minutes > 0 and gap_minutes <= 0: + bg_class = 'alert-danger' # Red -- impossible + icon = 'fa-times-circle' + status_text = ( + f'No gap! Previous task ends at {prev_end}. ' + f'Travel is ~{travel_minutes} min drive' + ) + else: + bg_class = 'alert-info' # Blue -- no travel data yet + icon = 'fa-info-circle' + status_text = travel_text + + html = ( + f'
' + f' ' + f'Previous: {prev.name} ' + f'({type_label}) {prev_start} - {prev_end}' + f'{" -- " + client_name if client_name else ""}' + f'
' + f' {prev_addr}' + f'
' + f' {status_text}' + f'
' + ) + task.prev_task_summary_html = Markup(html) + + def _quick_travel_time(self, from_lat, from_lng, to_lat, to_lng): + """Quick inline travel time calculation using Google Distance Matrix API. + Returns travel time in minutes, or 0 if unavailable.""" + try: + api_key = self.env['ir.config_parameter'].sudo().get_param( + 'fusion_claims.google_maps_api_key', '') + if not api_key: + return 0 + + url = 'https://maps.googleapis.com/maps/api/distancematrix/json' + params = { + 'origins': f'{from_lat},{from_lng}', + 'destinations': f'{to_lat},{to_lng}', + 'mode': 'driving', + 'avoid': 'tolls', + 'departure_time': 'now', + 'key': api_key, + } + resp = requests.get(url, params=params, timeout=5) + data = resp.json() + if data.get('status') == 'OK': + elements = data['rows'][0]['elements'][0] + if elements.get('status') == 'OK': + # Use duration_in_traffic if available, else duration + duration = elements.get( + 'duration_in_traffic', elements.get('duration', {})) + seconds = duration.get('value', 0) + return max(1, int(seconds / 60)) + except Exception: + _logger.warning('Failed to calculate travel time', exc_info=True) + return 0 + + @api.depends('status') + def _compute_color(self): + color_map = { + 'pending': 5, # purple + 'scheduled': 0, # grey + 'en_route': 4, # blue + 'in_progress': 2, # orange + 'completed': 10, # green + 'cancelled': 1, # red + 'rescheduled': 3, # yellow + } + for task in self: + task.color = color_map.get(task.status, 0) + + @api.depends('address_street', 'address_street2', 'address_city', + 'address_state_id', 'address_zip') + def _compute_address_display(self): + for task in self: + street = task.address_street or '' + # If the street field already contains a full address (has a comma), + # use it directly -- Google Places stores the formatted address here. + if ',' in street and ( + (task.address_city and task.address_city in street) or + (task.address_zip and task.address_zip in street) + ): + # Street already has full address; just append unit if separate + if task.address_street2 and task.address_street2 not in street: + task.address_display = f"{street}, {task.address_street2}" + else: + task.address_display = street + else: + # Build from components (manual entry or legacy data) + parts = [ + street, + task.address_street2, + task.address_city, + task.address_state_id.name if task.address_state_id else '', + task.address_zip, + ] + task.address_display = ', '.join([p for p in parts if p]) + + # ------------------------------------------------------------------ + # ONCHANGE - Auto-fill address from client + # ------------------------------------------------------------------ + + @api.onchange('is_in_store') + def _onchange_is_in_store(self): + """Auto-fill company address when task is marked as in-store.""" + if self.is_in_store: + company_partner = self.env.company.partner_id + if company_partner and company_partner.street: + self._fill_address_from_partner(company_partner) + else: + self.address_street = self.env.company.name or 'In Store' + + @api.onchange('partner_id') + def _onchange_partner_id(self): + """Auto-fill address fields from the selected client's address.""" + if self.is_in_store: + return + if self.partner_id: + addr = self.partner_id + self.address_partner_id = addr.id + self.address_street = addr.street or '' + self.address_street2 = addr.street2 or '' + self.address_city = addr.city or '' + self.address_state_id = addr.state_id.id if addr.state_id else False + self.address_zip = addr.zip or '' + self.address_lat = addr.x_fc_latitude if hasattr(addr, 'x_fc_latitude') and addr.x_fc_latitude else 0 + self.address_lng = addr.x_fc_longitude if hasattr(addr, 'x_fc_longitude') and addr.x_fc_longitude else 0 + + def _fill_address_from_partner(self, addr): + """Populate address fields from a partner record.""" + if not addr: + return + self.address_partner_id = addr.id + self.address_street = addr.street or '' + self.address_street2 = addr.street2 or '' + self.address_city = addr.city or '' + self.address_state_id = addr.state_id.id if addr.state_id else False + self.address_zip = addr.zip or '' + self.address_lat = addr.x_fc_latitude if hasattr(addr, 'x_fc_latitude') and addr.x_fc_latitude else 0 + self.address_lng = addr.x_fc_longitude if hasattr(addr, 'x_fc_longitude') and addr.x_fc_longitude else 0 + + # ------------------------------------------------------------------ + # CONSTRAINTS + VALIDATION + # ------------------------------------------------------------------ + + @api.constrains('address_street', 'address_lat', 'address_lng', 'is_in_store') + def _check_address_required(self): + """Non-in-store tasks must have a geocoded address.""" + for task in self: + if task.x_fc_sync_source: + continue + if task.is_in_store: + continue + if not task.address_street: + raise ValidationError(_( + "A valid address is required. If this task is at the store, " + "please check the 'In Store' option." + )) + + @api.constrains('technician_id', 'additional_technician_ids', + 'scheduled_date', 'time_start', 'time_end') + def _check_no_overlap(self): + """Prevent overlapping bookings for the same technician on the same date. + + Checks both the lead technician and all additional technicians. + """ + for task in self: + if task.status == 'cancelled': + continue + if task.x_fc_sync_source: + continue + # Validate time range + if task.time_start >= task.time_end: + raise ValidationError(_("Start time must be before end time.")) + # Validate store hours + s_open, s_close = self._get_store_hours() + if task.time_start < s_open or task.time_end > s_close: + open_str = self._float_to_time_str(s_open) + close_str = self._float_to_time_str(s_close) + raise ValidationError(_( + "Tasks must be scheduled within store hours (%s - %s)." + ) % (open_str, close_str)) + # Validate not in the past (only for new/scheduled local tasks) + if task.status == 'scheduled' and task.scheduled_date and not task.x_fc_sync_source: + local_now = self._local_now() + today = local_now.date() + if task.scheduled_date < today: + raise ValidationError(_("Cannot schedule tasks in the past.")) + if task.scheduled_date == today: + current_hour = local_now.hour + local_now.minute / 60.0 + if task.time_start < current_hour: + pass # Allow editing existing tasks that started earlier today + # Check overlap for lead + additional technicians + all_tech_ids = (task.technician_id | task.additional_technician_ids).ids + for tech_id in all_tech_ids: + tech_name = self.env['res.users'].browse(tech_id).name + overlapping = self.sudo().search([ + '|', + ('technician_id', '=', tech_id), + ('additional_technician_ids', 'in', [tech_id]), + ('scheduled_date', '=', task.scheduled_date), + ('status', 'not in', ['cancelled']), + ('id', '!=', task.id), + ('time_start', '<', task.time_end), + ('time_end', '>', task.time_start), + ], limit=1) + if overlapping: + start_str = self._float_to_time_str(overlapping.time_start) + end_str = self._float_to_time_str(overlapping.time_end) + raise ValidationError(_( + "%(tech)s has a time conflict with %(task)s " + "(%(start)s - %(end)s). Please choose a different time.", + tech=tech_name, + task=overlapping.name, + start=start_str, + end=end_str, + )) + + for cal_start, cal_end in self._get_calendar_busy_intervals( + tech_id, task.scheduled_date + ): + if cal_start < task.time_end and cal_end > task.time_start: + raise ValidationError(_( + "%(tech)s has a calendar event conflict " + "(%(start)s - %(end)s). Please choose a different time.", + tech=tech_name, + start=self._float_to_time_str(cal_start), + end=self._float_to_time_str(cal_end), + )) + + # Check travel time gaps for lead technician only + # (additional techs travel with the lead, same destination) + next_task = self.sudo().search([ + '|', + ('technician_id', '=', task.technician_id.id), + ('additional_technician_ids', 'in', [task.technician_id.id]), + ('scheduled_date', '=', task.scheduled_date), + ('status', 'not in', ['cancelled']), + ('id', '!=', task.id), + ('time_start', '>=', task.time_end), + ], order='time_start', limit=1) + if next_task and task.address_lat and task.address_lng and \ + next_task.address_lat and next_task.address_lng: + travel_min = self._quick_travel_time( + task.address_lat, task.address_lng, + next_task.address_lat, next_task.address_lng, + ) + if travel_min > 0: + gap_min = int((next_task.time_start - task.time_end) * 60) + if gap_min < travel_min: + raise ValidationError(_( + "Not enough travel time to the next task!\n\n" + "This task ends at %(end)s, and %(next)s starts " + "at %(next_start)s (%(gap)d min gap).\n" + "Travel time is ~%(travel)d minutes.\n\n" + "Please allow at least %(travel)d minutes between tasks.", + end=self._float_to_time_str(task.time_end), + next=next_task.name, + next_start=self._float_to_time_str(next_task.time_start), + gap=gap_min, + travel=travel_min, + )) + + prev_task = self.sudo().search([ + '|', + ('technician_id', '=', task.technician_id.id), + ('additional_technician_ids', 'in', [task.technician_id.id]), + ('scheduled_date', '=', task.scheduled_date), + ('status', 'not in', ['cancelled']), + ('id', '!=', task.id), + ('time_end', '<=', task.time_start), + ], order='time_end desc', limit=1) + if prev_task and task.address_lat and task.address_lng and \ + prev_task.address_lat and prev_task.address_lng: + travel_min = self._quick_travel_time( + prev_task.address_lat, prev_task.address_lng, + task.address_lat, task.address_lng, + ) + if travel_min > 0: + gap_min = int((task.time_start - prev_task.time_end) * 60) + if gap_min < travel_min: + raise ValidationError(_( + "Not enough travel time from the previous task!\n\n" + "%(prev)s ends at %(prev_end)s, and this task starts " + "at %(start)s (%(gap)d min gap).\n" + "Travel time is ~%(travel)d minutes.\n\n" + "Please allow at least %(travel)d minutes between tasks.", + prev=prev_task.name, + prev_end=self._float_to_time_str(prev_task.time_end), + start=self._float_to_time_str(task.time_start), + gap=gap_min, + travel=travel_min, + )) + + @api.onchange('technician_id', 'scheduled_date') + def _onchange_technician_date_autoset(self): + """Auto-set start/end time to the first available slot when tech+date change.""" + if not self.technician_id or not self.scheduled_date: + return + exclude_id = self._origin.id if self._origin else False + duration = self.duration_hours or 1.0 + s_open, _s_close = self._get_store_hours() + preferred = self.time_start or s_open + start, end = self._find_next_available_slot( + self.technician_id.id, + self.scheduled_date, + preferred_start=preferred, + duration=duration, + exclude_task_id=exclude_id, + dest_lat=self.address_lat or 0, + dest_lng=self.address_lng or 0, + ) + if start is not False: + self.time_start = start + self.time_end = end + self.duration_hours = end - start + else: + return {'warning': { + 'title': _('Fully Booked'), + 'message': _( + '%s is fully booked on %s. No available slots.' + ) % (self.technician_id.name, + self.scheduled_date.strftime('%B %d, %Y')), + }} + + def _snap_if_overlap(self): + """Check if current time_start/time_end overlaps with another task. + If so, auto-snap to the next available slot and return a warning dict.""" + if not self.technician_id or not self.scheduled_date or not self.time_start: + return None + exclude_id = self._origin.id if self._origin else 0 + duration = max(self.duration_hours or 1.0, 0.25) + + all_tech_ids = (self.technician_id | self.additional_technician_ids).ids + overlapping = self.sudo().search([ + '|', + ('technician_id', 'in', all_tech_ids), + ('additional_technician_ids', 'in', all_tech_ids), + ('scheduled_date', '=', self.scheduled_date), + ('status', 'not in', ['cancelled']), + ('id', '!=', exclude_id), + ('time_start', '<', self.time_end), + ('time_end', '>', self.time_start), + ], limit=1) + if overlapping: + conflict_name = overlapping.name + conflict_start = self._float_to_time_str(overlapping.time_start) + conflict_end = self._float_to_time_str(overlapping.time_end) + start, end = self._find_next_available_slot( + self.technician_id.id, + self.scheduled_date, + preferred_start=self.time_start, + duration=duration, + exclude_task_id=exclude_id, + dest_lat=self.address_lat or 0, + dest_lng=self.address_lng or 0, + ) + if start is not False: + new_start_str = self._float_to_time_str(start) + new_end_str = self._float_to_time_str(end) + self.time_start = start + self.time_end = end + self.duration_hours = end - start + return {'warning': { + 'title': _('Moved to Available Slot'), + 'message': _( + 'The selected time conflicts with %s (%s - %s).\n' + 'Automatically moved to: %s - %s.' + ) % (conflict_name, conflict_start, conflict_end, + new_start_str, new_end_str), + }} + else: + return {'warning': { + 'title': _('No Available Slots'), + 'message': _( + 'The selected time conflicts with %s (%s - %s) ' + 'and no other slots are available on this day.' + ) % (conflict_name, conflict_start, conflict_end), + }} + return None + + # ------------------------------------------------------------------ + # DEFAULT_GET - Calendar pre-fill + # ------------------------------------------------------------------ + + def _snap_to_quarter(self, hour_float): + """Round a float hour to the nearest 15-minute slot and clamp to store hours.""" + s_open, s_close = self._get_store_hours() + snapped = round(hour_float * 4) / 4 + return max(s_open, min(s_close, snapped)) + + @api.model + def default_get(self, fields_list): + """Handle calendar time range selection: pre-fill date + times from context.""" + res = super().default_get(fields_list) + ctx = self.env.context + + # Set duration default based on task type from context + task_type = ctx.get('default_task_type', res.get('task_type', 'delivery')) + if 'duration_hours' not in res or not res.get('duration_hours'): + res['duration_hours'] = self.TASK_TYPE_DURATIONS.get(task_type, 1.0) + + # When user clicks a time range on the calendar, Odoo passes + # default_datetime_start/end in UTC + dt_start_utc = None + dt_end_utc = None + if ctx.get('default_datetime_start'): + try: + dt_start_utc = fields.Datetime.from_string(ctx['default_datetime_start']) + except (ValueError, TypeError): + pass + if ctx.get('default_datetime_end'): + try: + dt_end_utc = fields.Datetime.from_string(ctx['default_datetime_end']) + except (ValueError, TypeError): + pass + + if dt_start_utc or dt_end_utc: + import pytz + user_tz = pytz.timezone(self.env.user.tz or 'UTC') + + if dt_start_utc: + dt_start_local = pytz.utc.localize(dt_start_utc).astimezone(user_tz) + res['scheduled_date'] = dt_start_local.date() + start_float = self._snap_to_quarter( + dt_start_local.hour + dt_start_local.minute / 60.0) + res['time_start'] = start_float + + if dt_end_utc: + dt_end_local = pytz.utc.localize(dt_end_utc).astimezone(user_tz) + end_float = self._snap_to_quarter( + dt_end_local.hour + dt_end_local.minute / 60.0) + if 'time_start' in res and end_float <= res['time_start']: + end_float = res['time_start'] + 1.0 + res['time_end'] = end_float + # Compute duration from the calendar drag + if 'time_start' in res: + res['duration_hours'] = end_float - res['time_start'] + + # Always compute end from start + duration if not already set + if 'time_end' not in res and 'time_start' in res and 'duration_hours' in res: + _open, close = self._get_store_hours() + res['time_end'] = min( + res['time_start'] + res['duration_hours'], close) + + return res + + # ------------------------------------------------------------------ + # CRUD OVERRIDES + # ------------------------------------------------------------------ + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if vals.get('name', _('New')) == _('New'): + vals['name'] = self.env['ir.sequence'].next_by_code('fusion.technician.task') or _('New') + if not vals.get('x_fc_sync_uuid') and not vals.get('x_fc_sync_source'): + vals['x_fc_sync_uuid'] = str(uuid.uuid4()) + # In-store tasks: auto-fill company address + if vals.get('is_in_store') and not vals.get('address_street'): + company_partner = self.env.company.partner_id + if company_partner and company_partner.street: + self._fill_address_vals(vals, company_partner) + else: + vals['address_street'] = self.env.company.name or 'In Store' + # Hook: fill address from linked records (overridden by fusion_claims) + self._create_vals_fill(vals) + records = super().create(vals_list) + # Hook: post-create actions for linked records + records._on_create_post_actions() + # Auto-calculate travel times for the full day chain + if not self.env.context.get('skip_travel_recalc'): + records._recalculate_day_travel_chains() + # Send "Appointment Scheduled" email + for rec in records: + rec._send_task_scheduled_email() + # Push new local tasks to remote instances + 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 _create_vals_fill(self, vals): + """Hook: fill address from linked records during create. + + Base implementation fills from partner_id. Override in fusion_claims + to also fill from sale_order_id or purchase_order_id. + """ + if vals.get('partner_id') and not vals.get('address_street'): + partner = self.env['res.partner'].browse(vals['partner_id']) + if partner.street: + self._fill_address_vals(vals, partner) + + def _on_create_post_actions(self): + """Hook: post-create side-effects for linked records. + + Override in fusion_claims to post chatter messages to linked orders, + mark sale orders as ready for delivery, etc. + """ + pass + + def write(self, vals): + if self.env.context.get('skip_travel_recalc'): + 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) + if ('time_start' in vals or 'duration_hours' in vals) and 'time_end' not in vals: + _open, close = self._get_store_hours() + start = vals.get('time_start', self[:1].time_start if len(self) == 1 else 9.0) + dur = vals.get('duration_hours', self[:1].duration_hours if len(self) == 1 else 1.0) or 1.0 + vals['time_end'] = min(start + dur, close) + + # Detect reschedule mode: capture old values BEFORE write + reschedule_mode = self.env.context.get('reschedule_mode') + old_schedule = {} + schedule_fields = {'scheduled_date', 'time_start', 'time_end', + 'duration_hours', 'technician_id'} + schedule_changed = schedule_fields & set(vals.keys()) + if reschedule_mode and schedule_changed: + for task in self: + old_schedule[task.id] = { + 'date': task.scheduled_date, + 'time_start': task.time_start, + 'time_end': task.time_end, + } + + # Capture old tech+date combos BEFORE write for travel recalc + travel_fields = {'address_street', 'address_city', 'address_zip', 'address_lat', 'address_lng', + 'scheduled_date', 'sequence', 'time_start', 'technician_id', + 'additional_technician_ids'} + needs_travel_recalc = travel_fields & set(vals.keys()) + old_combos = set() + if needs_travel_recalc: + for t in self: + old_combos.add((t.technician_id.id, t.scheduled_date)) + for tech in t.additional_technician_ids: + old_combos.add((tech.id, t.scheduled_date)) + res = super().write(vals) + if needs_travel_recalc: + new_combos = set() + for t in self: + new_combos.add((t.technician_id.id, t.scheduled_date)) + for tech in t.additional_technician_ids: + new_combos.add((tech.id, t.scheduled_date)) + all_combos = old_combos | new_combos + self._recalculate_combos_travel(all_combos) + + # After write: send reschedule email if schedule actually changed + if reschedule_mode and old_schedule: + for task in self: + old = old_schedule.get(task.id, {}) + if old and ( + old['date'] != task.scheduled_date + or abs(old['time_start'] - task.time_start) > 0.01 + or abs(old['time_end'] - task.time_end) > 0.01 + ): + task._post_status_message('rescheduled') + task._send_task_rescheduled_email( + old_date=old['date'], + old_start=old['time_start'], + old_end=old['time_end'], + ) + # Push updates to remote instances for local tasks + sync_fields = {'technician_id', 'additional_technician_ids', + 'scheduled_date', 'time_start', 'time_end', + 'duration_hours', 'status', 'task_type', 'address_street', + 'address_city', 'address_zip', 'address_lat', 'address_lng', + 'partner_id'} + if sync_fields & set(vals.keys()) and not self.env.context.get('skip_task_sync'): + 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. + Falls back gracefully if external calendar validation fails (e.g. + Microsoft Calendar requires the organizer to have Outlook synced). + """ + silent_ctx = { + 'dont_notify': True, + 'mail_create_nosubscribe': True, + 'mail_create_nolog': True, + 'no_mail_notification': True, + 'no_mail_to_attendees': True, + 'skip_attendee_notification': True, + } + CalendarEvent = self.env['calendar.event'].sudo().with_context(**silent_ctx) + 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.with_context(**silent_ctx).unlink() + task.with_context(skip_travel_recalc=True).write({'calendar_event_id': False}) + continue + + order = task._get_linked_order() + partner = task.partner_id or (order.partner_id if order else False) + 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 order: + description_parts.append(f"Ref: {order.name}") + if task.description: + description_parts.append(task.description) + + 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 | task.additional_technician_ids).mapped('partner_id').ids)], + 'show_as': 'busy', + 'description': '\n'.join(description_parts), + } + + try: + if task.calendar_event_id: + task.calendar_event_id.with_context(**silent_ctx).write(vals) + else: + event = CalendarEvent.create(vals) + task.with_context(skip_travel_recalc=True).write({'calendar_event_id': event.id}) + except Exception as e: + _logger.warning( + "Calendar sync skipped for task %s (tech=%s): %s", + task.name, task.technician_id.name, e, + ) + if not task.calendar_event_id: + try: + vals['user_id'] = self.env.uid + event = CalendarEvent.create(vals) + task.with_context(skip_travel_recalc=True).write({'calendar_event_id': event.id}) + except Exception: + pass + + @api.model + def _fill_address_vals(self, vals, partner): + """Helper to fill address vals dict from a partner record.""" + vals.update({ + 'address_partner_id': partner.id, + 'address_street': partner.street or '', + 'address_street2': partner.street2 or '', + 'address_city': partner.city or '', + 'address_state_id': partner.state_id.id if partner.state_id else False, + 'address_zip': partner.zip or '', + 'address_lat': partner.x_fc_latitude if hasattr(partner, 'x_fc_latitude') else 0, + 'address_lng': partner.x_fc_longitude if hasattr(partner, 'x_fc_longitude') else 0, + }) + + def _post_task_created_to_linked_order(self): + """Hook: post task creation notice to linked order chatter. + Override in fusion_claims.""" + pass + + def _mark_sale_order_ready_for_delivery(self): + """Hook: mark linked sale orders as ready for delivery. + Override in fusion_claims.""" + pass + + def _recalculate_day_travel_chains(self): + """Recalculate travel for all tech+date combos affected by these tasks. + + Includes combos for additional technicians so their schedules update too. + """ + combos = set() + for t in self: + if not t.scheduled_date: + continue + if t.technician_id: + combos.add((t.technician_id.id, t.scheduled_date)) + for tech in t.additional_technician_ids: + combos.add((tech.id, t.scheduled_date)) + self._recalculate_combos_travel(combos) + + def _get_technician_start_address(self, tech_id): + """Get the start address for a technician. + + Priority: + 1. Technician's personal x_fc_start_address (if set) + 2. Company default HQ address (fusion_claims.technician_start_address) + Returns the address string or ''. + """ + tech_user = self.env['res.users'].sudo().browse(tech_id) + if tech_user.exists() and tech_user.x_fc_start_address: + return tech_user.x_fc_start_address.strip() + # Fallback to company default + return (self.env['ir.config_parameter'].sudo() + .get_param('fusion_claims.technician_start_address', '') or '').strip() + + def _geocode_address_string(self, address, api_key): + """Geocode an address string and return (lat, lng) or (0.0, 0.0).""" + if not address or not api_key: + return 0.0, 0.0 + try: + url = 'https://maps.googleapis.com/maps/api/geocode/json' + params = {'address': address, 'key': api_key, 'region': 'ca'} + resp = requests.get(url, params=params, timeout=10) + data = resp.json() + if data.get('status') == 'OK' and data.get('results'): + loc = data['results'][0]['geometry']['location'] + return loc['lat'], loc['lng'] + except Exception as e: + _logger.warning("Address geocoding failed for '%s': %s", address, e) + return 0.0, 0.0 + + def _recalculate_combos_travel(self, combos): + """Recalculate travel for a set of (tech_id, date) combinations. + + Start-point priority per technician (for today only): + 1. Latest GPS location (from periodic tracking or task actions) + 2. Actual GPS from today's fusion_clock check-in + 3. Personal start address (x_fc_start_address) + 4. Company default HQ address + For future dates, only 3 and 4 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() + + start_coords_cache = {} + today = self._local_now().date() + 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: + # Try latest GPS first (most accurate real-time position) + lat, lng = self._get_tech_current_location(tech_id) + if lat and lng: + start_coords_cache[cache_key] = (lat, lng) + elif 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) + 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), + ('additional_technician_ids', 'in', [tech_id]), + ('scheduled_date', '=', date), + ('status', 'not in', ['cancelled']), + ], order='time_start, sequence, id') + if not all_day_tasks: + continue + + 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() + travel_vals = {} + 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'] = '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 + prev_lng = task.address_lng or prev_lng + + # ------------------------------------------------------------------ + # LIVE TRAVEL RECALCULATION (uses tech's current GPS position) + # ------------------------------------------------------------------ + + def _get_tech_current_location(self, tech_id): + """Get the technician's most recent GPS location. + + Priority: + 1. Latest fusion.technician.location record from last 30 min + 2. Latest action_latitude/longitude from today's tasks + 3. Clock-in location + 4. None (caller falls back to start address) + """ + Location = self.env['fusion.technician.location'].sudo() + cutoff = fields.Datetime.subtract(fields.Datetime.now(), minutes=30) + latest = Location.search([ + ('user_id', '=', tech_id), + ('logged_at', '>', cutoff), + ('source', '!=', 'sync'), + ], order='logged_at desc', limit=1) + if latest and latest.latitude and latest.longitude: + return latest.latitude, latest.longitude + + # Fallback: last completed task's location today + today = self._local_now().date() + last_completed = self.sudo().search([ + ('technician_id', '=', tech_id), + ('scheduled_date', '=', today), + ('status', '=', 'completed'), + ('completed_latitude', '!=', 0), + ('completed_longitude', '!=', 0), + ], order='completion_datetime desc', limit=1) + if last_completed: + return last_completed.completed_latitude, last_completed.completed_longitude + + # Fallback: clock-in location + clock_locs = self._get_clock_in_locations({tech_id}, today) + if tech_id in clock_locs: + cl = clock_locs[tech_id] + return cl['lat'], cl['lng'] + + return None, None + + def _recalculate_travel_from_current_location(self): + """Recalculate travel time for THIS task from the tech's current GPS. + + Called when tech starts en_route to get a live ETA. + """ + self.ensure_one() + ICP = self.env['ir.config_parameter'].sudo() + if not ICP.get_param('fusion_claims.google_distance_matrix_enabled', False): + return + tech_id = self.technician_id.id + if not tech_id: + return + lat, lng = self._get_tech_current_location(tech_id) + if lat and lng and self.address_lat and self.address_lng: + self.with_context(skip_travel_recalc=True)._calculate_travel_time(lat, lng) + self.with_context(skip_travel_recalc=True).write({ + 'travel_origin': 'Current Location (Live)', + }) + + def _recalculate_remaining_tasks_travel(self): + """After completing a task, recalculate travel for all remaining tasks + in the chain using the completion location as the new origin. + + This ensures ETAs update in real-time as the tech progresses through + their schedule, and the route reflects their actual position. + """ + self.ensure_one() + ICP = self.env['ir.config_parameter'].sudo() + if not ICP.get_param('fusion_claims.google_distance_matrix_enabled', False): + return + + tech_id = self.technician_id.id + if not tech_id or not self.scheduled_date: + return + + # Use completion GPS as origin for next task + origin_lat = self.completed_latitude or self.action_latitude + origin_lng = self.completed_longitude or self.action_longitude + + # If no GPS from completion, try task address (tech was physically there) + if not origin_lat or not origin_lng: + origin_lat = self.address_lat + origin_lng = self.address_lng + + if not origin_lat or not origin_lng: + return + + remaining = self.sudo().search([ + '|', + ('technician_id', '=', tech_id), + ('additional_technician_ids', 'in', [tech_id]), + ('scheduled_date', '=', self.scheduled_date), + ('status', 'not in', ['completed', 'cancelled']), + ('time_start', '>=', self.time_start), + ], order='time_start, sequence, id') + + if not remaining: + return + + prev_lat, prev_lng = origin_lat, origin_lng + for i, task in enumerate(remaining): + if not (task.address_lat and task.address_lng): + task._geocode_address() + 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) + origin_label = (f'Completed: {self.name}' if i == 0 + else f'Task {remaining[i - 1].name}') + task.with_context(skip_travel_recalc=True).write({ + 'previous_task_id': self.id if i == 0 else remaining[i - 1].id, + 'travel_origin': origin_label, + }) + prev_lat = task.address_lat or prev_lat + prev_lng = task.address_lng or prev_lng + + # ------------------------------------------------------------------ + # STATUS ACTIONS + # ------------------------------------------------------------------ + + def _check_previous_tasks_completed(self): + """Check that all earlier tasks for the same technician+date are completed. + + Considers tasks where the technician is either lead or additional. + """ + self.ensure_one() + earlier_incomplete = self.sudo().search([ + '|', + ('technician_id', '=', self.technician_id.id), + ('additional_technician_ids', 'in', [self.technician_id.id]), + ('scheduled_date', '=', self.scheduled_date), + ('time_start', '<', self.time_start), + ('status', 'not in', ['completed', 'cancelled']), + ('id', '!=', self.id), + ], limit=1) + if earlier_incomplete: + raise UserError(_( + "Please complete previous task %s first before starting this one." + ) % earlier_incomplete.name) + + def _write_action_location(self, extra_vals=None): + """Write GPS coordinates from context onto the task record.""" + ctx = self.env.context + lat = ctx.get('action_latitude', 0) + lng = ctx.get('action_longitude', 0) + acc = ctx.get('action_accuracy', 0) + vals = { + 'action_latitude': lat, + 'action_longitude': lng, + 'action_location_accuracy': acc, + } + if extra_vals: + vals.update(extra_vals) + if lat and lng: + self.with_context(skip_travel_recalc=True).write(vals) + + def action_start_en_route(self): + """Mark task as En Route.""" + for task in self: + if task.status != 'scheduled': + raise UserError(_("Only scheduled tasks can be marked as En Route.")) + task._check_previous_tasks_completed() + task.status = 'en_route' + task._write_action_location() + task._post_status_message('en_route') + task._send_task_en_route_email() + # Recalculate travel from tech's current location to THIS task + task._recalculate_travel_from_current_location() + if task.x_fc_sync_source: + try: + self.env['fusion.task.sync.config']._push_shadow_status(task) + except Exception: + _logger.exception( + "Failed to push en_route for shadow %s", task.name) + try: + remaining = self.sudo().search_count([ + ('technician_id', '=', task.technician_id.id), + ('scheduled_date', '=', task.scheduled_date), + ('status', 'in', ['scheduled', 'en_route']), + ('id', '!=', task.id), + ]) + client = task.client_display_name or 'your next client' + ttype = dict(self._fields['task_type'].selection).get( + task.task_type, task.task_type or 'Task') + task._send_push_notification( + f'En Route to {client}', + f'{ttype} at {task.address_display or "scheduled location"}. ' + f'{remaining} more task(s) today.', + ) + except Exception: + pass + + def action_start_task(self): + """Mark task as In Progress.""" + for task in self: + if task.status not in ('scheduled', 'en_route'): + raise UserError(_("Task must be scheduled or en route to start.")) + task._check_previous_tasks_completed() + task.status = 'in_progress' + ctx = self.env.context + task._write_action_location({ + 'started_latitude': ctx.get('action_latitude', 0), + 'started_longitude': ctx.get('action_longitude', 0), + }) + task._post_status_message('in_progress') + + def action_complete_task(self): + """Mark task as Completed.""" + for task in self: + if task.status not in ('in_progress', 'en_route', 'scheduled'): + raise UserError(_("Task must be in progress to complete.")) + + task._check_completion_requirements() + + ctx = self.env.context + task.with_context(skip_travel_recalc=True).write({ + 'status': 'completed', + 'completion_datetime': fields.Datetime.now(), + 'completed_latitude': ctx.get('action_latitude', 0), + 'completed_longitude': ctx.get('action_longitude', 0), + 'action_latitude': ctx.get('action_latitude', 0), + 'action_longitude': ctx.get('action_longitude', 0), + 'action_location_accuracy': ctx.get('action_accuracy', 0), + }) + task._post_status_message('completed') + task._post_completion_to_linked_order() + task._notify_scheduler_on_completion() + task._send_task_completion_email() + + # Recalculate travel for remaining tasks from this completion location + task._recalculate_remaining_tasks_travel() + + task._on_complete_extra() + + def _check_completion_requirements(self): + """Hook: check additional requirements before task completion. + Override in fusion_claims for rental inspection checks.""" + pass + + def _on_complete_extra(self): + """Hook: additional side-effects after task completion. + Override in fusion_claims for ODSP advancement and rental inspection.""" + pass + + def action_cancel_task(self): + """Cancel the task. Sends cancellation email and runs cancel hooks.""" + for task in self: + if task.status == 'completed': + raise UserError(_("Cannot cancel a completed task.")) + task.status = 'cancelled' + task._write_action_location() + task._post_status_message('cancelled') + if task.x_fc_sync_source: + try: + self.env['fusion.task.sync.config']._push_shadow_status(task) + except Exception: + _logger.exception( + "Failed to push cancel for shadow %s", task.name) + else: + task._on_cancel_extra() + + def _on_cancel_extra(self): + """Hook: additional side-effects after task cancellation. + Override in fusion_claims for sale order revert and email.""" + self._send_task_cancelled_email() + + def action_reschedule(self): + """Open the reschedule form for this task. + Saves old schedule info, then opens the same task form for editing. + On save, the write() method detects the reschedule and sends emails.""" + self.ensure_one() + return { + 'type': 'ir.actions.act_window', + 'res_model': 'fusion.technician.task', + 'res_id': self.id, + 'view_mode': 'form', + 'target': 'new', + 'context': { + 'reschedule_mode': True, + 'old_date': str(self.scheduled_date) if self.scheduled_date else '', + 'old_time_start': self.time_start, + 'old_time_end': self.time_end, + }, + } + + def action_reset_to_scheduled(self): + """Reset task back to scheduled.""" + for task in self: + task.status = 'scheduled' + + # ------------------------------------------------------------------ + # CHATTER / NOTIFICATIONS + # ------------------------------------------------------------------ + + def _post_status_message(self, new_status): + """Post a status change message to the task chatter.""" + self.ensure_one() + status_labels = dict(self._fields['status'].selection) + label = status_labels.get(new_status, new_status) + icons = { + 'en_route': 'fa-road', + 'in_progress': 'fa-wrench', + 'completed': 'fa-check-circle', + 'cancelled': 'fa-times-circle', + 'rescheduled': 'fa-calendar', + } + icon = icons.get(new_status, 'fa-info-circle') + body = Markup( + f'

Task status changed to ' + f'{label} by {self.env.user.name}

' + ) + self.message_post(body=body, message_type='notification', subtype_xmlid='mail.mt_note') + + def _post_completion_to_linked_order(self): + """Hook: post completion notes to linked order chatter. + Override in fusion_claims.""" + pass + + def _notify_scheduler_on_completion(self): + """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() + if self.x_fc_sync_source: + return + + recipient = None + order = self._get_linked_order() + if order and order.user_id: + recipient = order.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._get_linked_order() + 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 + # ------------------------------------------------------------------ + + def _get_task_email_details(self): + """Build common detail rows for task emails.""" + self.ensure_one() + type_label = dict(self._fields['task_type'].selection).get( + self.task_type, self.task_type or '') + rows = [ + ('Task', f'{self.name} ({type_label})'), + ('Client', self.partner_id.name or 'N/A'), + ] + if self.scheduled_date: + date_str = self.scheduled_date.strftime('%B %d, %Y') + start_str = self._float_to_time_str(self.time_start) + end_str = self._float_to_time_str(self.time_end) + rows.append(('Scheduled', f'{date_str}, {start_str} - {end_str}')) + if self.technician_id: + rows.append(('Technician', self.all_technician_names or self.technician_id.name)) + if self.address_display: + rows.append(('Address', self.address_display)) + return rows + + def _get_task_email_recipients(self): + """Get email recipients for task notifications. + Returns dict with 'to' (client), 'cc' (technician, sales rep, office).""" + self.ensure_one() + to_emails = [] + cc_emails = [] + + # Client email + if self.partner_id and self.partner_id.email: + to_emails.append(self.partner_id.email) + + # Technician emails (lead + additional) + for tech in (self.technician_id | self.additional_technician_ids): + if tech.email: + cc_emails.append(tech.email) + + return {'to': to_emails, 'cc': list(set(cc_emails))} + + def _send_task_cancelled_email(self): + """Send cancellation email. Base: no-op. Override in fusion_claims.""" + return False + + def _send_task_scheduled_email(self): + """Send scheduled email. Base: no-op. Override in fusion_claims.""" + return False + + def _send_task_rescheduled_email(self, old_date=None, old_start=None, old_end=None): + """Send rescheduled email. Base: no-op. Override in fusion_claims.""" + return False + + # ------------------------------------------------------------------ + # CLIENT UPDATE EMAILS (en-route + completion) + # ------------------------------------------------------------------ + + def _get_email_builder(self): + """Return a record that has the _email_build mixin. + + Base: returns self (task model inherits mixin). + Override in fusion_claims to prefer linked sale order. + """ + return self + + def _is_email_notifications_enabled(self): + """Check if email notifications are enabled. + + Base: always True. Override in fusion_claims to check + linked sale order's notification settings. + """ + return True + + def _get_linked_order(self): + """Return the linked order record (SO or PO), or False. + + Base: always False. Override in fusion_claims to return + sale_order_id or purchase_order_id. + """ + return False + + def _send_task_en_route_email(self): + """Email the client that the technician is on the way.""" + self.ensure_one() + if self.x_fc_sync_source or not self.x_fc_send_client_updates: + return False + if not self.partner_id or not self.partner_id.email: + return False + if not self._is_email_notifications_enabled(): + return False + + client_name = self.client_display_name or self.partner_id.name or 'Client' + tech_name = self.all_technician_names or (self.technician_id.name if self.technician_id else 'Our technician') + type_label = dict(self._fields['task_type'].selection).get( + self.task_type, self.task_type or 'service') + company = self.env.company + + detail_rows = self._get_task_email_details() + builder = self._get_email_builder() + + time_range = '' + if self.scheduled_date and self.time_start is not None: + time_range = ( + f'{self.scheduled_date.strftime("%B %d, %Y")}, ' + f'{self._float_to_time_str(self.time_start)} - ' + f'{self._float_to_time_str(self.time_end or self.time_start + 1.0)}' + ) + + preparation_note = ( + 'Please prepare for the visit:
' + '
    ' + '
  • Ensure the area where service is needed is accessible and clear.
  • ' + '
  • If applicable, secure pets away from the work area.
  • ' + '
  • Have any relevant documents or information ready for the technician.
  • ' + '
  • An adult (18+) must be present during the visit.
  • ' + '
' + 'Important: Our technicians will not ask for credit card ' + 'details or request payment directly unless payment on arrival has been ' + 'previously agreed upon. If a cash payment was arranged, please have ' + 'the payment ready in an envelope.' + ) + + body_html = builder._email_build( + title='Your Technician Is On The Way', + summary=( + f'Our technician {tech_name} is heading to your ' + f'location and is expected to arrive between ' + f'{time_range or "the scheduled time"}.' + ), + email_type='info', + sections=[('Visit Details', detail_rows)], + note=preparation_note, + note_color='#2B6CB0', + sender_name=f'The {company.name} Team', + ) + + recipients = self._get_task_email_recipients() + to_emails = recipients.get('to', []) + cc_emails = recipients.get('cc', []) + if not to_emails: + return False + + email_to = ', '.join(to_emails) + email_cc = ', '.join(cc_emails) + order = self._get_linked_order() + case_ref = order.name if order else self.name + + try: + mail_vals = { + 'subject': f'Your Technician Is On The Way - {client_name} - {case_ref}', + 'body_html': body_html, + 'email_to': email_to, + 'email_cc': email_cc, + } + if order: + mail_vals['model'] = order._name + mail_vals['res_id'] = order.id + self.env['mail.mail'].sudo().create(mail_vals).send() + _logger.info("Sent en-route email for task %s to %s", self.name, email_to) + return True + except Exception as e: + _logger.error("Failed to send en-route email for %s: %s", self.name, e) + return False + + def _send_task_completion_email(self): + """Email the client that the visit is complete. + + Sends one of two variants depending on x_fc_ask_google_review: + - With Google review request (default) + - Standard thank-you without review request + """ + self.ensure_one() + if self.x_fc_sync_source or not self.x_fc_send_client_updates: + return False + if not self.partner_id or not self.partner_id.email: + return False + if not self._is_email_notifications_enabled(): + return False + + client_name = self.client_display_name or self.partner_id.name or 'Client' + tech_name = self.all_technician_names or (self.technician_id.name if self.technician_id else 'Our technician') + type_label = dict(self._fields['task_type'].selection).get( + self.task_type, self.task_type or 'service') + company = self.env.company + builder = self._get_email_builder() + + summary_rows = [ + ('Client', client_name), + ('Service', type_label.title()), + ] + if self.scheduled_date: + summary_rows.append(('Date', self.scheduled_date.strftime('%B %d, %Y'))) + if self.technician_id: + summary_rows.append(('Technician', tech_name)) + + contact_note = ( + 'If you have any questions or concerns about the service provided, ' + f'please don\'t hesitate to contact us at ' + f'{company.phone or company.email or "our office"}.' + ) + + google_url = company.x_fc_google_review_url or '' + include_review = self.x_fc_ask_google_review and google_url + + extra_html = '' + button_url = '' + button_text = '' + + if include_review: + extra_html = builder._email_note( + 'We Value Your Feedback

' + 'We hope you had a great experience! Your feedback helps us ' + 'improve and serve you better. We would truly appreciate it ' + 'if you could take a moment to share your experience.', + '#38a169', + ) + button_url = google_url + button_text = 'Leave a Review' + else: + company_website = company.website or '' + if company_website: + button_url = company_website + button_text = 'Visit Our Website' + + body_html = builder._email_build( + title='Service Visit Completed', + summary=( + f'Our technician {tech_name} has completed the ' + f'{type_label.lower()} at your location. ' + f'Thank you for choosing {company.name}.' + ), + email_type='success', + sections=[('Visit Summary', summary_rows)], + extra_html=extra_html, + note=contact_note, + note_color='#38a169', + button_url=button_url, + button_text=button_text, + sender_name=f'The {company.name} Team', + ) + + recipients = self._get_task_email_recipients() + to_emails = recipients.get('to', []) + cc_emails = recipients.get('cc', []) + if not to_emails: + return False + + email_to = ', '.join(to_emails) + email_cc = ', '.join(cc_emails) + order = self._get_linked_order() + case_ref = order.name if order else self.name + + try: + mail_vals = { + 'subject': f'Service Visit Completed - {client_name} - {case_ref}', + 'body_html': body_html, + 'email_to': email_to, + 'email_cc': email_cc, + } + if order: + mail_vals['model'] = order._name + mail_vals['res_id'] = order.id + self.env['mail.mail'].sudo().create(mail_vals).send() + _logger.info("Sent completion email for task %s to %s", self.name, email_to) + return True + except Exception as e: + _logger.error("Failed to send completion email for %s: %s", self.name, e) + return False + + def get_next_task_for_technician(self): + """Get the next task in sequence for the same technician+date after this one. + + Considers tasks where the technician is either lead or additional. + """ + self.ensure_one() + return self.sudo().search([ + '|', + ('technician_id', '=', self.technician_id.id), + ('additional_technician_ids', 'in', [self.technician_id.id]), + ('scheduled_date', '=', self.scheduled_date), + ('time_start', '>=', self.time_start), + ('status', 'in', ['scheduled', 'en_route']), + ('id', '!=', self.id), + ], order='time_start, sequence, id', limit=1) + + # ------------------------------------------------------------------ + # GOOGLE MAPS INTEGRATION + # ------------------------------------------------------------------ + + def _get_google_maps_api_key(self): + """Get the Google Maps API key from config.""" + return self.env['ir.config_parameter'].sudo().get_param( + 'fusion_claims.google_maps_api_key', '' + ) + + @api.model + def get_map_data(self, domain=None): + """Return task data, technician locations, and Google Maps API key. + + Args: + domain: optional extra domain from the search bar filters. + """ + api_key = self.env['ir.config_parameter'].sudo().get_param( + 'fusion_claims.google_maps_api_key', '') + local_instance = self.env['ir.config_parameter'].sudo().get_param( + 'fusion_claims.sync_instance_id', '') + base_domain = [ + ('status', 'not in', ['cancelled']), + ] + if domain: + base_domain = expression.AND([base_domain, domain]) + tasks = self.sudo().search_read( + base_domain, + ['name', 'partner_id', 'technician_id', 'task_type', + 'address_lat', 'address_lng', 'address_display', + 'time_start', 'time_end', 'time_start_display', 'time_end_display', + 'status', 'scheduled_date', 'travel_time_minutes', + 'x_fc_sync_client_name', 'x_fc_is_shadow', 'x_fc_sync_source'], + order='scheduled_date asc NULLS LAST, time_start asc', + limit=500, + ) + locations = self.env['fusion.technician.location'].get_latest_locations() + tech_starts = self._get_tech_start_locations(tasks, api_key) + return { + 'api_key': api_key, + 'tasks': tasks, + 'locations': locations, + 'local_instance_id': local_instance, + 'tech_start_locations': tech_starts, + } + + @api.model + def _get_tech_start_locations(self, tasks, api_key): + """Build a dict of technician start locations for route origins. + + Priority per technician: + 1. Today's fusion_clock check-in location (if module installed) + 2. Personal start address (x_fc_start_address with cached lat/lng) + 3. Company default HQ address + """ + tech_ids = { + t['technician_id'][0] + for t in tasks + if t.get('technician_id') + } + if not tech_ids: + return {} + + result = {} + today = self._local_now().date() + + clock_locations = self._get_clock_in_locations(tech_ids, today) + + hq_address = ( + self.env['ir.config_parameter'].sudo() + .get_param('fusion_claims.technician_start_address', '') or '' + ).strip() + hq_lat, hq_lng = 0.0, 0.0 + + for uid in tech_ids: + if uid in clock_locations: + result[uid] = clock_locations[uid] + continue + + user = self.env['res.users'].sudo().browse(uid) + if not user.exists(): + continue + partner = user.partner_id + + if partner.x_fc_start_address and partner.x_fc_start_address.strip(): + lat = partner.x_fc_start_address_lat + lng = partner.x_fc_start_address_lng + if not lat or not lng: + lat, lng = self._geocode_address_string( + partner.x_fc_start_address, api_key) + if lat and lng: + partner.sudo().write({ + 'x_fc_start_address_lat': lat, + 'x_fc_start_address_lng': lng, + }) + if lat and lng: + result[uid] = { + 'lat': lat, 'lng': lng, + 'address': partner.x_fc_start_address.strip(), + 'source': 'start_address', + } + continue + + if hq_address: + if not hq_lat and not hq_lng: + hq_lat, hq_lng = self._geocode_address_string( + hq_address, api_key) + if hq_lat and hq_lng: + result[uid] = { + 'lat': hq_lat, 'lng': hq_lng, + 'address': hq_address, + 'source': 'company_hq', + } + + return result + + @api.model + def _get_clock_in_locations(self, tech_ids, today): + """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([ + ('name', '=', 'fusion_clock'), + ('state', '=', 'installed'), + ], limit=1) + if not module: + return result + except Exception: + return result + + 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 + + employees = Employee.search([ + ('user_id', 'in', list(tech_ids)), + ]) + emp_to_user = {e.id: e.user_id.id for e in employees} + + if not employees: + return result + + today_start = dt_datetime.combine(today, dt_datetime.min.time()) + today_end = today_start + timedelta(days=1) + + attendances = Attendance.search([ + ('employee_id', 'in', employees.ids), + ('check_in', '>=', today_start), + ('check_in', '<', today_end), + ], order='check_in asc') + + for att in attendances: + uid = emp_to_user.get(att.employee_id.id) + if not uid or uid in result: + continue + + 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': lat, + 'lng': lng, + 'address': address, + 'source': 'clock_in', + } + + return result + + def _geocode_address(self): + """Geocode the task address using Google Geocoding API.""" + self.ensure_one() + api_key = self._get_google_maps_api_key() + if not api_key or not self.address_display: + return False + + try: + url = 'https://maps.googleapis.com/maps/api/geocode/json' + params = { + 'address': self.address_display, + 'key': api_key, + 'region': 'ca', + } + resp = requests.get(url, params=params, timeout=10) + data = resp.json() + if data.get('status') == 'OK' and data.get('results'): + location = data['results'][0]['geometry']['location'] + self.write({ + 'address_lat': location['lat'], + 'address_lng': location['lng'], + }) + return True + except Exception as e: + _logger.warning(f"Geocoding failed for task {self.name}: {e}") + return False + + def _calculate_travel_time(self, origin_lat, origin_lng): + """Calculate travel time from origin to this task using Distance Matrix API.""" + self.ensure_one() + api_key = self._get_google_maps_api_key() + if not api_key: + return False + if not (origin_lat and origin_lng and self.address_lat and self.address_lng): + return False + + try: + url = 'https://maps.googleapis.com/maps/api/distancematrix/json' + params = { + 'origins': f'{origin_lat},{origin_lng}', + 'destinations': f'{self.address_lat},{self.address_lng}', + 'key': api_key, + 'mode': 'driving', + 'avoid': 'tolls', + 'traffic_model': 'best_guess', + 'departure_time': 'now', + } + resp = requests.get(url, params=params, timeout=10) + data = resp.json() + if data.get('status') == 'OK': + element = data['rows'][0]['elements'][0] + if element.get('status') == 'OK': + duration_seconds = element['duration_in_traffic']['value'] if 'duration_in_traffic' in element else element['duration']['value'] + distance_meters = element['distance']['value'] + self.write({ + 'travel_time_minutes': round(duration_seconds / 60), + 'travel_distance_km': round(distance_meters / 1000, 1), + }) + return True + except Exception as e: + _logger.warning(f"Travel time calculation failed for task {self.name}: {e}") + return False + + def action_calculate_travel_times(self): + """Calculate travel times for a day's schedule. Called from backend button or cron.""" + self._do_calculate_travel_times() + # Return False to stay on the current form without navigation + return False + + def _do_calculate_travel_times(self): + """Internal: calculate travel times for tasks. Does not return an action. + + For today's tasks: uses the tech's current GPS location as origin + for the first non-completed task, so ETAs reflect reality. + For future tasks: uses personal start address or company HQ. + """ + # Group tasks by technician and date + task_groups = {} + for task in self: + key = (task.technician_id.id, task.scheduled_date) + if key not in task_groups: + task_groups[key] = self.env['fusion.technician.task'] + task_groups[key] |= task + + api_key = self._get_google_maps_api_key() + today = self._local_now().date() + + for (tech_id, date), tasks in task_groups.items(): + sorted_tasks = tasks.sorted(lambda t: (t.sequence, t.time_start)) + + # For today: try current GPS, then clock-in, then start address + # For future: use start address + if date == today: + lat, lng = self._get_tech_current_location(tech_id) + if lat and lng: + prev_lat, prev_lng = lat, lng + origin_label = 'Current Location' + else: + clock_locs = self._get_clock_in_locations({tech_id}, today) + if tech_id in clock_locs: + cl = clock_locs[tech_id] + prev_lat, prev_lng = cl['lat'], cl['lng'] + origin_label = 'Clock-In Location' + else: + addr = self._get_technician_start_address(tech_id) + prev_lat, prev_lng = self._geocode_address_string(addr, api_key) + origin_label = 'Start Location' + else: + addr = self._get_technician_start_address(tech_id) + prev_lat, prev_lng = self._geocode_address_string(addr, api_key) + origin_label = 'Start Location' + + # Skip already-completed tasks for today (chain starts from + # last completed task's location instead) + first_pending_idx = 0 + if date == today: + for idx, task in enumerate(sorted_tasks): + if task.status == 'completed': + if task.completed_latitude and task.completed_longitude: + prev_lat = task.completed_latitude + prev_lng = task.completed_longitude + elif task.address_lat and task.address_lng: + prev_lat = task.address_lat + prev_lng = task.address_lng + origin_label = f'Completed: {task.name}' + first_pending_idx = idx + 1 + else: + break + + for i, task in enumerate(sorted_tasks): + if i < first_pending_idx: + continue + # Geocode task if needed + if not (task.address_lat and task.address_lng): + task._geocode_address() + + if prev_lat and prev_lng and task.address_lat and task.address_lng: + task._calculate_travel_time(prev_lat, prev_lng) + task.previous_task_id = sorted_tasks[i - 1].id if i > 0 else False + task.travel_origin = origin_label if i == first_pending_idx else f'Task {sorted_tasks[i - 1].name}' + + prev_lat = task.address_lat or prev_lat + prev_lng = task.address_lng or prev_lng + + @api.model + def _cron_calculate_travel_times(self): + """Cron job: Calculate travel times for today and tomorrow. + + Runs every 15 minutes. For today's tasks, uses the tech's latest + GPS location so ETAs stay accurate as technicians move. + Includes completed tasks in the search so the chain can skip + them and use their completion location as origin. + """ + today = fields.Date.context_today(self) + tomorrow = today + timedelta(days=1) + tasks = self.search([ + ('scheduled_date', 'in', [today, tomorrow]), + ('status', 'not in', ['cancelled']), + ]) + if tasks: + tasks._do_calculate_travel_times() + _logger.info(f"Calculated travel times for {len(tasks)} tasks") + + @api.model + def _cron_check_late_arrivals(self): + """Cron: detect tasks where the technician hasn't started and the + scheduled start time has passed. Sends a push notification to the + tech and an in-app notification to the office (once per task). + """ + ICP = self.env['ir.config_parameter'].sudo() + push_enabled = ICP.get_param('fusion_claims.push_enabled', 'False') + if push_enabled.lower() not in ('true', '1', 'yes'): + return + + local_now = self._local_now() + today = local_now.date() + current_hour = local_now.hour + local_now.minute / 60.0 + + late_tasks = self.sudo().search([ + ('scheduled_date', '=', today), + ('status', '=', 'scheduled'), + ('time_start', '<', current_hour), + ('x_fc_late_notified', '=', False), + ('x_fc_sync_source', '=', False), + ('technician_id', '!=', False), + ]) + + for task in late_tasks: + minutes_late = int((current_hour - task.time_start) * 60) + if minutes_late < 5: + continue + + client = task.client_display_name or 'Client' + try: + task._send_push_notification( + f'Running Late - {client}', + f'Your {task._float_to_time_str(task.time_start)} ' + f'{dict(self._fields["task_type"].selection).get(task.task_type, "task")} ' + f'is {minutes_late} min overdue. Please update your status.', + ) + except Exception: + pass + + try: + order = task._get_linked_order() + if order and order.user_id: + recipient = order.user_id + elif task.create_uid: + recipient = task.create_uid + else: + recipient = None + if recipient: + self.env['mail.thread'].sudo().message_notify( + partner_ids=[recipient.partner_id.id], + subject=f'Late: {task.name} - {client}', + body=Markup( + f'

' + f'{task.technician_id.name} has not started ' + f'{task.name} for {client}, ' + f'scheduled at {task._float_to_time_str(task.time_start)}. ' + f'Currently {minutes_late} min overdue.

' + ), + ) + except Exception: + _logger.warning("Failed to notify office about late task %s", task.name) + + task.with_context(skip_travel_recalc=True).write({ + 'x_fc_late_notified': True, + }) + + if late_tasks: + _logger.info("Late arrival notifications sent for %d tasks", len(late_tasks)) + + # ------------------------------------------------------------------ + # PORTAL HELPERS + # ------------------------------------------------------------------ + + def get_technician_tasks_for_date(self, user_id, date): + """Get all tasks for a technician on a given date, ordered by sequence.""" + return self.sudo().search([ + ('technician_id', '=', user_id), + ('scheduled_date', '=', date), + ('status', '!=', 'cancelled'), + ], order='sequence, time_start, id') + + def get_next_task(self, user_id): + """Get the next upcoming task for a technician.""" + today = fields.Date.context_today(self) + return self.sudo().search([ + ('technician_id', '=', user_id), + ('scheduled_date', '>=', today), + ('status', 'in', ['scheduled', 'en_route']), + ], order='scheduled_date, sequence, time_start', limit=1) + + def get_current_task(self, user_id): + """Get the current in-progress task for a technician.""" + today = fields.Date.context_today(self) + return self.sudo().search([ + ('technician_id', '=', user_id), + ('scheduled_date', '=', today), + ('status', '=', 'in_progress'), + ], limit=1) + + # ------------------------------------------------------------------ + # PUSH NOTIFICATIONS + # ------------------------------------------------------------------ + + def _send_push_notification(self, title, body_text, url=None): + """Send a web push notification for this task.""" + self.ensure_one() + PushSub = self.env['fusion.push.subscription'].sudo() + subscriptions = PushSub.search([ + ('user_id', '=', self.technician_id.id), + ('active', '=', True), + ]) + if not subscriptions: + return + + ICP = self.env['ir.config_parameter'].sudo() + vapid_private = ICP.get_param('fusion_claims.vapid_private_key', '') + vapid_public = ICP.get_param('fusion_claims.vapid_public_key', '') + if not vapid_private or not vapid_public: + _logger.warning("VAPID keys not configured, cannot send push notification") + return + + try: + from pywebpush import webpush, WebPushException + except ImportError: + _logger.warning("pywebpush not installed, cannot send push notifications") + return + + payload = json.dumps({ + 'title': title, + 'body': body_text, + 'url': url or f'/my/technician/task/{self.id}', + 'task_id': self.id, + 'task_type': self.task_type, + }) + + for sub in subscriptions: + try: + webpush( + subscription_info={ + 'endpoint': sub.endpoint, + 'keys': { + 'p256dh': sub.p256dh_key, + 'auth': sub.auth_key, + }, + }, + data=payload, + vapid_private_key=vapid_private, + vapid_claims={'sub': 'mailto:support@nexasystems.ca'}, + ) + except Exception as e: + _logger.warning(f"Push notification failed for subscription {sub.id}: {e}") + # Deactivate invalid subscriptions + if 'gone' in str(e).lower() or '410' in str(e): + sub.active = False + + self.write({ + 'push_notified': True, + 'push_notified_datetime': fields.Datetime.now(), + }) + + @api.model + def _cron_send_push_notifications(self): + """Cron: Send push notifications for upcoming tasks.""" + ICP = self.env['ir.config_parameter'].sudo() + if not ICP.get_param('fusion_claims.push_enabled', False): + return + + advance_minutes = int(ICP.get_param('fusion_claims.push_advance_minutes', '30')) + local_now = self._local_now() + + tasks = self.search([ + ('scheduled_date', '=', local_now.date()), + ('status', '=', 'scheduled'), + ('push_notified', '=', False), + ]) + + for task in tasks: + task_start_hour = int(task.time_start) + task_start_min = int((task.time_start % 1) * 60) + task_start_dt = local_now.replace( + hour=task_start_hour, minute=task_start_min, second=0, microsecond=0) + + minutes_until = (task_start_dt - local_now).total_seconds() / 60 + if 0 <= minutes_until <= advance_minutes: + task_type_label = dict(self._fields['task_type'].selection).get(task.task_type, task.task_type) + title = f'Upcoming: {task_type_label}' + body_text = f'{task.partner_id.name or "Task"} - {task.time_start_display}' + if task.travel_time_minutes: + body_text += f' ({task.travel_time_minutes} min drive)' + task._send_push_notification(title, body_text) + + # ------------------------------------------------------------------ + # HELPERS + # ------------------------------------------------------------------ + + def _get_local_tz(self): + """Return the pytz timezone for local time calculations. + Priority: company resource calendar > user tz > UTC.""" + import pytz + tz_name = ( + self.env.company.resource_calendar_id.tz + or self.env.user.tz + or 'UTC' + ) + try: + return pytz.timezone(tz_name) + except pytz.UnknownTimeZoneError: + return pytz.timezone('UTC') + + def _utc_to_local(self, dt_utc): + """Convert a naive UTC datetime to a timezone-aware local datetime.""" + import pytz + if not dt_utc: + return None + return pytz.utc.localize(dt_utc).astimezone(self._get_local_tz()) + + def _local_now(self): + """Current datetime in the local (company) timezone.""" + return self._utc_to_local(fields.Datetime.now()) + + @staticmethod + def _float_to_time_str(value): + """Convert float hours to time string like '9:30 AM'.""" + if not value and value != 0: + return '' + hours = int(value) + minutes = int(round((value % 1) * 60)) + period = 'AM' if hours < 12 else 'PM' + display_hour = hours % 12 or 12 + return f'{display_hour}:{minutes:02d} {period}' + + def get_google_maps_url(self): + """Get Google Maps navigation URL using the text address so the + destination shows a proper street name instead of raw coordinates. + Returns a google.com/maps URL that Android auto-opens in the app; + iOS handling is done client-side via JS to launch comgooglemaps://.""" + self.ensure_one() + if self.address_display: + addr = urllib.parse.quote(self.address_display) + return f'https://www.google.com/maps/dir/?api=1&destination={addr}&travelmode=driving' + if self.address_lat and self.address_lng: + return f'https://www.google.com/maps/dir/?api=1&destination={self.address_lat},{self.address_lng}&travelmode=driving' + return '' diff --git a/fusion-plating/fusion_tasks/security/ir.model.access.csv b/fusion-plating/fusion_tasks/security/ir.model.access.csv new file mode 100644 index 00000000..dad063b7 --- /dev/null +++ b/fusion-plating/fusion_tasks/security/ir.model.access.csv @@ -0,0 +1,12 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_fusion_technician_task_user,fusion.technician.task.user,model_fusion_technician_task,sales_team.group_sale_salesman,1,1,1,0 +access_fusion_technician_task_manager,fusion.technician.task.manager,model_fusion_technician_task,sales_team.group_sale_manager,1,1,1,1 +access_fusion_technician_task_technician,fusion.technician.task.technician,model_fusion_technician_task,fusion_tasks.group_field_technician,1,1,0,0 +access_fusion_technician_task_portal,fusion.technician.task.portal,model_fusion_technician_task,base.group_portal,1,0,0,0 +access_fusion_push_subscription_user,fusion.push.subscription.user,model_fusion_push_subscription,base.group_user,1,1,1,0 +access_fusion_push_subscription_portal,fusion.push.subscription.portal,model_fusion_push_subscription,base.group_portal,1,1,1,0 +access_fusion_technician_location_manager,fusion.technician.location.manager,model_fusion_technician_location,sales_team.group_sale_manager,1,1,1,1 +access_fusion_technician_location_user,fusion.technician.location.user,model_fusion_technician_location,sales_team.group_sale_salesman,1,0,0,0 +access_fusion_technician_location_portal,fusion.technician.location.portal,model_fusion_technician_location,base.group_portal,0,0,1,0 +access_fusion_task_sync_config_manager,fusion.task.sync.config.manager,model_fusion_task_sync_config,sales_team.group_sale_manager,1,1,1,1 +access_fusion_task_sync_config_user,fusion.task.sync.config.user,model_fusion_task_sync_config,sales_team.group_sale_salesman,1,0,0,0 diff --git a/fusion-plating/fusion_tasks/security/security.xml b/fusion-plating/fusion_tasks/security/security.xml new file mode 100644 index 00000000..7edf97aa --- /dev/null +++ b/fusion-plating/fusion_tasks/security/security.xml @@ -0,0 +1,103 @@ + + + + + + + Fusion Tasks + 46 + + + + + + + Fusion Tasks + 46 + + + + + + + + + + + Field Technician + + + + + + + + + + Technician Task: Manager Full Access + + [(1, '=', 1)] + + + + + + + + + + Technician Task: Sales User Access + + [(1, '=', 1)] + + + + + + + + + + Technician Task: Technician Own Tasks + + [('technician_id', '=', user.id)] + + + + + + + + + + Technician Task: Portal Technician Access + + [('technician_id', '=', user.id)] + + + + + + + + + + + + + + Push Subscription: Own Only + + [('user_id', '=', user.id)] + + + + + + Push Subscription: Portal Own Only + + [('user_id', '=', user.id)] + + + + diff --git a/fusion-plating/fusion_tasks/static/description/icon.png b/fusion-plating/fusion_tasks/static/description/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..5738185dcb6a3cd61de15da3dcc51b9d4cb5a49c GIT binary patch literal 43989 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 Z9Xa0@WCXV>@0

+
+ ${loc.name} + ${srcLabel ? `${srcLabel}` : ""} +
+
+
Last seen: ${loc.logged_at || "Unknown"}
+
Accuracy: ${loc.accuracy ? Math.round(loc.accuracy) + "m" : "N/A"}
+
+
`); + this.infoWindow.open(this.map, marker); + }); + this.techMarkers.push(marker); + bounds.extend(pos); + hasBounds = true; + } + } + + const starts = this.techStartLocations || {}; + for (const uid of Object.keys(starts)) { + const sl = starts[uid]; + if (sl && sl.lat && sl.lng) { + bounds.extend({ lat: sl.lat, lng: sl.lng }); + hasBounds = true; + } + } + + return { bounds, hasBounds }; + } + + _renderMarkers() { + this._clearRoute(); + const { bounds, hasBounds } = this._placeMarkers(); + + if (this.state.showRoute && this.state.showTasks) { + this._renderRoute(); + } + + if (hasBounds) { + try { + this.map.fitBounds(bounds); + if (this.taskMarkers.length + this.techMarkers.length === 1) { + this.map.setZoom(14); + } + } catch (_e) { + // bounds not ready yet + } + } + } + + _renderRoute() { + this._clearRoute(); + + const routeSegments = {}; + for (const group of this.state.groups) { + if (this.state.visibleGroups[group.key] === false) continue; + for (const task of group.tasks) { + if (!task._hasCoords) continue; + const techId = task.technician_id ? task.technician_id[0] : 0; + if (!techId) continue; + const dayKey = task.scheduled_date || "none"; + const segKey = `${techId}_${dayKey}`; + if (!routeSegments[segKey]) { + routeSegments[segKey] = { + name: task._techName, day: dayKey, + techId, tasks: [], + }; + } + routeSegments[segKey].tasks.push(task); + } + } + + const LEG_COLORS = [ + "#3b82f6", "#f59e0b", "#8b5cf6", "#ec4899", + "#f97316", "#0ea5e9", "#d946ef", "#06b6d4", + "#a855f7", "#6366f1", "#eab308", "#0284c7", + "#c026d3", "#7c3aed", "#2563eb", "#db2777", + "#9333ea", "#0891b2", "#4f46e5", "#be185d", + ]; + let globalLegIdx = 0; + + if (!this._directionsService) { + this._directionsService = new google.maps.DirectionsService(); + } + + const allAnimLines = []; + const starts = this.techStartLocations || {}; + + for (const segKey of Object.keys(routeSegments)) { + const seg = routeSegments[segKey]; + const tasks = seg.tasks; + tasks.sort((a, b) => (a.time_start || 0) - (b.time_start || 0)); + + const startLoc = starts[seg.techId]; + const hasStart = startLoc && startLoc.lat && startLoc.lng; + + if (tasks.length < 2 && !hasStart) continue; + if (tasks.length < 1) continue; + + const segBaseColor = LEG_COLORS[globalLegIdx % LEG_COLORS.length]; + + let origin, destination, waypoints, hasStartLeg; + + if (hasStart) { + origin = { lat: startLoc.lat, lng: startLoc.lng }; + destination = { + lat: tasks[tasks.length - 1].address_lat, + lng: tasks[tasks.length - 1].address_lng, + }; + waypoints = tasks.slice(0, -1).map(t => ({ + location: { lat: t.address_lat, lng: t.address_lng }, + stopover: true, + })); + hasStartLeg = true; + } else { + origin = { lat: tasks[0].address_lat, lng: tasks[0].address_lng }; + destination = { + lat: tasks[tasks.length - 1].address_lat, + lng: tasks[tasks.length - 1].address_lng, + }; + waypoints = tasks.slice(1, -1).map(t => ({ + location: { lat: t.address_lat, lng: t.address_lng }, + stopover: true, + })); + hasStartLeg = false; + } + + if (hasStart) { + const startSvg = + `` + + `` + + `` + + ``; + const startMarker = new google.maps.Marker({ + position: origin, + map: this.map, + title: `${seg.name} - Start`, + icon: { + url: "data:image/svg+xml;charset=UTF-8," + encodeURIComponent(startSvg), + scaledSize: new google.maps.Size(32, 32), + anchor: new google.maps.Point(16, 16), + }, + zIndex: 5, + }); + startMarker.addListener("click", () => { + this.infoWindow.setContent(` +
+
+ ${seg.name} - Start +
+
+ ${startLoc.address || 'Start location'} +
${startLoc.source === 'clock_in' ? 'Clock-in location' : startLoc.source === 'start_address' ? 'Home address' : 'Company HQ'}
+
+
`); + this.infoWindow.open(this.map, startMarker); + }); + this.routeLines.push(startMarker); + } + + this._directionsService.route({ + origin, + destination, + waypoints, + optimizeWaypoints: false, + travelMode: google.maps.TravelMode.DRIVING, + avoidTolls: true, + drivingOptions: { + departureTime: new Date(), + trafficModel: "bestguess", + }, + }, (result, status) => { + if (status !== "OK" || !result.routes || !result.routes[0]) return; + + const route = result.routes[0]; + + for (let li = 0; li < route.legs.length; li++) { + const leg = route.legs[li]; + const legColor = LEG_COLORS[globalLegIdx % LEG_COLORS.length]; + globalLegIdx++; + + const legPath = []; + for (const step of leg.steps) { + for (const pt of step.path) legPath.push(pt); + } + if (legPath.length < 2) continue; + + const baseLine = new google.maps.Polyline({ + path: legPath, map: this.map, + strokeColor: legColor, strokeOpacity: 0.25, strokeWeight: 6, + zIndex: 1, + }); + this.routeLines.push(baseLine); + + const animLine = new google.maps.Polyline({ + path: legPath, map: this.map, + strokeOpacity: 0, strokeWeight: 0, zIndex: 2, + icons: [{ + icon: { + path: "M 0,-0.5 0,0.5", + strokeOpacity: 0.8, strokeColor: legColor, + strokeWeight: 3, scale: 4, + }, + offset: "0%", repeat: "16px", + }], + }); + this.routeLines.push(animLine); + allAnimLines.push(animLine); + + const arrowLine = new google.maps.Polyline({ + path: legPath, map: this.map, + strokeOpacity: 0, strokeWeight: 0, zIndex: 3, + icons: [{ + icon: { + path: google.maps.SymbolPath.FORWARD_OPEN_ARROW, + scale: 3, strokeColor: legColor, + strokeOpacity: 0.9, strokeWeight: 2.5, + }, + offset: "0%", repeat: "80px", + }], + }); + this.routeLines.push(arrowLine); + allAnimLines.push(arrowLine); + + const dur = leg.duration_in_traffic || leg.duration; + const dist = leg.distance; + if (dur) { + const totalMins = Math.round(dur.value / 60); + const totalKm = dist ? (dist.value / 1000).toFixed(1) : null; + + const destIdx = hasStartLeg ? li : li + 1; + const destTask = destIdx < tasks.length ? tasks[destIdx] : tasks[tasks.length - 1]; + const etaFloat = destTask.time_start || 0; + const etaStr = etaFloat ? floatToTime12(etaFloat) : ""; + + const techName = seg.name; + this.routeLabels.push(this._createTravelLabel( + legPath, totalMins, totalKm, legColor, techName, etaStr, + )); + } + } + + if (!this.routeAnimFrameId) { + this._startRouteAnimation(allAnimLines); + } + }); + } + } + + _pointAlongLeg(leg, fraction) { + const points = []; + for (const step of leg.steps) { + for (const pt of step.path) { + points.push(pt); + } + } + if (points.length < 2) return leg.start_location; + + const segDists = []; + let totalDist = 0; + for (let i = 1; i < points.length; i++) { + const d = google.maps.geometry + ? google.maps.geometry.spherical.computeDistanceBetween(points[i - 1], points[i]) + : this._haversine(points[i - 1], points[i]); + segDists.push(d); + totalDist += d; + } + + const target = totalDist * fraction; + let acc = 0; + for (let i = 0; i < segDists.length; i++) { + if (acc + segDists[i] >= target) { + const remain = target - acc; + const ratio = segDists[i] > 0 ? remain / segDists[i] : 0; + return new google.maps.LatLng( + points[i].lat() + (points[i + 1].lat() - points[i].lat()) * ratio, + points[i].lng() + (points[i + 1].lng() - points[i].lng()) * ratio, + ); + } + acc += segDists[i]; + } + return points[points.length - 1]; + } + + _haversine(a, b) { + const R = 6371000; + const dLat = (b.lat() - a.lat()) * Math.PI / 180; + const dLng = (b.lng() - a.lng()) * Math.PI / 180; + const s = Math.sin(dLat / 2) ** 2 + + Math.cos(a.lat() * Math.PI / 180) * Math.cos(b.lat() * Math.PI / 180) * + Math.sin(dLng / 2) ** 2; + return R * 2 * Math.atan2(Math.sqrt(s), Math.sqrt(1 - s)); + } + + _createTravelLabel(legPath, mins, km, color, techName, eta) { + if (!this._TravelLabel) { + this._TravelLabel = class extends google.maps.OverlayView { + constructor(path, html) { + super(); + this._path = path; + this._html = html; + this._div = null; + } + onAdd() { + this._div = document.createElement("div"); + this._div.style.position = "absolute"; + this._div.style.whiteSpace = "nowrap"; + this._div.style.pointerEvents = "none"; + this._div.style.zIndex = "50"; + this._div.style.transition = "left .3s ease, top .3s ease"; + this._div.innerHTML = this._html; + this.getPanes().floatPane.appendChild(this._div); + } + draw() { + const proj = this.getProjection(); + if (!proj || !this._div) return; + const map = this.getMap(); + if (!map) return; + const bounds = map.getBounds(); + if (!bounds) return; + + const visible = this._path.filter(p => bounds.contains(p)); + if (visible.length === 0) { + this._div.style.display = "none"; + return; + } + this._div.style.display = ""; + + const anchor = visible[Math.floor(visible.length / 2)]; + + const px = proj.fromLatLngToDivPixel(anchor); + if (px) { + this._div.style.left = (px.x - this._div.offsetWidth / 2) + "px"; + this._div.style.top = (px.y - this._div.offsetHeight - 8) + "px"; + } + } + onRemove() { + if (this._div && this._div.parentNode) { + this._div.parentNode.removeChild(this._div); + } + this._div = null; + } + }; + } + + const timeStr = mins < 60 + ? `${mins} min` + : `${Math.floor(mins / 60)}h ${mins % 60}m`; + const distStr = km ? `${km} km` : ""; + + const firstName = techName ? techName.split(" ")[0] : ""; + const html = `
${firstName ? `${firstName}|` : ""}🚗${timeStr}${distStr ? `· ${distStr}` : ""}${eta ? `|ETA ${eta}` : ""}
`; + + const label = new this._TravelLabel(legPath, html); + label.setMap(this.map); + return label; + } + + _startRouteAnimation(animLines) { + let off = 0; + let last = 0; + const animate = (ts) => { + this.routeAnimFrameId = requestAnimationFrame(animate); + if (ts - last < 50) return; + last = ts; + off = (off + 0.08) % 100; + const pct = off + "%"; + for (const line of animLines) { + const icons = line.get("icons"); + if (icons && icons.length > 0) { + icons[0].offset = pct; + line.set("icons", icons); + } + } + }; + this.routeAnimFrameId = requestAnimationFrame(animate); + } + + _openTaskPopup(task, marker) { + const c = task._dayColor; + const sc = task._statusColor; + const navDest = task.address_lat && task.address_lng + ? `${task.address_lat},${task.address_lng}` + : encodeURIComponent(task.address_display || ""); + const html = ` +
+
+
+ #${task._scheduleNum} ${task.name} + ${task._statusLabel} +
+
${task._clientName}
+
+
+ + ${task._typeLbl} + + + ${task._timeRange} + + ${task.travel_time_minutes ? `${task.travel_time_minutes} min` : ""} +
+
+
👤${task._techName}
+
📅${task.scheduled_date || "No date"}
+ ${task.address_display ? `
📍${task.address_display}
` : ""} +
+
+ + + Navigate → + +
+
`; + this.infoWindow.setContent(html); + this.infoWindow.open(this.map, marker); + } + + // ── Sidebar actions ───────────────────────────────────────────── + toggleSidebar() { + this.state.sidebarOpen = !this.state.sidebarOpen; + // Trigger map resize after CSS transition + if (this.map) { + setTimeout(() => google.maps.event.trigger(this.map, "resize"), 320); + } + } + + toggleGroup(groupKey) { + this.state.collapsedGroups[groupKey] = !this.state.collapsedGroups[groupKey]; + } + + isGroupCollapsed(groupKey) { + return !!this.state.collapsedGroups[groupKey]; + } + + focusTask(taskId) { + this.state.activeTaskId = taskId; + const marker = this.taskMarkerMap[taskId]; + if (marker && this.map) { + this.map.panTo(marker.getPosition()); + this.map.setZoom(15); + // Find the task data + for (const g of this.state.groups) { + for (const t of g.tasks) { + if (t.id === taskId) { + this._openTaskPopup(t, marker); + return; + } + } + } + } + } + + // ── Day filter toggle ──────────────────────────────────────────── + toggleDayFilter(groupKey) { + this.state.visibleGroups[groupKey] = !this.state.visibleGroups[groupKey]; + this._renderMarkers(); + } + + isGroupVisible(groupKey) { + return this.state.visibleGroups[groupKey] !== false; + } + + showAllDays() { + for (const k of Object.keys(this.state.visibleGroups)) { + this.state.visibleGroups[k] = true; + } + this._renderMarkers(); + } + + showTodayOnly() { + for (const k of Object.keys(this.state.visibleGroups)) { + this.state.visibleGroups[k] = k === GROUP_TODAY; + } + this._renderMarkers(); + } + + // ── Technician filter ───────────────────────────────────────────── + toggleTechFilter(techId) { + if (this.state.visibleTechIds[techId]) { + delete this.state.visibleTechIds[techId]; + } else { + this.state.visibleTechIds[techId] = true; + } + this._rebuildGroups(); + this._renderMarkers(); + } + + isTechVisible(techId) { + const hasFilter = Object.keys(this.state.visibleTechIds).length > 0; + return !hasFilter || !!this.state.visibleTechIds[techId]; + } + + showAllTechs() { + this.state.visibleTechIds = {}; + this._rebuildGroups(); + this._renderMarkers(); + } + + // ── Top bar actions ───────────────────────────────────────────── + toggleTraffic() { + this.state.showTraffic = !this.state.showTraffic; + if (this.trafficLayer) { + this.trafficLayer.setMap(this.state.showTraffic ? this.map : null); + } + } + toggleTasks() { + this.state.showTasks = !this.state.showTasks; + this._renderMarkers(); + } + toggleTechnicians() { + this.state.showTechnicians = !this.state.showTechnicians; + this._renderMarkers(); + } + toggleRoute() { + this.state.showRoute = !this.state.showRoute; + if (this.state.showRoute) { + this._renderRoute(); + } else { + this._clearRoute(); + } + } + onRefresh() { + this.state.loading = true; + this._loadAndRender(); + } + async openTask(taskId) { + if (!taskId) return; + try { + await this.actionService.doAction( + { + type: "ir.actions.act_window", + res_model: "fusion.technician.task", + res_id: taskId, + view_mode: "form", + views: [[false, "form"]], + target: "new", + context: { dialog_size: "extra-large" }, + }, + { onClose: () => this._softRefresh() }, + ); + } catch (e) { + console.error("[FusionMap] openTask failed:", e); + this.actionService.doAction({ + type: "ir.actions.act_window", + res_model: "fusion.technician.task", + res_id: taskId, + view_mode: "form", + views: [[false, "form"]], + target: "current", + }); + } + } + async createNewTask() { + try { + await this.actionService.doAction( + { + type: "ir.actions.act_window", + res_model: "fusion.technician.task", + view_mode: "form", + views: [[false, "form"]], + target: "new", + context: { default_task_type: "delivery", dialog_size: "extra-large" }, + }, + { onClose: () => this._softRefresh() }, + ); + } catch (e) { + console.error("[FusionMap] createNewTask failed:", e); + this.actionService.doAction({ + type: "ir.actions.act_window", + res_model: "fusion.technician.task", + view_mode: "form", + views: [[false, "form"]], + target: "current", + context: { default_task_type: "delivery" }, + }); + } + } +} + +window.__fusionMapOpenTask = () => {}; + +// ── Minimal ArchParser for tags (no web_map dependency) ─────── +class FusionMapArchParser { + parse(xmlDoc, models, modelName) { + const fieldNames = []; + const activeFields = {}; + if (xmlDoc && xmlDoc.querySelectorAll) { + for (const fieldEl of xmlDoc.querySelectorAll("field")) { + const name = fieldEl.getAttribute("name"); + if (name) { + fieldNames.push(name); + activeFields[name] = { attrs: {}, options: {} }; + } + } + } + return { fieldNames, activeFields }; + } +} + +// ── View registration (self-contained, no @web_map dependency) ────── +const fusionTaskMapView = { + type: "map", + display_name: _t("Map"), + icon: "oi-view-map", + multiRecord: true, + searchMenuTypes: ["filter", "groupBy", "favorite"], + Controller: FusionTaskMapController, + Model: RelationalModel, + ArchParser: FusionMapArchParser, + buttonTemplate: "fusion_tasks.FusionTaskMapView.Buttons", + props(genericProps, view, config) { + const { resModel, fields } = genericProps; + let archInfo = { fieldNames: [], activeFields: {} }; + if (view && view.arch) { + archInfo = new FusionMapArchParser().parse(view.arch); + } + return { + ...genericProps, + buttonTemplate: "fusion_tasks.FusionTaskMapView.Buttons", + Model: RelationalModel, + modelParams: { + config: { + resModel, + fields, + activeFields: archInfo.activeFields || {}, + isMonoRecord: false, + }, + state: { + domain: genericProps.domain || [], + context: genericProps.context || {}, + groupBy: genericProps.groupBy || [], + orderBy: genericProps.orderBy || [], + }, + }, + }; + }, +}; +registry.category("views").add("fusion_task_map", fusionTaskMapView); diff --git a/fusion-plating/fusion_tasks/static/src/xml/fusion_task_map_view.xml b/fusion-plating/fusion_tasks/static/src/xml/fusion_task_map_view.xml new file mode 100644 index 00000000..7e99b8fe --- /dev/null +++ b/fusion-plating/fusion_tasks/static/src/xml/fusion_task_map_view.xml @@ -0,0 +1,255 @@ + + + + +
+ + + + + + + + + + + + + + +
+ + +
+ + +
+
+
+ Deliveries + +
+ +
+ + + + +
+ + + + +
+ + + +
+ + + + +
+
+
+ + +
+ + +
+ + + + hidden + +
+ + +
+ +
+ + +
+ + + + + + + +
+ + +
+ + +
+ + +
+ + +
+ +
+ + +
+ +
+
+ + +
+ + +
+ + + min travel + + + + + + + Edit + +
+
+ +
+
+ + +
+ + No tasks found +
+
+ + + +
+ + + + + +
+ +
+ + + + Pins: + Pending + Today + Tomorrow + This Week + Upcoming + Yesterday + + + + +
+ + +
+
+ + +
+
+ + Loading Google Maps... +
+
+ + +
+ +
+ + +
+
+ +
No locations to show
+

Try adjusting the filters or date range.

+
+
+
+
+
+ +
+ + + + + diff --git a/fusion-plating/fusion_tasks/views/res_config_settings_views.xml b/fusion-plating/fusion_tasks/views/res_config_settings_views.xml new file mode 100644 index 00000000..4247bf5e --- /dev/null +++ b/fusion-plating/fusion_tasks/views/res_config_settings_views.xml @@ -0,0 +1,156 @@ + + + + + res.config.settings.view.form.fusion.tasks + res.config.settings + + + + + +

Technician Management

+ +
+ +
+
+ Google Maps API +
+ API key for Google Maps Places autocomplete in address fields and Distance Matrix travel calculations. +
+
+ +
+ +
+
+ +
+
+ Google Business Review URL +
+ Link to your Google Business Profile review page. + Sent to clients after service completion (when "Request Google Review" is enabled on the task). +
+
+ +
+
+
+ +
+
+ Store / Scheduling Hours +
+ Operating hours for technician task scheduling. Tasks can only be booked + within these hours. Calendar view is also restricted to this range. +
+
+ + to + +
+
+
+ +
+
+ +
+
+
+
+ +
+
+ Default HQ / Fallback Address +
+ Company default start location used when a technician has no personal + start address set. Each technician can set their own start location + in their user profile or from the portal. +
+
+ +
+
+
+ +
+
+ Location History Retention +
+ How many days to keep technician GPS location history before automatic cleanup. +
+
+ + days +
+
+ Leave empty = 30 days. Enter 0 = delete at end of each day. 1+ = keep that many days. +
+
+
+
+ +

Push Notifications

+ +
+ +
+
+ +
+
+
+
+ +
+
+ Notification Advance Time +
+ Send push notification this many minutes before a scheduled task. +
+
+ minutes +
+
+
+ +
+
+ VAPID Public Key +
+ +
+
+
+ +
+
+ VAPID Private Key +
+ +
+
+
+
+ +
+
+
+
+
diff --git a/fusion-plating/fusion_tasks/views/technician_location_views.xml b/fusion-plating/fusion_tasks/views/technician_location_views.xml new file mode 100644 index 00000000..6248f104 --- /dev/null +++ b/fusion-plating/fusion_tasks/views/technician_location_views.xml @@ -0,0 +1,102 @@ + + + + + + + + fusion.technician.location.list + fusion.technician.location + + + + + + + + + + + + + + + + + fusion.technician.location.form + fusion.technician.location + +
+ + + + + + + + + + + + + + +
+
+
+ + + + + + fusion.technician.location.search + fusion.technician.location + + + + + + + + + + + + + + + + + + + + Location History + fusion.technician.location + list,form + + { + 'search_default_filter_today': 1, + 'search_default_group_user': 1, + } + +

+ No location data logged yet. +

+

Technician locations are automatically logged when they use the portal.

+
+
+ + + + + + +
diff --git a/fusion-plating/fusion_tasks/views/technician_task_views.xml b/fusion-plating/fusion_tasks/views/technician_task_views.xml new file mode 100644 index 00000000..0406458a --- /dev/null +++ b/fusion-plating/fusion_tasks/views/technician_task_views.xml @@ -0,0 +1,507 @@ + + + + + + + + Technician Task + fusion.technician.task + TASK- + 5 + 1 + + + + + + + res.users.form.field.staff + res.users + + + + + + + + + + + + + + + fusion.technician.task.search + fusion.technician.task + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + fusion.technician.task.form + fusion.technician.task + +
+ + +
+
+ + + +
+
+ + + +
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ + + + + + fusion.technician.task.list + fusion.technician.task + + + + + + + + + + + + + + + + + + + + + + + + + fusion.technician.task.kanban + fusion.technician.task + + + + + + + + + + + + + + + + + + +
+
+
+
+ + + +
+ +
+
+ + - +
+
+ + + + + + + +
+
+ + + min + +
+
+ + + technician(s) +
+
+
+ +
+
+ +
+
+
+
+
+
+
+
+
+ + + + + + fusion.technician.task.calendar + fusion.technician.task + + + + + + + + + + + + + + + + + + + + + + + + fusion.technician.task.map + fusion.technician.task + + + + + + + + + + + + + + + + + + + + Technician Tasks + fusion.technician.task + list,kanban,form,calendar,map + + {'search_default_filter_active': 1} + +

+ Create your first technician task +

+

Schedule deliveries, repairs, and other field tasks for your technicians.

+
+
+ + + + Schedule + fusion.technician.task + map,calendar,list,kanban,form + + {'search_default_filter_active': 1} + + + + + Task Map + fusion.technician.task + map,list,kanban,form,calendar + + {'search_default_filter_active': 1} + + + + + Today's Tasks + fusion.technician.task + kanban,list,form,map + + {'search_default_filter_today': 1, 'search_default_filter_active': 1} + + + + + My Tasks + fusion.technician.task + list,kanban,form,calendar,map + + {'search_default_filter_my_tasks': 1, 'search_default_filter_active': 1} + + + + + Pending Tasks + fusion.technician.task + list,kanban,form + + {'search_default_filter_pending': 1} + + + + + Task Calendar + fusion.technician.task + calendar,list,kanban,form,map + + {'search_default_filter_active': 1} + + + + + + + + + + + + + + + + + + + + + +