feat: add fusion_tasks module for field service management

Standalone module extracted from fusion_claims providing technician
scheduling, route simulation with Google Maps, GPS tracking, and
cross-instance task sync between odoo-westin and odoo-mobility.

Includes fix for route simulation: added Directions API error logging
to diagnose silent failures from conflicting Google Maps API keys.

Made-with: Cursor
This commit is contained in:
gsinghpal
2026-03-09 16:56:53 -04:00
parent b649246e81
commit 3b3c57205a
23 changed files with 7445 additions and 0 deletions

36
fusion_tasks/__init__.py Normal file
View File

@@ -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]})

View File

@@ -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,
}

View File

@@ -0,0 +1,50 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!--
Default configuration parameters for Fusion Tasks.
noupdate="1" ensures these are ONLY set on first install.
forcecreate="false" prevents errors if keys already exist.
Keys use fusion_claims.* prefix to preserve existing data.
-->
<data noupdate="1">
<!-- Google Maps API Key -->
<record id="config_google_maps_api_key" model="ir.config_parameter" forcecreate="false">
<field name="key">fusion_claims.google_maps_api_key</field>
<field name="value"></field>
</record>
<!-- Store Hours -->
<record id="config_store_open_hour" model="ir.config_parameter" forcecreate="false">
<field name="key">fusion_claims.store_open_hour</field>
<field name="value">9.0</field>
</record>
<record id="config_store_close_hour" model="ir.config_parameter" forcecreate="false">
<field name="key">fusion_claims.store_close_hour</field>
<field name="value">18.0</field>
</record>
<!-- Push Notifications -->
<record id="config_push_enabled" model="ir.config_parameter" forcecreate="false">
<field name="key">fusion_claims.push_enabled</field>
<field name="value">False</field>
</record>
<record id="config_push_advance_minutes" model="ir.config_parameter" forcecreate="false">
<field name="key">fusion_claims.push_advance_minutes</field>
<field name="value">30</field>
</record>
<!-- Cross-instance task sync -->
<record id="config_sync_instance_id" model="ir.config_parameter" forcecreate="false">
<field name="key">fusion_claims.sync_instance_id</field>
<field name="value"></field>
</record>
<!-- Technician start address (HQ default) -->
<record id="config_technician_start_address" model="ir.config_parameter" forcecreate="false">
<field name="key">fusion_claims.technician_start_address</field>
<field name="value"></field>
</record>
</data>
</odoo>

View File

@@ -0,0 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<!--
Copyright 2024-2026 Nexa Systems Inc.
License OPL-1 (Odoo Proprietary License v1.0)
-->
<odoo>
<data>
<!-- Cron Job: Calculate Travel Times for Technician Tasks (every 15 min) -->
<record id="ir_cron_technician_travel_times" model="ir.cron">
<field name="name">Fusion Tasks: Calculate Technician Travel Times</field>
<field name="model_id" ref="model_fusion_technician_task"/>
<field name="state">code</field>
<field name="code">model._cron_calculate_travel_times()</field>
<field name="interval_number">15</field>
<field name="interval_type">minutes</field>
<field name="active">True</field>
</record>
<!-- Cron Job: Send Push Notifications for Upcoming Tasks -->
<record id="ir_cron_technician_push_notifications" model="ir.cron">
<field name="name">Fusion Tasks: Technician Push Notifications</field>
<field name="model_id" ref="model_fusion_technician_task"/>
<field name="state">code</field>
<field name="code">model._cron_send_push_notifications()</field>
<field name="interval_number">15</field>
<field name="interval_type">minutes</field>
<field name="active">True</field>
</record>
<!-- Cron Job: Pull Remote Technician Tasks (cross-instance sync) -->
<record id="ir_cron_task_sync_pull" model="ir.cron">
<field name="name">Fusion Tasks: Sync Remote Tasks (Pull)</field>
<field name="model_id" ref="model_fusion_task_sync_config"/>
<field name="state">code</field>
<field name="code">model._cron_pull_remote_tasks()</field>
<field name="interval_number">2</field>
<field name="interval_type">minutes</field>
<field name="active">True</field>
</record>
<!-- Cron Job: Cleanup Old Shadow Tasks (30+ days) -->
<record id="ir_cron_task_sync_cleanup" model="ir.cron">
<field name="name">Fusion Tasks: Cleanup Old Shadow Tasks</field>
<field name="model_id" ref="model_fusion_task_sync_config"/>
<field name="state">code</field>
<field name="code">model._cron_cleanup_old_shadows()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active">True</field>
<field name="nextcall" eval="DateTime.now().replace(hour=3, minute=0, second=0)"/>
</record>
<!-- Cron Job: Check for Late Technician Arrivals -->
<record id="ir_cron_check_late_arrivals" model="ir.cron">
<field name="name">Fusion Tasks: Check Late Technician Arrivals</field>
<field name="model_id" ref="model_fusion_technician_task"/>
<field name="state">code</field>
<field name="code">model._cron_check_late_arrivals()</field>
<field name="interval_number">10</field>
<field name="interval_type">minutes</field>
<field name="active">True</field>
</record>
<!-- Cron Job: Cleanup Old Technician Locations -->
<record id="ir_cron_cleanup_locations" model="ir.cron">
<field name="name">Fusion Tasks: Cleanup Old Locations</field>
<field name="model_id" ref="model_fusion_technician_location"/>
<field name="state">code</field>
<field name="code">model._cron_cleanup_old_locations()</field>
<field name="interval_number">1</field>
<field name="interval_type">days</field>
<field name="active">True</field>
<field name="nextcall" eval="DateTime.now().replace(hour=4, minute=0, second=0)"/>
</record>
</data>
</odoo>

View File

@@ -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

View File

@@ -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 <strong> 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'<div style="font-family:-apple-system,BlinkMacSystemFont,\'Segoe UI\',Roboto,Arial,sans-serif;'
f'max-width:600px;margin:0 auto;">'
f'<div style="height:4px;background-color:{accent};"></div>'
f'<div style="padding:32px 28px;">'
)
# -- Company name (accent color works in both themes)
parts.append(
f'<p style="color:{accent};font-size:13px;font-weight:600;letter-spacing:0.5px;'
f'text-transform:uppercase;margin:0 0 24px 0;">{company["name"]}</p>'
)
# -- Title (inherits text color from container)
parts.append(
f'<h2 style="font-size:22px;font-weight:700;'
f'margin:0 0 6px 0;line-height:1.3;">{title}</h2>'
)
# -- Summary (muted via opacity)
parts.append(
f'<p style="opacity:0.65;font-size:15px;line-height:1.5;'
f'margin:0 0 24px 0;">{summary}</p>'
)
# -- 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'<p style="font-size:14px;line-height:1.6;margin:24px 0 0 0;">'
f'Best regards,<br/>'
f'<strong>{signer}</strong><br/>'
f'<span style="opacity:0.6;">{company["name"]}</span></p>'
)
# -- Close content card
parts.append('</div>')
# -- Footer
footer_parts = [company['name']]
if company['phone']:
footer_parts.append(company['phone'])
if company['email']:
footer_parts.append(company['email'])
footer_text = ' &middot; '.join(footer_parts)
parts.append(
f'<div style="padding:16px 28px;text-align:center;">'
f'<p style="opacity:0.5;font-size:11px;line-height:1.5;margin:0;">'
f'{footer_text}<br/>'
f'This is an automated notification from the ADP Claims Management System.</p>'
f'</div>'
)
# -- Close wrapper
parts.append('</div>')
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 = (
'<table style="width:100%;border-collapse:collapse;margin:0 0 24px 0;">'
f'<tr><td colspan="2" style="padding:10px 14px;font-size:12px;font-weight:600;'
f'opacity:0.55;text-transform:uppercase;letter-spacing:0.5px;'
f'border-bottom:2px solid rgba(128,128,128,0.25);">{heading}</td></tr>'
)
for label, value in rows:
if value is None or value == '' or value is False:
continue
html += (
f'<tr>'
f'<td style="padding:10px 14px;opacity:0.6;font-size:14px;'
f'border-bottom:1px solid rgba(128,128,128,0.15);width:35%;">{label}</td>'
f'<td style="padding:10px 14px;font-size:14px;'
f'border-bottom:1px solid rgba(128,128,128,0.15);">{value}</td>'
f'</tr>'
)
html += '</table>'
return html
def _email_note(self, text, color='#2B6CB0'):
"""Build a left-border accent note block."""
return (
f'<div style="border-left:3px solid {color};padding:12px 16px;'
f'margin:0 0 24px 0;">'
f'<p style="margin:0;font-size:14px;line-height:1.5;">{text}</p>'
f'</div>'
)
def _email_button(self, url, text='View Case Details', color='#2B6CB0'):
"""Build a centered CTA button."""
return (
f'<p style="text-align:center;margin:28px 0;">'
f'<a href="{url}" style="display:inline-block;background:{color};color:#ffffff;'
f'padding:12px 28px;text-decoration:none;border-radius:6px;'
f'font-size:14px;font-weight:600;">{text}</a></p>'
)
def _email_attachment_note(self, description):
"""Build a dashed-border attachment callout.
Args:
description: e.g. "ADP Application (PDF), XML Data File"
"""
return (
f'<div style="padding:10px 14px;border:1px dashed rgba(128,128,128,0.35);border-radius:6px;'
f'margin:0 0 24px 0;">'
f'<p style="margin:0;font-size:13px;opacity:0.65;">'
f'<strong style="opacity:1;">Attached:</strong> {description}</p>'
f'</div>'
)
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'<span style="display:inline-block;background:{bg};color:{color};'
f'padding:2px 10px;border-radius:12px;font-size:12px;font-weight:600;">'
f'{label}</span>'
)
# ------------------------------------------------------------------
# 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')

View File

@@ -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,
})

View File

@@ -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',
)

View File

@@ -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',
)

View File

@@ -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

View File

@@ -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,
)

View File

@@ -0,0 +1,748 @@
# -*- coding: utf-8 -*-
# Copyright 2024-2026 Nexa Systems Inc.
# License OPL-1 (Odoo Proprietary License v1.0)
"""
Cross-instance technician task sync.
Enables two Odoo instances (e.g. Westin and Mobility) that share the same
field technicians to see each other's delivery tasks, preventing double-booking.
Remote tasks appear as read-only "shadow" records in the local calendar.
The existing _find_next_available_slot() automatically sees shadow tasks,
so collision detection works without changes to the scheduling algorithm.
Technicians are matched across instances using the x_fc_tech_sync_id field
on res.users. Set the same value (e.g. "gordy") on both instances for the
same person -- no mapping table needed.
"""
from odoo import models, fields, api, _
from odoo.exceptions import UserError
import logging
import requests
from datetime import timedelta
_logger = logging.getLogger(__name__)
SYNC_TASK_FIELDS = [
'x_fc_sync_uuid', 'name', 'technician_id', 'additional_technician_ids',
'task_type', 'status',
'scheduled_date', 'time_start', 'time_end', 'duration_hours',
'address_street', 'address_street2', 'address_city', 'address_zip',
'address_state_id', 'address_buzz_code',
'address_lat', 'address_lng', 'priority', 'partner_id', 'partner_phone',
'pod_required', 'description',
'travel_time_minutes', 'travel_distance_km', 'travel_origin',
'completed_latitude', 'completed_longitude',
'action_latitude', 'action_longitude',
'completion_datetime',
]
TERMINAL_STATUSES = ('completed', 'cancelled')
class FusionTaskSyncConfig(models.Model):
_name = 'fusion.task.sync.config'
_description = 'Task Sync Remote Instance'
name = fields.Char('Instance Name', required=True,
help='e.g. Westin Healthcare, Mobility Specialties')
instance_id = fields.Char('Instance ID', required=True,
help='Short identifier, e.g. westin or mobility')
url = fields.Char('Odoo URL', required=True,
help='e.g. http://192.168.1.40:8069')
database = fields.Char('Database', required=True)
username = fields.Char('API Username', required=True)
api_key = fields.Char('API Key', required=True)
active = fields.Boolean(default=True)
last_sync = fields.Datetime('Last Successful Sync', readonly=True)
last_sync_error = fields.Text('Last Error', readonly=True)
# ------------------------------------------------------------------
# JSON-RPC helpers (uses /jsonrpc dispatch, muted on receiving side)
# ------------------------------------------------------------------
def _jsonrpc(self, service, method, args):
"""Execute a JSON-RPC call against the remote Odoo instance."""
self.ensure_one()
url = f"{self.url.rstrip('/')}/jsonrpc"
payload = {
'jsonrpc': '2.0',
'method': 'call',
'id': 1,
'params': {
'service': service,
'method': method,
'args': args,
},
}
try:
resp = requests.post(url, json=payload, timeout=15)
resp.raise_for_status()
result = resp.json()
if result.get('error'):
err = result['error'].get('data', {}).get('message', str(result['error']))
raise UserError(f"Remote error: {err}")
return result.get('result')
except requests.exceptions.ConnectionError:
_logger.warning("Task sync: cannot connect to %s", self.url)
return None
except requests.exceptions.Timeout:
_logger.warning("Task sync: timeout connecting to %s", self.url)
return None
def _authenticate(self):
"""Authenticate with the remote instance and return the uid."""
self.ensure_one()
uid = self._jsonrpc('common', 'authenticate',
[self.database, self.username, self.api_key, {}])
if not uid:
_logger.error("Task sync: authentication failed for %s", self.name)
return uid
def _rpc(self, model, method, args, kwargs=None):
"""Execute a method on the remote instance via execute_kw."""
self.ensure_one()
uid = self._authenticate()
if not uid:
return None
call_args = [self.database, uid, self.api_key, model, method, args]
if kwargs:
call_args.append(kwargs)
return self._jsonrpc('object', 'execute_kw', call_args)
# ------------------------------------------------------------------
# Tech sync ID helpers
# ------------------------------------------------------------------
def _get_local_tech_map(self):
"""Build {local_user_id: x_fc_tech_sync_id} for all local field staff."""
techs = self.env['res.users'].sudo().search([
('x_fc_is_field_staff', '=', True),
('x_fc_tech_sync_id', '!=', False),
('active', '=', True),
])
return {u.id: u.x_fc_tech_sync_id for u in techs}
def _get_remote_tech_map(self):
"""Build {x_fc_tech_sync_id: remote_user_id} from the remote instance."""
self.ensure_one()
remote_users = self._rpc('res.users', 'search_read', [
[('x_fc_is_field_staff', '=', True),
('x_fc_tech_sync_id', '!=', False),
('active', '=', True)],
], {'fields': ['id', 'x_fc_tech_sync_id']})
if not remote_users:
return {}
return {
ru['x_fc_tech_sync_id']: ru['id']
for ru in remote_users
if ru.get('x_fc_tech_sync_id')
}
def _get_local_syncid_to_uid(self):
"""Build {x_fc_tech_sync_id: local_user_id} for local field staff."""
techs = self.env['res.users'].sudo().search([
('x_fc_is_field_staff', '=', True),
('x_fc_tech_sync_id', '!=', False),
('active', '=', True),
])
return {u.x_fc_tech_sync_id: u.id for u in techs}
# ------------------------------------------------------------------
# Connection test
# ------------------------------------------------------------------
def action_test_connection(self):
"""Test the connection to the remote instance."""
self.ensure_one()
uid = self._authenticate()
if uid:
remote_map = self._get_remote_tech_map()
local_map = self._get_local_tech_map()
matched = set(local_map.values()) & set(remote_map.keys())
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Connection Successful',
'message': f'Connected to {self.name}. '
f'{len(matched)} technician(s) matched by sync ID.',
'type': 'success',
'sticky': False,
},
}
raise UserError(f"Cannot connect to {self.name}. Check URL, database, and API key.")
# ------------------------------------------------------------------
# PUSH: send local task changes to remote instance
# ------------------------------------------------------------------
def _get_local_instance_id(self):
"""Return this instance's own ID from config parameters."""
return self.env['ir.config_parameter'].sudo().get_param(
'fusion_claims.sync_instance_id', '')
@api.model
def _push_tasks(self, tasks, operation='create'):
"""Push local task changes to all active remote instances.
Called from technician_task create/write overrides.
Non-blocking: errors are logged, not raised.
"""
configs = self.sudo().search([('active', '=', True)])
if not configs:
return
local_id = configs[0]._get_local_instance_id()
if not local_id:
return
for config in configs:
try:
config._push_tasks_to_remote(tasks, operation, local_id)
except Exception:
_logger.exception("Task sync push to %s failed", config.name)
def _push_tasks_to_remote(self, tasks, operation, local_instance_id):
"""Push task data to a single remote instance.
Maps additional_technician_ids via sync IDs so the remote instance
also blocks those technicians' schedules.
"""
self.ensure_one()
local_map = self._get_local_tech_map()
remote_map = self._get_remote_tech_map()
if not local_map or not remote_map:
return
ctx = {'context': {'skip_task_sync': True, 'skip_travel_recalc': True}}
for task in tasks:
sync_id = local_map.get(task.technician_id.id)
if not sync_id:
continue
remote_tech_uid = remote_map.get(sync_id)
if not remote_tech_uid:
continue
# Map additional technicians to remote user IDs
remote_additional_ids = []
for tech in task.additional_technician_ids:
add_sync_id = local_map.get(tech.id)
if add_sync_id:
remote_add_uid = remote_map.get(add_sync_id)
if remote_add_uid:
remote_additional_ids.append(remote_add_uid)
task_data = {
'x_fc_sync_uuid': task.x_fc_sync_uuid,
'x_fc_sync_source': local_instance_id,
'x_fc_sync_remote_id': task.id,
'name': f"[{local_instance_id.upper()}] {task.name}",
'technician_id': remote_tech_uid,
'additional_technician_ids': [(6, 0, remote_additional_ids)],
'task_type': task.task_type,
'status': task.status,
'scheduled_date': str(task.scheduled_date) if task.scheduled_date else False,
'time_start': task.time_start,
'time_end': task.time_end,
'duration_hours': task.duration_hours,
'address_street': task.address_street or '',
'address_street2': task.address_street2 or '',
'address_city': task.address_city or '',
'address_zip': task.address_zip or '',
'address_lat': float(task.address_lat or 0),
'address_lng': float(task.address_lng or 0),
'priority': task.priority or 'normal',
'x_fc_sync_client_name': task.partner_id.name if task.partner_id else '',
'travel_time_minutes': task.travel_time_minutes or 0,
'travel_distance_km': float(task.travel_distance_km or 0),
'travel_origin': task.travel_origin or '',
'completed_latitude': float(task.completed_latitude or 0),
'completed_longitude': float(task.completed_longitude or 0),
'action_latitude': float(task.action_latitude or 0),
'action_longitude': float(task.action_longitude or 0),
}
if task.completion_datetime:
task_data['completion_datetime'] = str(task.completion_datetime)
existing = self._rpc(
'fusion.technician.task', 'search',
[[('x_fc_sync_uuid', '=', task.x_fc_sync_uuid)]],
{'limit': 1})
if operation in ('create', 'write'):
if existing:
self._rpc('fusion.technician.task', 'write',
[existing, task_data], ctx)
elif operation == 'create':
task_data['sale_order_id'] = False
self._rpc('fusion.technician.task', 'create',
[[task_data]], ctx)
elif operation == 'unlink' and existing:
self._rpc('fusion.technician.task', 'write',
[existing, {'status': 'cancelled', 'active': False}], ctx)
@api.model
def _push_shadow_status(self, shadow_tasks):
"""Push local status changes on shadow tasks back to their source instance.
When a tech changes a shadow task status locally, update the original
task on the remote instance and trigger the appropriate client emails
there. Only the parent (originating) instance sends client-facing
emails -- the child instance skips them via x_fc_sync_source guards.
"""
configs = self.sudo().search([('active', '=', True)])
config_by_instance = {c.instance_id: c for c in configs}
ctx = {'context': {'skip_task_sync': True, 'skip_travel_recalc': True}}
for task in shadow_tasks:
config = config_by_instance.get(task.x_fc_sync_source)
if not config or not task.x_fc_sync_remote_id:
continue
try:
update_vals = {'status': task.status}
if task.status == 'completed' and task.completion_datetime:
update_vals['completion_datetime'] = str(task.completion_datetime)
if task.completed_latitude and task.completed_longitude:
update_vals['completed_latitude'] = task.completed_latitude
update_vals['completed_longitude'] = task.completed_longitude
if task.action_latitude and task.action_longitude:
update_vals['action_latitude'] = task.action_latitude
update_vals['action_longitude'] = task.action_longitude
config._rpc(
'fusion.technician.task', 'write',
[[task.x_fc_sync_remote_id], update_vals], ctx)
_logger.info(
"Pushed status '%s' for shadow task %s back to %s (remote id %d)",
task.status, task.name, config.name, task.x_fc_sync_remote_id)
self._trigger_parent_notifications(config, task)
except Exception:
_logger.exception(
"Failed to push status for shadow task %s to %s",
task.name, config.name)
@api.model
def _push_technician_location(self, user_id, latitude, longitude, accuracy=0):
"""Push a technician's location update to all remote instances.
Called when a technician performs a task action (en_route, complete)
so the other instance immediately knows where the tech is, without
waiting for the next pull cron cycle.
"""
configs = self.sudo().search([('active', '=', True)])
if not configs:
return
local_map = configs[0]._get_local_tech_map()
sync_id = local_map.get(user_id)
if not sync_id:
return
for config in configs:
try:
remote_map = config._get_remote_tech_map()
remote_uid = remote_map.get(sync_id)
if not remote_uid:
continue
# Create location record on remote instance
config._rpc(
'fusion.technician.location', 'create',
[[{
'user_id': remote_uid,
'latitude': latitude,
'longitude': longitude,
'accuracy': accuracy,
'source': 'sync',
'sync_instance': configs[0]._get_local_instance_id(),
}]])
except Exception:
_logger.warning(
"Failed to push location for tech %s to %s",
user_id, config.name)
def _trigger_parent_notifications(self, config, task):
"""After pushing a shadow status, trigger appropriate emails and
notifications on the parent instance so the client gets notified
exactly once (from the originating instance only)."""
remote_id = task.x_fc_sync_remote_id
if task.status == 'completed':
for method in ('_notify_scheduler_on_completion',
'_send_task_completion_email'):
try:
config._rpc('fusion.technician.task', method, [[remote_id]])
except Exception:
_logger.warning(
"Could not call %s on remote for %s", method, task.name)
elif task.status == 'en_route':
try:
config._rpc(
'fusion.technician.task',
'_send_task_en_route_email', [[remote_id]])
except Exception:
_logger.warning(
"Could not trigger en-route email on remote for %s",
task.name)
elif task.status == 'cancelled':
try:
config._rpc(
'fusion.technician.task',
'_send_task_cancelled_email', [[remote_id]])
except Exception:
_logger.warning(
"Could not trigger cancel email on remote for %s",
task.name)
# ------------------------------------------------------------------
# PULL: cron-based full reconciliation
# ------------------------------------------------------------------
@api.model
def _cron_pull_remote_tasks(self):
"""Cron job: pull tasks and technician locations from all active remote instances."""
configs = self.sudo().search([('active', '=', True)])
for config in configs:
try:
config._pull_tasks_from_remote()
config._pull_technician_locations()
config.sudo().write({
'last_sync': fields.Datetime.now(),
'last_sync_error': False,
})
except Exception as e:
_logger.exception("Task sync pull from %s failed", config.name)
config.sudo().write({'last_sync_error': str(e)})
def _pull_tasks_from_remote(self):
"""Pull all active tasks for matched technicians from the remote instance.
After syncing, recalculates travel chains for all affected tech+date
combos so route planning accounts for both local and shadow tasks.
"""
self.ensure_one()
local_syncid_to_uid = self._get_local_syncid_to_uid()
if not local_syncid_to_uid:
return
remote_map = self._get_remote_tech_map()
if not remote_map:
return
matched_sync_ids = set(local_syncid_to_uid.keys()) & set(remote_map.keys())
if not matched_sync_ids:
_logger.info("Task sync: no matched technicians between local and %s", self.name)
return
remote_tech_ids = [remote_map[sid] for sid in matched_sync_ids]
remote_syncid_by_uid = {v: k for k, v in remote_map.items()}
cutoff = fields.Date.today() - timedelta(days=7)
remote_tasks = self._rpc(
'fusion.technician.task', 'search_read',
[[
'|',
('technician_id', 'in', remote_tech_ids),
('additional_technician_ids', 'in', remote_tech_ids),
('scheduled_date', '>=', str(cutoff)),
('x_fc_sync_source', '=', False),
]],
{'fields': SYNC_TASK_FIELDS + ['id']})
if remote_tasks is None:
return
Task = self.env['fusion.technician.task'].sudo().with_context(
skip_task_sync=True, skip_travel_recalc=True)
remote_uuids = set()
affected_combos = set()
for rt in remote_tasks:
sync_uuid = rt.get('x_fc_sync_uuid')
if not sync_uuid:
continue
remote_uuids.add(sync_uuid)
remote_tech_raw = rt['technician_id']
remote_uid = remote_tech_raw[0] if isinstance(remote_tech_raw, (list, tuple)) else remote_tech_raw
tech_sync_id = remote_syncid_by_uid.get(remote_uid)
local_uid = local_syncid_to_uid.get(tech_sync_id) if tech_sync_id else None
if not local_uid:
continue
partner_raw = rt.get('partner_id')
client_name = partner_raw[1] if isinstance(partner_raw, (list, tuple)) and len(partner_raw) > 1 else ''
client_phone = rt.get('partner_phone', '') or ''
state_raw = rt.get('address_state_id')
state_name = ''
if isinstance(state_raw, (list, tuple)) and len(state_raw) > 1:
state_name = state_raw[1]
# Map additional technicians from remote to local
local_additional_ids = []
remote_add_raw = rt.get('additional_technician_ids', [])
if remote_add_raw and isinstance(remote_add_raw, list):
for add_uid in remote_add_raw:
add_sync_id = remote_syncid_by_uid.get(add_uid)
if add_sync_id:
local_add_uid = local_syncid_to_uid.get(add_sync_id)
if local_add_uid:
local_additional_ids.append(local_add_uid)
sched_date = rt.get('scheduled_date')
vals = {
'x_fc_sync_uuid': sync_uuid,
'x_fc_sync_source': self.instance_id,
'x_fc_sync_remote_id': rt['id'],
'name': f"[{self.instance_id.upper()}] {rt.get('name', '')}",
'technician_id': local_uid,
'additional_technician_ids': [(6, 0, local_additional_ids)],
'task_type': rt.get('task_type', 'delivery'),
'status': rt.get('status', 'scheduled'),
'scheduled_date': sched_date,
'time_start': rt.get('time_start', 9.0),
'time_end': rt.get('time_end', 10.0),
'duration_hours': rt.get('duration_hours', 1.0),
'address_street': rt.get('address_street', ''),
'address_street2': rt.get('address_street2', ''),
'address_city': rt.get('address_city', ''),
'address_zip': rt.get('address_zip', ''),
'address_buzz_code': rt.get('address_buzz_code', ''),
'address_lat': rt.get('address_lat', 0),
'address_lng': rt.get('address_lng', 0),
'priority': rt.get('priority', 'normal'),
'pod_required': rt.get('pod_required', False),
'description': rt.get('description', ''),
'x_fc_sync_client_name': client_name,
'x_fc_sync_client_phone': client_phone,
'travel_time_minutes': rt.get('travel_time_minutes', 0),
'travel_distance_km': rt.get('travel_distance_km', 0),
'travel_origin': rt.get('travel_origin', ''),
'completed_latitude': rt.get('completed_latitude', 0),
'completed_longitude': rt.get('completed_longitude', 0),
'action_latitude': rt.get('action_latitude', 0),
'action_longitude': rt.get('action_longitude', 0),
}
if rt.get('completion_datetime'):
vals['completion_datetime'] = rt['completion_datetime']
if state_name:
state_rec = self.env['res.country.state'].sudo().search(
[('name', '=', state_name)], limit=1)
if state_rec:
vals['address_state_id'] = state_rec.id
existing = Task.search([('x_fc_sync_uuid', '=', sync_uuid)], limit=1)
if existing:
if existing.status in TERMINAL_STATUSES:
vals.pop('status', None)
existing.write(vals)
else:
vals['sale_order_id'] = False
Task.create([vals])
if sched_date:
affected_combos.add((local_uid, sched_date))
for add_uid in local_additional_ids:
affected_combos.add((add_uid, sched_date))
stale_shadows = Task.search([
('x_fc_sync_source', '=', self.instance_id),
('x_fc_sync_uuid', 'not in', list(remote_uuids)),
('scheduled_date', '>=', str(cutoff)),
('active', '=', True),
])
if stale_shadows:
for st in stale_shadows:
if st.scheduled_date and st.technician_id:
affected_combos.add((st.technician_id.id, st.scheduled_date))
for tech in st.additional_technician_ids:
if st.scheduled_date:
affected_combos.add((tech.id, st.scheduled_date))
stale_shadows.write({'active': False, 'status': 'cancelled'})
_logger.info("Deactivated %d stale shadow tasks from %s",
len(stale_shadows), self.instance_id)
if affected_combos:
today = fields.Date.today()
today_str = str(today)
future_combos = set()
for tid, d in affected_combos:
if not d:
continue
d_str = str(d) if not isinstance(d, str) else d
if d_str >= today_str:
future_combos.add((tid, d_str))
if future_combos:
TaskModel = self.env['fusion.technician.task'].sudo()
try:
ungeocode = TaskModel.search([
('x_fc_sync_source', '=', self.instance_id),
('active', '=', True),
('scheduled_date', '>=', today_str),
('status', 'not in', ['cancelled']),
'|',
('address_lat', '=', 0), ('address_lat', '=', False),
])
geocoded = 0
for shadow in ungeocode:
if shadow.address_display:
if shadow.with_context(skip_travel_recalc=True)._geocode_address():
geocoded += 1
if geocoded:
_logger.info("Geocoded %d shadow tasks from %s",
geocoded, self.name)
except Exception:
_logger.exception(
"Shadow task geocoding after sync from %s failed", self.name)
try:
TaskModel._recalculate_combos_travel(future_combos)
_logger.info(
"Recalculated travel for %d tech+date combos after sync from %s",
len(future_combos), self.name)
except Exception:
_logger.exception(
"Travel recalculation after sync from %s failed", self.name)
# ------------------------------------------------------------------
# PULL: technician locations from remote instance
# ------------------------------------------------------------------
def _pull_technician_locations(self):
"""Pull latest GPS locations for matched technicians from the remote instance.
Creates local location records with source='sync' so the map view
shows technician positions from both instances. Only keeps the single
most recent synced location per technician (replaces older synced
records to avoid clutter).
"""
self.ensure_one()
local_syncid_to_uid = self._get_local_syncid_to_uid()
if not local_syncid_to_uid:
return
remote_map = self._get_remote_tech_map()
if not remote_map:
return
matched_sync_ids = set(local_syncid_to_uid.keys()) & set(remote_map.keys())
if not matched_sync_ids:
return
remote_tech_ids = [remote_map[sid] for sid in matched_sync_ids]
remote_syncid_by_uid = {v: k for k, v in remote_map.items()}
remote_locations = self._rpc(
'fusion.technician.location', 'search_read',
[[
('user_id', 'in', remote_tech_ids),
('logged_at', '>', str(fields.Datetime.subtract(
fields.Datetime.now(), hours=24))),
('source', '!=', 'sync'),
]],
{
'fields': ['user_id', 'latitude', 'longitude',
'accuracy', 'logged_at'],
'order': 'logged_at desc',
})
if not remote_locations:
return
Location = self.env['fusion.technician.location'].sudo()
seen_techs = set()
synced_count = 0
for rloc in remote_locations:
remote_uid_raw = rloc['user_id']
remote_uid = (remote_uid_raw[0]
if isinstance(remote_uid_raw, (list, tuple))
else remote_uid_raw)
if remote_uid in seen_techs:
continue
seen_techs.add(remote_uid)
sync_id = remote_syncid_by_uid.get(remote_uid)
local_uid = local_syncid_to_uid.get(sync_id) if sync_id else None
if not local_uid:
continue
lat = rloc.get('latitude', 0)
lng = rloc.get('longitude', 0)
if not lat or not lng:
continue
old_synced = Location.search([
('user_id', '=', local_uid),
('source', '=', 'sync'),
('sync_instance', '=', self.instance_id),
])
if old_synced:
old_synced.unlink()
Location.create({
'user_id': local_uid,
'latitude': lat,
'longitude': lng,
'accuracy': rloc.get('accuracy', 0),
'logged_at': rloc.get('logged_at', fields.Datetime.now()),
'source': 'sync',
'sync_instance': self.instance_id,
})
synced_count += 1
if synced_count:
_logger.info("Synced %d technician location(s) from %s",
synced_count, self.name)
# ------------------------------------------------------------------
# CLEANUP
# ------------------------------------------------------------------
@api.model
def _cron_cleanup_old_shadows(self):
"""Remove shadow tasks older than 30 days (completed/cancelled)."""
cutoff = fields.Date.today() - timedelta(days=30)
old_shadows = self.env['fusion.technician.task'].sudo().search([
('x_fc_sync_source', '!=', False),
('scheduled_date', '<', str(cutoff)),
('status', 'in', ['completed', 'cancelled']),
])
if old_shadows:
count = len(old_shadows)
old_shadows.unlink()
_logger.info("Cleaned up %d old shadow tasks", count)
# ------------------------------------------------------------------
# Manual trigger
# ------------------------------------------------------------------
def action_sync_now(self):
"""Manually trigger a full sync for this config."""
self.ensure_one()
self._pull_tasks_from_remote()
self._pull_technician_locations()
self.sudo().write({
'last_sync': fields.Datetime.now(),
'last_sync_error': False,
})
shadow_count = self.env['fusion.technician.task'].sudo().search_count([
('x_fc_sync_source', '=', self.instance_id),
])
loc_count = self.env['fusion.technician.location'].sudo().search_count([
('source', '=', 'sync'),
('sync_instance', '=', self.instance_id),
])
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Sync Complete',
'message': (f'Synced from {self.name}. '
f'{shadow_count} shadow task(s), '
f'{loc_count} technician location(s) visible.'),
'type': 'success',
'sticky': False,
},
}

View File

@@ -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,
)

File diff suppressed because it is too large Load Diff

View File

@@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_fusion_technician_task_user fusion.technician.task.user model_fusion_technician_task sales_team.group_sale_salesman 1 1 1 0
3 access_fusion_technician_task_manager fusion.technician.task.manager model_fusion_technician_task sales_team.group_sale_manager 1 1 1 1
4 access_fusion_technician_task_technician fusion.technician.task.technician model_fusion_technician_task fusion_tasks.group_field_technician 1 1 0 0
5 access_fusion_technician_task_portal fusion.technician.task.portal model_fusion_technician_task base.group_portal 1 0 0 0
6 access_fusion_push_subscription_user fusion.push.subscription.user model_fusion_push_subscription base.group_user 1 1 1 0
7 access_fusion_push_subscription_portal fusion.push.subscription.portal model_fusion_push_subscription base.group_portal 1 1 1 0
8 access_fusion_technician_location_manager fusion.technician.location.manager model_fusion_technician_location sales_team.group_sale_manager 1 1 1 1
9 access_fusion_technician_location_user fusion.technician.location.user model_fusion_technician_location sales_team.group_sale_salesman 1 0 0 0
10 access_fusion_technician_location_portal fusion.technician.location.portal model_fusion_technician_location base.group_portal 0 0 1 0
11 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
12 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

View File

@@ -0,0 +1,103 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ================================================================== -->
<!-- MODULE CATEGORY -->
<!-- ================================================================== -->
<record id="module_category_fusion_tasks" model="ir.module.category">
<field name="name">Fusion Tasks</field>
<field name="sequence">46</field>
</record>
<!-- ================================================================== -->
<!-- FUSION TASKS PRIVILEGE (Odoo 19 pattern) -->
<!-- ================================================================== -->
<record id="res_groups_privilege_fusion_tasks" model="res.groups.privilege">
<field name="name">Fusion Tasks</field>
<field name="sequence">46</field>
<field name="category_id" ref="module_category_fusion_tasks"/>
</record>
<!-- ================================================================== -->
<!-- FIELD TECHNICIAN GROUP -->
<!-- Standalone group safe for both portal and internal users. -->
<!-- Do NOT imply base.group_user — that chain conflicts with portal -->
<!-- users (share=True). -->
<!-- ================================================================== -->
<record id="group_field_technician" model="res.groups">
<field name="name">Field Technician</field>
<field name="privilege_id" ref="res_groups_privilege_fusion_tasks"/>
</record>
<!-- ================================================================== -->
<!-- TECHNICIAN TASK RECORD RULES -->
<!-- ================================================================== -->
<!-- Managers: full access to all tasks -->
<record id="rule_technician_task_manager" model="ir.rule">
<field name="name">Technician Task: Manager Full Access</field>
<field name="model_id" ref="model_fusion_technician_task"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('sales_team.group_sale_manager'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="True"/>
</record>
<!-- Sales users: read/write all tasks, create tasks -->
<record id="rule_technician_task_sales_user" model="ir.rule">
<field name="name">Technician Task: Sales User Access</field>
<field name="model_id" ref="model_fusion_technician_task"/>
<field name="domain_force">[(1, '=', 1)]</field>
<field name="groups" eval="[(4, ref('sales_team.group_sale_salesman'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="True"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- Field Technicians (internal): own tasks only -->
<record id="rule_technician_task_technician" model="ir.rule">
<field name="name">Technician Task: Technician Own Tasks</field>
<field name="model_id" ref="model_fusion_technician_task"/>
<field name="domain_force">[('technician_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('group_field_technician'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="True"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- Portal technicians: own tasks only, read + limited write -->
<record id="rule_technician_task_portal" model="ir.rule">
<field name="name">Technician Task: Portal Technician Access</field>
<field name="model_id" ref="model_fusion_technician_task"/>
<field name="domain_force">[('technician_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
<field name="perm_read" eval="True"/>
<field name="perm_write" eval="False"/>
<field name="perm_create" eval="False"/>
<field name="perm_unlink" eval="False"/>
</record>
<!-- ================================================================== -->
<!-- PUSH SUBSCRIPTION RECORD RULES -->
<!-- ================================================================== -->
<!-- Users: own subscriptions only -->
<record id="rule_push_subscription_user" model="ir.rule">
<field name="name">Push Subscription: Own Only</field>
<field name="model_id" ref="model_fusion_push_subscription"/>
<field name="domain_force">[('user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('base.group_user'))]"/>
</record>
<!-- Portal: own subscriptions only -->
<record id="rule_push_subscription_portal" model="ir.rule">
<field name="name">Push Subscription: Portal Own Only</field>
<field name="model_id" ref="model_fusion_push_subscription"/>
<field name="domain_force">[('user_id', '=', user.id)]</field>
<field name="groups" eval="[(4, ref('base.group_portal'))]"/>
</record>
</odoo>

View File

@@ -0,0 +1,480 @@
// =====================================================================
// Fusion Task Map View - Sidebar + Google Maps
// Theme-aware: uses Odoo/Bootstrap variables for dark mode support
// =====================================================================
$sidebar-width: 340px;
$transition-speed: .25s;
.o_fusion_task_map_view {
height: 100%;
.o_content {
height: 100%;
display: flex;
flex-direction: column;
}
}
// ── Main wrapper: sidebar + map side by side ────────────────────────
.fc_map_wrapper {
display: flex;
flex-direction: row;
height: 100%;
min-height: 0;
overflow: hidden;
position: relative;
}
// ── Sidebar ─────────────────────────────────────────────────────────
.fc_sidebar {
width: $sidebar-width;
min-width: $sidebar-width;
max-width: $sidebar-width;
background: var(--o-view-background-color, $o-view-background-color);
border-right: 1px solid $border-color;
display: flex;
flex-direction: column;
transition: width $transition-speed ease, min-width $transition-speed ease,
max-width $transition-speed ease, opacity $transition-speed ease;
overflow: hidden;
&--collapsed {
width: 0;
min-width: 0;
max-width: 0;
opacity: 0;
border-right: none;
}
}
.fc_sidebar_header {
padding: 14px 16px 12px;
border-bottom: 1px solid $border-color;
flex-shrink: 0;
h6 {
font-size: 14px;
color: $headings-color;
}
}
.fc_sidebar_body {
flex: 1 1 auto;
overflow-y: auto;
overflow-x: hidden;
padding: 6px 0;
&::-webkit-scrollbar { width: 5px; }
&::-webkit-scrollbar-track { background: transparent; }
&::-webkit-scrollbar-thumb { background: $border-color; border-radius: 4px; }
}
.fc_sidebar_footer {
padding: 10px 16px;
border-top: 1px solid $border-color;
flex-shrink: 0;
}
.fc_sidebar_empty {
text-align: center;
padding: 40px 20px;
color: $text-muted;
}
// ── Day filter chips ────────────────────────────────────────────────
.fc_day_filters {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.fc_day_chip {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 2px 10px;
font-size: 11px;
font-weight: 600;
border: 1px solid $border-color;
border-radius: 12px;
background: transparent;
color: $text-muted;
cursor: pointer;
transition: all .15s;
line-height: 18px;
&:hover {
border-color: rgba($primary, .3);
color: $body-color;
}
&--active {
color: #fff !important;
border-color: transparent !important;
}
&--all {
color: $body-color;
font-weight: 500;
&:hover { background: rgba($primary, .1); }
}
}
.fc_day_chip_count {
font-size: 10px;
opacity: .8;
}
.fc_group_hidden_tag {
font-size: 9px;
text-transform: uppercase;
letter-spacing: .5px;
color: $text-muted;
background: rgba($secondary, .1);
padding: 0 5px;
border-radius: 3px;
margin-left: 4px;
font-weight: 500;
}
// ── Technician filter chips ─────────────────────────────────────────
.fc_tech_filters {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.fc_tech_chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 3px 10px 3px 4px;
font-size: 11px;
font-weight: 600;
border: 1px solid $border-color;
border-radius: 14px;
background: transparent;
color: $text-muted;
cursor: pointer;
transition: all .15s;
line-height: 18px;
max-width: 100%;
overflow: hidden;
&:hover {
border-color: rgba($primary, .35);
color: $body-color;
background: rgba($primary, .06);
}
&--active {
background: $primary !important;
color: #fff !important;
border-color: $primary !important;
.fc_tech_chip_avatar {
background: rgba(#fff, .25);
color: #fff;
}
}
&--all {
padding: 3px 10px;
color: $body-color;
font-weight: 500;
&:hover { background: rgba($primary, .1); }
}
}
.fc_tech_chip_avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
background: rgba($secondary, .15);
color: $body-color;
font-size: 9px;
font-weight: 700;
flex-shrink: 0;
}
.fc_tech_chip_name {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
// Collapsed toggle button (floating)
.fc_sidebar_toggle_btn {
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
z-index: 15;
background: var(--o-view-background-color, $o-view-background-color);
border: 1px solid $border-color;
border-left: none;
border-radius: 0 8px 8px 0;
padding: 12px 6px;
cursor: pointer;
box-shadow: 2px 0 6px rgba(0,0,0,.08);
color: $text-muted;
transition: background .15s;
&:hover {
background: $o-gray-100;
color: $body-color;
}
}
// ── Group headers ───────────────────────────────────────────────────
.fc_group_header {
display: flex;
align-items: center;
padding: 8px 16px;
cursor: pointer;
user-select: none;
font-weight: 600;
font-size: 12px;
color: $text-muted;
text-transform: uppercase;
letter-spacing: .5px;
background: rgba($secondary, .08);
border-bottom: 1px solid $border-color;
transition: background .15s;
&:hover {
background: rgba($secondary, .15);
}
.fa-caret-right,
.fa-caret-down {
width: 14px;
text-align: center;
font-size: 13px;
}
}
.fc_group_label {
flex: 1;
}
.fc_group_badge {
background: rgba($secondary, .2);
color: $body-color;
font-size: 10px;
font-weight: 700;
padding: 1px 7px;
border-radius: 10px;
min-width: 20px;
text-align: center;
}
// ── Task cards ──────────────────────────────────────────────────────
.fc_group_tasks {
padding: 4px 0;
}
.fc_task_card {
margin: 3px 10px;
padding: 10px 12px;
background: var(--o-view-background-color, $o-view-background-color);
border: 1px solid $border-color;
border-radius: 8px;
cursor: pointer;
transition: all .15s;
position: relative;
&:hover {
background: rgba($primary, .05);
border-color: rgba($primary, .2);
box-shadow: 0 1px 4px rgba(0,0,0,.06);
}
&--active {
background: rgba($primary, .1) !important;
border-color: rgba($primary, .35) !important;
box-shadow: 0 0 0 2px rgba($primary, .15);
}
}
.fc_task_card_top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 6px;
}
.fc_task_num {
display: inline-block;
color: #fff;
font-size: 11px;
font-weight: 700;
padding: 1px 8px;
border-radius: 4px;
line-height: 18px;
}
.fc_task_status {
font-size: 11px;
font-weight: 600;
}
.fc_task_client {
font-size: 13px;
font-weight: 600;
color: $headings-color;
margin-bottom: 4px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.fc_task_meta {
display: flex;
gap: 12px;
font-size: 11px;
color: $body-color;
margin-bottom: 3px;
.fa { opacity: .5; }
}
.fc_task_detail {
font-size: 11px;
color: $body-color;
margin-bottom: 2px;
.fa { opacity: .5; }
}
.fc_task_address {
font-size: 10px;
color: $text-muted;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
margin-top: 2px;
}
.fc_task_bottom_row {
display: flex;
align-items: center;
gap: 6px;
margin-top: 4px;
flex-wrap: wrap;
}
.fc_task_travel {
display: inline-flex;
align-items: center;
font-size: 10px;
color: $body-color;
background: rgba($secondary, .1);
padding: 1px 8px;
border-radius: 4px;
.fa { opacity: .5; }
}
.fc_task_source {
display: inline-flex;
align-items: center;
font-size: 10px;
color: #fff;
font-weight: 600;
padding: 1px 8px;
border-radius: 4px;
.fa { opacity: .8; }
}
.fc_task_edit_btn {
display: inline-flex;
align-items: center;
font-size: 10px;
font-weight: 600;
color: var(--btn-primary-color, #fff);
background: var(--btn-primary-bg, #{$primary});
padding: 2px 10px;
border-radius: 4px;
cursor: pointer;
margin-left: auto;
transition: all .15s;
&:hover {
opacity: .85;
filter: brightness(1.15);
}
}
// ── Map area ────────────────────────────────────────────────────────
.fc_map_area {
flex: 1 1 auto;
display: flex;
flex-direction: column;
min-width: 0;
position: relative;
}
.fc_map_legend_bar {
flex: 0 0 auto;
font-size: 12px;
min-height: 40px;
}
.fc_map_container {
flex: 1 1 auto;
position: relative;
min-height: 400px;
}
// ── Google Maps InfoWindow override ──────────────────────────────────
.gm-style-iw-d {
overflow: auto !important;
}
.gm-style .gm-style-iw-c {
padding: 0 !important;
border-radius: 10px !important;
overflow: hidden !important;
box-shadow: 0 4px 20px rgba(0,0,0,.15) !important;
}
.gm-style .gm-style-iw-tc {
display: none !important;
}
.gm-style .gm-ui-hover-effect {
display: none !important;
}
// ── Responsive ──────────────────────────────────────────────────────
@media (max-width: 768px) {
.fc_map_wrapper {
flex-direction: column;
}
.fc_sidebar {
width: 100% !important;
min-width: 100% !important;
max-width: 100% !important;
max-height: 40vh;
border-right: none;
border-bottom: 1px solid $border-color;
&--collapsed {
max-height: 0;
opacity: 0;
}
}
.fc_sidebar_toggle_btn {
top: auto;
bottom: 10px;
left: 50%;
transform: translateX(-50%);
border-radius: 8px;
border: 1px solid $border-color;
padding: 8px 16px;
}
.fc_map_area {
flex: 1;
min-height: 300px;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,250 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="fusion_tasks.FusionTaskMapView">
<div class="o_fusion_task_map_view">
<Layout display="display">
<t t-set-slot="control-panel-additional-actions">
<CogMenu/>
</t>
<t t-set-slot="layout-buttons">
<t t-call="{{ props.buttonTemplate }}"/>
</t>
<t t-set-slot="layout-actions">
<SearchBar toggler="searchBarToggler"/>
</t>
<t t-set-slot="control-panel-navigation-additional">
<t t-component="searchBarToggler.component" t-props="searchBarToggler.props"/>
</t>
<div class="fc_map_wrapper">
<!-- ========== SIDEBAR ========== -->
<div t-att-class="'fc_sidebar' + (state.sidebarOpen ? '' : ' fc_sidebar--collapsed')">
<!-- Sidebar header -->
<div class="fc_sidebar_header">
<div class="d-flex align-items-center justify-content-between">
<h6 class="mb-0 fw-bold">
<i class="fa fa-list-ul me-2"/>Deliveries
<span class="badge text-bg-primary ms-1" t-esc="state.taskCount"/>
</h6>
<button class="btn btn-sm btn-link text-muted p-0" t-on-click="toggleSidebar"
title="Toggle sidebar">
<i t-att-class="'fa ' + (state.sidebarOpen ? 'fa-chevron-left' : 'fa-chevron-right')"/>
</button>
</div>
<!-- New task button -->
<button class="btn btn-primary btn-sm w-100 mt-2" t-on-click="createNewTask">
<i class="fa fa-plus me-1"/>New Delivery Task
</button>
<!-- Day filter chips -->
<div class="fc_day_filters mt-2">
<t t-foreach="state.groups" t-as="group" t-key="group.key + '_filter'">
<button t-att-class="'fc_day_chip' + (isGroupVisible(group.key) ? ' fc_day_chip--active' : '')"
t-att-style="isGroupVisible(group.key) ? 'background:' + group.dayColor + ';color:#fff;border-color:' + group.dayColor : ''"
t-on-click="() => this.toggleDayFilter(group.key)">
<t t-esc="group.label"/>
<span class="fc_day_chip_count" t-esc="group.count"/>
</button>
</t>
<button class="fc_day_chip fc_day_chip--all" t-on-click="showAllDays"
title="Show all">All</button>
</div>
<!-- Technician filter -->
<t t-if="state.allTechnicians.length > 1">
<div class="fc_tech_filters mt-2">
<t t-foreach="state.allTechnicians" t-as="tech" t-key="tech.id">
<button t-att-class="'fc_tech_chip' + (isTechVisible(tech.id) ? ' fc_tech_chip--active' : '')"
t-on-click="() => this.toggleTechFilter(tech.id)"
t-att-title="tech.name">
<span class="fc_tech_chip_avatar" t-esc="tech.initials"/>
<span class="fc_tech_chip_name" t-esc="tech.name"/>
</button>
</t>
<button class="fc_tech_chip fc_tech_chip--all" t-on-click="showAllTechs"
title="Show all technicians">All</button>
</div>
</t>
</div>
<!-- Sidebar body: grouped task list -->
<div class="fc_sidebar_body">
<t t-foreach="state.groups" t-as="group" t-key="group.key">
<!-- Group header (collapsible) with day color -->
<div class="fc_group_header" t-on-click="() => this.toggleGroup(group.key)">
<i t-att-class="'fa me-1 ' + (isGroupCollapsed(group.key) ? 'fa-caret-right' : 'fa-caret-down')"/>
<i class="fa fa-circle me-1" style="font-size:8px;"
t-att-style="'color:' + group.dayColor"/>
<span class="fc_group_label" t-esc="group.label"/>
<span t-if="!isGroupVisible(group.key)" class="fc_group_hidden_tag">hidden</span>
<span class="fc_group_badge" t-esc="group.count"/>
</div>
<!-- Group tasks -->
<div t-if="!isGroupCollapsed(group.key)" class="fc_group_tasks">
<t t-foreach="group.tasks" t-as="task" t-key="task.id">
<div t-att-class="'fc_task_card' + (state.activeTaskId === task.id ? ' fc_task_card--active' : '')"
t-on-click="() => this.focusTask(task.id)">
<!-- Card top row: number + status -->
<div class="fc_task_card_top">
<span class="fc_task_num" t-att-style="'background:' + task._dayColor">
<t t-esc="'#' + task._scheduleNum"/>
</span>
<span class="fc_task_status" t-att-style="'color:' + task._statusColor">
<i t-att-class="'fa ' + task._statusIcon" style="margin-right:3px;"/>
<t t-esc="task._statusLabel"/>
</span>
</div>
<!-- Client name -->
<div class="fc_task_client" t-esc="task._clientName"/>
<!-- Type + time -->
<div class="fc_task_meta">
<span><i class="fa fa-tag me-1"/><t t-esc="task._typeLbl"/></span>
<span><i class="fa fa-clock-o me-1"/><t t-esc="task._timeRange"/></span>
</div>
<!-- Technician + address -->
<div class="fc_task_detail">
<span><i class="fa fa-user me-1"/><t t-esc="task._techName"/></span>
</div>
<div t-if="task.address_display" class="fc_task_address">
<i class="fa fa-map-marker me-1"/>
<t t-esc="task.address_display"/>
</div>
<!-- Travel + source -->
<div class="fc_task_bottom_row">
<span t-if="task.travel_time_minutes" class="fc_task_travel">
<i class="fa fa-car me-1"/>
<t t-esc="task.travel_time_minutes"/> min travel
</span>
<span t-if="task._sourceLabel" class="fc_task_source"
t-att-style="'background:' + task._sourceColor">
<i class="fa fa-building-o me-1"/>
<t t-esc="task._sourceLabel"/>
</span>
<span class="fc_task_edit_btn"
t-on-click.stop="() => this.openTask(task.id)"
title="Edit task">
<i class="fa fa-pencil me-1"/>Edit
</span>
</div>
</div>
</t>
</div>
</t>
<!-- Empty state -->
<div t-if="state.groups.length === 0 and !state.loading" class="fc_sidebar_empty">
<i class="fa fa-inbox fa-2x text-muted d-block mb-2"/>
<span class="text-muted">No tasks found</span>
</div>
</div>
<!-- Sidebar footer: technician count -->
<div class="fc_sidebar_footer">
<div class="d-flex align-items-center gap-2">
<svg width="14" height="14" viewBox="0 0 48 48">
<rect x="2" y="2" width="44" height="44" rx="12" ry="12" fill="#1d4ed8" stroke="#fff" stroke-width="3"/>
<text x="24" y="30" text-anchor="middle" fill="#fff" font-size="17" font-family="Arial,sans-serif" font-weight="bold">T</text>
</svg>
<small class="text-muted">
<t t-esc="state.techCount"/> technician(s) online
</small>
</div>
</div>
</div>
<!-- Collapsed sidebar toggle -->
<button t-if="!state.sidebarOpen"
class="fc_sidebar_toggle_btn" t-on-click="toggleSidebar"
title="Open sidebar">
<i class="fa fa-chevron-right"/>
</button>
<!-- ========== MAP AREA ========== -->
<div class="fc_map_area">
<!-- Legend bar -->
<div class="fc_map_legend_bar d-flex align-items-center gap-3 px-3 py-2 border-bottom bg-view flex-wrap">
<button class="btn btn-sm d-flex align-items-center gap-1"
t-att-class="state.showTasks ? 'btn-primary' : 'btn-outline-secondary'"
t-on-click="toggleTasks">
<i class="fa fa-map-marker"/>Tasks <t t-esc="state.taskCount"/>
</button>
<button class="btn btn-sm d-flex align-items-center gap-1"
t-att-class="state.showTechnicians ? 'btn-primary' : 'btn-outline-secondary'"
t-on-click="toggleTechnicians">
<i class="fa fa-user"/>Techs <t t-esc="state.techCount"/>
</button>
<span class="border-start mx-1" style="height:20px;"/>
<span class="text-muted fw-bold" style="font-size:11px;">Pins:</span>
<span style="font-size:11px;"><i class="fa fa-map-marker me-1" style="color:#f59e0b;"/>Pending</span>
<span style="font-size:11px;"><i class="fa fa-map-marker me-1" style="color:#ef4444;"/>Today</span>
<span style="font-size:11px;"><i class="fa fa-map-marker me-1" style="color:#3b82f6;"/>Tomorrow</span>
<span style="font-size:11px;"><i class="fa fa-map-marker me-1" style="color:#10b981;"/>This Week</span>
<span style="font-size:11px;"><i class="fa fa-map-marker me-1" style="color:#a855f7;"/>Upcoming</span>
<span style="font-size:11px;"><i class="fa fa-map-marker me-1" style="color:#9ca3af;"/>Yesterday</span>
<span class="flex-grow-1"/>
<button class="btn btn-sm d-flex align-items-center gap-1"
t-att-class="state.showRoute ? 'btn-info' : 'btn-outline-secondary'"
t-on-click="toggleRoute" title="Toggle route animation">
<i class="fa fa-road"/>Route
</button>
<button class="btn btn-sm d-flex align-items-center gap-1"
t-att-class="state.showTraffic ? 'btn-warning' : 'btn-outline-secondary'"
t-on-click="toggleTraffic" title="Toggle traffic layer">
<i class="fa fa-car"/>Traffic
</button>
<button class="btn btn-outline-secondary btn-sm" t-on-click="onRefresh" title="Refresh">
<i class="fa fa-refresh" t-att-class="{'fa-spin': state.loading}"/>
</button>
</div>
<!-- Map container -->
<div class="fc_map_container">
<div t-ref="mapContainer" style="position:absolute;top:0;left:0;right:0;bottom:0;"/>
<!-- Loading -->
<div t-if="state.loading"
class="position-absolute top-0 start-0 w-100 h-100 d-flex justify-content-center align-items-center"
style="z-index:10;background:rgba(255,255,255,.92);">
<div class="text-center">
<i class="fa fa-spinner fa-spin fa-3x text-primary mb-3 d-block"/>
<span class="text-muted">Loading Google Maps...</span>
</div>
</div>
<!-- Error -->
<div t-if="state.error"
class="position-absolute top-0 start-0 w-100 h-100 d-flex justify-content-center align-items-center"
style="z-index:10;background:rgba(255,255,255,.92);">
<div class="alert alert-danger m-4" role="alert">
<i class="fa fa-exclamation-triangle me-2"/><t t-esc="state.error"/>
</div>
</div>
<!-- Empty -->
<div t-if="!state.loading and !state.error and state.taskCount === 0 and state.techCount === 0"
class="position-absolute top-50 start-50 translate-middle text-center" style="z-index:5;">
<div class="bg-white rounded-3 shadow p-4">
<i class="fa fa-map-marker fa-3x text-muted mb-3 d-block"/>
<h5>No locations to show</h5>
<p class="text-muted mb-0">Try adjusting the filters or date range.</p>
</div>
</div>
</div>
</div>
</div>
</Layout>
</div>
</t>
<t t-name="fusion_tasks.FusionTaskMapView.Buttons"/>
</templates>

View File

@@ -0,0 +1,156 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Add Fusion Tasks Settings as a new app block -->
<record id="res_config_settings_view_form_fusion_tasks" model="ir.ui.view">
<field name="name">res.config.settings.view.form.fusion.tasks</field>
<field name="model">res.config.settings</field>
<field name="inherit_id" ref="base.res_config_settings_view_form"/>
<field name="arch" type="xml">
<xpath expr="//form" position="inside">
<app data-string="Fusion Tasks" string="Fusion Tasks" name="fusion_tasks"
groups="fusion_tasks.group_field_technician">
<h2>Technician Management</h2>
<div class="row mt-4 o_settings_container">
<!-- Google Maps API Key -->
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<span class="o_form_label">Google Maps API</span>
<div class="text-muted">
API key for Google Maps Places autocomplete in address fields and Distance Matrix travel calculations.
</div>
<div class="mt-2">
<field name="fc_google_maps_api_key" placeholder="Enter your Google Maps API Key" password="True"/>
</div>
<div class="alert alert-info mt-2" role="alert">
<i class="fa fa-info-circle"/> Enable the "Places API" and "Distance Matrix API" in your Google Cloud Console.
</div>
</div>
</div>
<!-- Google Business Review URL -->
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<span class="o_form_label">Google Business Review URL</span>
<div class="text-muted">
Link to your Google Business Profile review page.
Sent to clients after service completion (when "Request Google Review" is enabled on the task).
</div>
<div class="mt-2">
<field name="fc_google_review_url" placeholder="https://g.page/r/your-business/review"/>
</div>
</div>
</div>
<!-- Store Hours -->
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<span class="o_form_label">Store / Scheduling Hours</span>
<div class="text-muted">
Operating hours for technician task scheduling. Tasks can only be booked
within these hours. Calendar view is also restricted to this range.
</div>
<div class="mt-2 d-flex align-items-center gap-2">
<field name="fc_store_open_hour" widget="float_time" style="max-width: 100px;"/>
<span>to</span>
<field name="fc_store_close_hour" widget="float_time" style="max-width: 100px;"/>
</div>
</div>
</div>
<!-- Distance Matrix Toggle -->
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="fc_google_distance_matrix_enabled"/>
</div>
<div class="o_setting_right_pane">
<label for="fc_google_distance_matrix_enabled"/>
<div class="text-muted">
Calculate travel time between technician tasks using Google Distance Matrix API.
Requires Google Maps API key above with Distance Matrix API enabled.
</div>
</div>
</div>
<!-- Start Address (Company Default / Fallback) -->
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<span class="o_form_label">Default HQ / Fallback Address</span>
<div class="text-muted">
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.
</div>
<div class="mt-2">
<field name="fc_technician_start_address" placeholder="e.g. 123 Main St, Brampton, ON"/>
</div>
</div>
</div>
<!-- Location History Retention -->
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<span class="o_form_label">Location History Retention</span>
<div class="text-muted">
How many days to keep technician GPS location history before automatic cleanup.
</div>
<div class="mt-2 d-flex align-items-center gap-2">
<field name="fc_location_retention_days" placeholder="30" style="max-width: 80px;"/>
<span class="text-muted">days</span>
</div>
<div class="text-muted small mt-1">
Leave empty = 30 days. Enter 0 = delete at end of each day. 1+ = keep that many days.
</div>
</div>
</div>
</div>
<h2>Push Notifications</h2>
<div class="row mt-4 o_settings_container">
<!-- Push Enable -->
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_left_pane">
<field name="fc_push_enabled"/>
</div>
<div class="o_setting_right_pane">
<label for="fc_push_enabled"/>
<div class="text-muted">
Send web push notifications to technicians about upcoming tasks.
Requires VAPID keys (auto-generated on first save if empty).
</div>
</div>
</div>
<!-- Advance Minutes -->
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<span class="o_form_label">Notification Advance Time</span>
<div class="text-muted">
Send push notification this many minutes before a scheduled task.
</div>
<div class="mt-2">
<field name="fc_push_advance_minutes"/> minutes
</div>
</div>
</div>
<!-- VAPID Public Key -->
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<span class="o_form_label">VAPID Public Key</span>
<div class="mt-2">
<field name="fc_vapid_public_key" placeholder="Auto-generated"/>
</div>
</div>
</div>
<!-- VAPID Private Key -->
<div class="col-12 col-lg-6 o_setting_box">
<div class="o_setting_right_pane">
<span class="o_form_label">VAPID Private Key</span>
<div class="mt-2">
<field name="fc_vapid_private_key" password="True" placeholder="Auto-generated"/>
</div>
</div>
</div>
</div>
</app>
</xpath>
</field>
</record>
</odoo>

View File

@@ -0,0 +1,80 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ================================================================== -->
<!-- SYNC CONFIG - FORM VIEW -->
<!-- ================================================================== -->
<record id="view_task_sync_config_form" model="ir.ui.view">
<field name="name">fusion.task.sync.config.form</field>
<field name="model">fusion.task.sync.config</field>
<field name="arch" type="xml">
<form string="Task Sync Configuration">
<header>
<button name="action_test_connection" type="object"
string="Test Connection" class="btn-secondary" icon="fa-plug"/>
<button name="action_sync_now" type="object"
string="Sync Now" class="btn-success" icon="fa-sync"/>
</header>
<sheet>
<div class="oe_title">
<h1><field name="name" placeholder="e.g. Westin Healthcare"/></h1>
</div>
<group>
<group string="Connection">
<field name="instance_id" placeholder="e.g. westin"/>
<field name="url" placeholder="http://192.168.1.40:8069"/>
<field name="database" placeholder="e.g. westin-v19"/>
<field name="username" placeholder="e.g. admin"/>
<field name="api_key" password="True"/>
<field name="active"/>
</group>
<group string="Status">
<field name="last_sync"/>
<field name="last_sync_error" readonly="1"/>
</group>
</group>
<div class="alert alert-info mt-3">
<i class="fa fa-info-circle"/>
Technicians are matched across instances by their
<strong>Tech Sync ID</strong> field (Settings &gt; Users).
Set the same ID (e.g. "gordy") on both instances for each shared technician.
</div>
</sheet>
</form>
</field>
</record>
<!-- ================================================================== -->
<!-- SYNC CONFIG - LIST VIEW -->
<!-- ================================================================== -->
<record id="view_task_sync_config_list" model="ir.ui.view">
<field name="name">fusion.task.sync.config.list</field>
<field name="model">fusion.task.sync.config</field>
<field name="arch" type="xml">
<list>
<field name="name"/>
<field name="instance_id"/>
<field name="url"/>
<field name="database"/>
<field name="active"/>
<field name="last_sync"/>
</list>
</field>
</record>
<!-- ================================================================== -->
<!-- SYNC CONFIG - ACTION + MENU -->
<!-- ================================================================== -->
<record id="action_task_sync_config" model="ir.actions.act_window">
<field name="name">Task Sync Instances</field>
<field name="res_model">fusion.task.sync.config</field>
<field name="view_mode">list,form</field>
</record>
<menuitem id="menu_task_sync_config"
name="Task Sync"
parent="menu_technician_config"
action="action_task_sync_config"
sequence="10"/>
</odoo>

View File

@@ -0,0 +1,102 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ================================================================== -->
<!-- LIST VIEW -->
<!-- ================================================================== -->
<record id="view_technician_location_list" model="ir.ui.view">
<field name="name">fusion.technician.location.list</field>
<field name="model">fusion.technician.location</field>
<field name="arch" type="xml">
<list string="Technician Locations" create="0" edit="0"
default_order="logged_at desc">
<field name="user_id" widget="many2one_avatar_user"/>
<field name="logged_at" string="Time"/>
<field name="latitude" optional="hide"/>
<field name="longitude" optional="hide"/>
<field name="accuracy" string="Accuracy (m)" optional="hide"/>
<field name="source"/>
</list>
</field>
</record>
<!-- ================================================================== -->
<!-- FORM VIEW (read-only) -->
<!-- ================================================================== -->
<record id="view_technician_location_form" model="ir.ui.view">
<field name="name">fusion.technician.location.form</field>
<field name="model">fusion.technician.location</field>
<field name="arch" type="xml">
<form string="Location Log" create="0" edit="0">
<sheet>
<group>
<group>
<field name="user_id"/>
<field name="logged_at"/>
<field name="source"/>
</group>
<group>
<field name="latitude"/>
<field name="longitude"/>
<field name="accuracy"/>
</group>
</group>
</sheet>
</form>
</field>
</record>
<!-- ================================================================== -->
<!-- SEARCH VIEW -->
<!-- ================================================================== -->
<record id="view_technician_location_search" model="ir.ui.view">
<field name="name">fusion.technician.location.search</field>
<field name="model">fusion.technician.location</field>
<field name="arch" type="xml">
<search string="Search Location Logs">
<field name="user_id" string="Technician"/>
<separator/>
<filter string="Today" name="filter_today"
domain="[('logged_at', '>=', context_today().strftime('%Y-%m-%d'))]"/>
<filter string="Last 7 Days" name="filter_7d"
domain="[('logged_at', '>=', (context_today() - datetime.timedelta(days=7)).strftime('%Y-%m-%d'))]"/>
<filter string="Last 30 Days" name="filter_30d"
domain="[('logged_at', '>=', (context_today() - datetime.timedelta(days=30)).strftime('%Y-%m-%d'))]"/>
<separator/>
<filter string="Technician" name="group_user" context="{'group_by': 'user_id'}"/>
<filter string="Date" name="group_date" context="{'group_by': 'logged_at:day'}"/>
<filter string="Source" name="group_source" context="{'group_by': 'source'}"/>
</search>
</field>
</record>
<!-- ================================================================== -->
<!-- ACTION -->
<!-- ================================================================== -->
<record id="action_technician_locations" model="ir.actions.act_window">
<field name="name">Location History</field>
<field name="res_model">fusion.technician.location</field>
<field name="view_mode">list,form</field>
<field name="search_view_id" ref="view_technician_location_search"/>
<field name="context">{
'search_default_filter_today': 1,
'search_default_group_user': 1,
}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
No location data logged yet.
</p>
<p>Technician locations are automatically logged when they use the portal.</p>
</field>
</record>
<!-- ================================================================== -->
<!-- MENU ITEMS (under Configuration) -->
<!-- ================================================================== -->
<menuitem id="menu_technician_locations"
name="Location History"
parent="menu_technician_config"
action="action_technician_locations"
sequence="20"/>
</odoo>

View File

@@ -0,0 +1,507 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- ================================================================== -->
<!-- SEQUENCE -->
<!-- ================================================================== -->
<record id="seq_technician_task" model="ir.sequence">
<field name="name">Technician Task</field>
<field name="code">fusion.technician.task</field>
<field name="prefix">TASK-</field>
<field name="padding">5</field>
<field name="number_increment">1</field>
</record>
<!-- ================================================================== -->
<!-- RES.USERS FORM EXTENSION - Field Staff toggle -->
<!-- ================================================================== -->
<record id="view_users_form_field_staff" model="ir.ui.view">
<field name="name">res.users.form.field.staff</field>
<field name="model">res.users</field>
<field name="inherit_id" ref="base.view_users_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='login']" position="after">
<field name="x_fc_is_field_staff"/>
<field name="x_fc_start_address"
invisible="not x_fc_is_field_staff"
placeholder="e.g. 123 Main St, Brampton, ON"/>
<field name="x_fc_tech_sync_id"
invisible="not x_fc_is_field_staff"
placeholder="e.g. gordy, manpreet"/>
</xpath>
</field>
</record>
<!-- ================================================================== -->
<!-- SEARCH VIEW -->
<!-- ================================================================== -->
<record id="view_technician_task_search" model="ir.ui.view">
<field name="name">fusion.technician.task.search</field>
<field name="model">fusion.technician.task</field>
<field name="arch" type="xml">
<search string="Search Tasks">
<field name="technician_id" string="Technician"/>
<field name="partner_id" string="Client"/>
<field name="name" string="Task"/>
<separator/>
<!-- Quick Filters -->
<filter string="Today" name="filter_today"
domain="[('scheduled_date', '=', context_today().strftime('%Y-%m-%d'))]"/>
<filter string="Tomorrow" name="filter_tomorrow"
domain="[('scheduled_date', '=', (context_today() + datetime.timedelta(days=1)).strftime('%Y-%m-%d'))]"/>
<filter string="This Week" name="filter_this_week"
domain="[('scheduled_date', '>=', (context_today() - datetime.timedelta(days=context_today().weekday())).strftime('%Y-%m-%d')),
('scheduled_date', '&lt;=', (context_today() + datetime.timedelta(days=6-context_today().weekday())).strftime('%Y-%m-%d'))]"/>
<separator/>
<filter string="Pending" name="filter_pending" domain="[('status', '=', 'pending')]"/>
<filter string="Scheduled" name="filter_scheduled" domain="[('status', '=', 'scheduled')]"/>
<filter string="En Route" name="filter_en_route" domain="[('status', '=', 'en_route')]"/>
<filter string="In Progress" name="filter_in_progress" domain="[('status', '=', 'in_progress')]"/>
<filter string="Completed" name="filter_completed" domain="[('status', '=', 'completed')]"/>
<filter string="Active" name="filter_active" domain="[('status', 'not in', ['cancelled', 'completed'])]"/>
<separator/>
<filter string="My Tasks" name="filter_my_tasks"
domain="['|', ('technician_id', '=', uid), ('additional_technician_ids', 'in', [uid])]"/>
<filter string="Deliveries" name="filter_deliveries" domain="[('task_type', '=', 'delivery')]"/>
<filter string="Repairs" name="filter_repairs" domain="[('task_type', '=', 'repair')]"/>
<filter string="POD Required" name="filter_pod" domain="[('pod_required', '=', True)]"/>
<separator/>
<filter string="Local Tasks" name="filter_local"
domain="[('x_fc_sync_source', '=', False)]"/>
<filter string="Synced Tasks" name="filter_synced"
domain="[('x_fc_sync_source', '!=', False)]"/>
<separator/>
<!-- Group By -->
<filter string="Technician" name="group_technician" context="{'group_by': 'technician_id'}"/>
<filter string="Date" name="group_date" context="{'group_by': 'scheduled_date'}"/>
<filter string="Status" name="group_status" context="{'group_by': 'status'}"/>
<filter string="Task Type" name="group_type" context="{'group_by': 'task_type'}"/>
<filter string="Client" name="group_client" context="{'group_by': 'partner_id'}"/>
</search>
</field>
</record>
<!-- ================================================================== -->
<!-- FORM VIEW -->
<!-- ================================================================== -->
<record id="view_technician_task_form" model="ir.ui.view">
<field name="name">fusion.technician.task.form</field>
<field name="model">fusion.technician.task</field>
<field name="arch" type="xml">
<form string="Technician Task">
<field name="x_fc_is_shadow" invisible="1"/>
<field name="x_fc_sync_source" invisible="1"/>
<header>
<button name="action_start_en_route" type="object" string="En Route"
class="btn-primary" invisible="status != 'scheduled' or x_fc_is_shadow"/>
<button name="action_start_task" type="object" string="Start Task"
class="btn-primary" invisible="status not in ('scheduled', 'en_route') or x_fc_is_shadow"/>
<button name="action_complete_task" type="object" string="Complete"
class="btn-success" invisible="status not in ('in_progress', 'en_route') or x_fc_is_shadow"/>
<button name="action_reschedule" type="object" string="Reschedule"
class="btn-warning" invisible="status not in ('scheduled', 'en_route') or x_fc_is_shadow"/>
<button name="action_cancel_task" type="object" string="Cancel"
class="btn-danger" invisible="status in ('completed', 'cancelled') or x_fc_is_shadow"
confirm="Are you sure you want to cancel this task?"/>
<button name="action_reset_to_scheduled" type="object" string="Reset to Scheduled"
invisible="status not in ('cancelled', 'rescheduled') or x_fc_is_shadow"/>
<button string="Calculate Travel"
class="btn-secondary o_fc_calculate_travel" icon="fa-car"
invisible="x_fc_is_shadow"/>
<field name="status" widget="statusbar"
statusbar_visible="pending,scheduled,en_route,in_progress,completed"/>
</header>
<sheet>
<!-- Shadow task banner -->
<div class="alert alert-info text-center" role="alert"
invisible="not x_fc_is_shadow">
<strong><i class="fa fa-link"/> This task is synced from
<field name="x_fc_sync_source" readonly="1" nolabel="1" class="d-inline"/>
— view only.</strong>
</div>
<div class="oe_button_box" name="button_box">
</div>
<widget name="web_ribbon" title="Completed" bg_color="text-bg-success"
invisible="status != 'completed'"/>
<widget name="web_ribbon" title="Cancelled" bg_color="text-bg-danger"
invisible="status != 'cancelled'"/>
<widget name="web_ribbon" title="Synced" bg_color="text-bg-info"
invisible="not x_fc_is_shadow or status in ('completed', 'cancelled')"/>
<div class="oe_title">
<h1>
<field name="name" readonly="1"/>
</h1>
</div>
<!-- Schedule Info Banner -->
<field name="schedule_info_html" nolabel="1" colspan="2"
invisible="not technician_id or not scheduled_date"/>
<!-- Previous Task / Travel Warning Banner -->
<field name="prev_task_summary_html" nolabel="1" colspan="2"
invisible="not technician_id or not scheduled_date"/>
<!-- Hidden fields for calendar sync and legacy -->
<field name="datetime_start" invisible="1"/>
<field name="datetime_end" invisible="1"/>
<field name="time_start_12h" invisible="1"/>
<field name="time_end_12h" invisible="1"/>
<group>
<group string="Assignment">
<field name="technician_id"
domain="[('x_fc_is_field_staff', '=', True)]"/>
<field name="additional_technician_ids"
widget="many2many_tags_avatar"
domain="[('x_fc_is_field_staff', '=', True), ('id', '!=', technician_id)]"
options="{'color_field': 'color'}"/>
<field name="task_type"/>
<field name="priority" widget="priority"/>
</group>
<group string="Schedule">
<field name="scheduled_date"/>
<field name="time_start" widget="float_time"
string="Start Time"/>
<field name="duration_hours" widget="float_time"
string="Duration"/>
<field name="time_end" widget="float_time"
string="End Time" readonly="1"
force_save="1"/>
</group>
</group>
<group>
<group string="Client">
<field name="partner_id"/>
<field name="partner_phone" widget="phone"/>
</group>
<group string="Location">
<field name="is_in_store"/>
<field name="address_partner_id" invisible="is_in_store"/>
<field name="address_street" readonly="is_in_store"/>
<field name="address_street2" string="Unit/Suite #" invisible="is_in_store"/>
<field name="address_buzz_code" invisible="is_in_store"/>
<field name="address_city" invisible="1"/>
<field name="address_state_id" invisible="1"/>
<field name="address_zip" invisible="1"/>
<field name="address_lat" invisible="1"/>
<field name="address_lng" invisible="1"/>
</group>
</group>
<group>
<group string="Travel (Auto-Calculated)">
<field name="travel_time_minutes" readonly="1"/>
<field name="travel_distance_km" readonly="1"/>
<field name="travel_origin" readonly="1"/>
<field name="previous_task_id" readonly="1"/>
</group>
<group string="Options">
<field name="pod_required"/>
<field name="x_fc_send_client_updates"/>
<field name="x_fc_ask_google_review"/>
<field name="active" invisible="1"/>
</group>
</group>
<notebook>
<page string="Description" name="description">
<group>
<field name="description" placeholder="What needs to be done..."/>
</group>
<group>
<field name="equipment_needed" placeholder="Tools, parts, materials..."/>
</group>
</page>
<page string="Completion" name="completion">
<group>
<field name="completion_datetime"/>
<field name="completion_notes"/>
</group>
<group>
<field name="voice_note_transcription"/>
</group>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<!-- ================================================================== -->
<!-- LIST VIEW -->
<!-- ================================================================== -->
<record id="view_technician_task_list" model="ir.ui.view">
<field name="name">fusion.technician.task.list</field>
<field name="model">fusion.technician.task</field>
<field name="arch" type="xml">
<list string="Technician Tasks" decoration-success="status == 'completed'"
decoration-warning="status == 'in_progress'"
decoration-info="status == 'en_route'"
decoration-danger="status == 'cancelled'"
decoration-muted="status == 'rescheduled'"
default_order="scheduled_date, sequence, time_start">
<field name="name"/>
<field name="technician_id" widget="many2one_avatar_user"/>
<field name="additional_technician_ids" widget="many2many_tags_avatar"
optional="show" string="+ Techs"/>
<field name="task_type" decoration-bf="1"/>
<field name="scheduled_date"/>
<field name="time_start_display" string="Start"/>
<field name="time_end_display" string="End"/>
<field name="partner_id"/>
<field name="address_city"/>
<field name="travel_time_minutes" string="Travel (min)" optional="show"/>
<field name="status" widget="badge"
decoration-success="status == 'completed'"
decoration-warning="status == 'in_progress'"
decoration-info="status in ('scheduled', 'en_route')"
decoration-danger="status == 'cancelled'"/>
<field name="priority" widget="priority" optional="hide"/>
<field name="pod_required" optional="hide"/>
<field name="x_fc_source_label" string="Source" optional="show"
widget="badge" decoration-info="x_fc_is_shadow"
decoration-success="not x_fc_is_shadow"/>
</list>
</field>
</record>
<!-- ================================================================== -->
<!-- KANBAN VIEW -->
<!-- ================================================================== -->
<record id="view_technician_task_kanban" model="ir.ui.view">
<field name="name">fusion.technician.task.kanban</field>
<field name="model">fusion.technician.task</field>
<field name="arch" type="xml">
<kanban default_group_by="status" class="o_kanban_small_column"
records_draggable="1" group_create="0">
<field name="color"/>
<field name="priority"/>
<field name="technician_id"/>
<field name="additional_technician_ids"/>
<field name="additional_tech_count"/>
<field name="partner_id"/>
<field name="task_type"/>
<field name="scheduled_date"/>
<field name="time_start_display"/>
<field name="address_city"/>
<field name="travel_time_minutes"/>
<field name="status"/>
<field name="x_fc_is_shadow"/>
<field name="x_fc_sync_client_name"/>
<templates>
<t t-name="card">
<div t-attf-class="oe_kanban_color_#{record.color.raw_value} oe_kanban_card oe_kanban_global_click">
<div class="oe_kanban_content">
<div class="o_kanban_record_top mb-1">
<div class="o_kanban_record_headings">
<strong class="o_kanban_record_title">
<field name="name"/>
</strong>
</div>
<field name="priority" widget="priority"/>
</div>
<div class="mb-1">
<span class="badge bg-primary me-1"><field name="task_type"/></span>
<span class="text-muted"><field name="scheduled_date"/> - <field name="time_start_display"/></span>
</div>
<div class="mb-1">
<i class="fa fa-user me-1"/>
<t t-if="record.x_fc_is_shadow.raw_value">
<span t-out="record.x_fc_sync_client_name.value"/>
</t>
<t t-else="">
<field name="partner_id"/>
</t>
</div>
<div class="text-muted small" t-if="record.address_city.raw_value">
<i class="fa fa-map-marker me-1"/><field name="address_city"/>
<t t-if="record.travel_time_minutes.raw_value">
<span class="ms-2"><i class="fa fa-car me-1"/><field name="travel_time_minutes"/> min</span>
</t>
</div>
<div t-if="record.additional_tech_count.raw_value > 0" class="text-muted small mb-1">
<i class="fa fa-users me-1"/>
<span>+<field name="additional_tech_count"/> technician(s)</span>
</div>
<div class="o_kanban_record_bottom mt-2">
<div class="oe_kanban_bottom_left">
<field name="activity_ids" widget="kanban_activity"/>
</div>
<div class="oe_kanban_bottom_right">
<field name="technician_id" widget="many2one_avatar_user"/>
</div>
</div>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<!-- ================================================================== -->
<!-- CALENDAR VIEW -->
<!-- ================================================================== -->
<record id="view_technician_task_calendar" model="ir.ui.view">
<field name="name">fusion.technician.task.calendar</field>
<field name="model">fusion.technician.task</field>
<field name="arch" type="xml">
<calendar string="Technician Schedule"
date_start="datetime_start" date_stop="datetime_end"
color="technician_id" mode="week" event_open_popup="1"
quick_create="0">
<!-- Displayed on the calendar card -->
<field name="partner_id"/>
<field name="x_fc_sync_client_name"/>
<field name="task_type"/>
<field name="time_start_display" string="Start"/>
<field name="time_end_display" string="End"/>
<!-- Popover (hover/click) details -->
<field name="name"/>
<field name="technician_id" avatar_field="image_128"/>
<field name="address_display" string="Address"/>
<field name="travel_time_minutes" string="Travel (min)"/>
<field name="status"/>
<field name="duration_hours" widget="float_time" string="Duration"/>
</calendar>
</field>
</record>
<!-- ================================================================== -->
<!-- MAP VIEW (Enterprise web_map) -->
<!-- ================================================================== -->
<record id="view_technician_task_map" model="ir.ui.view">
<field name="name">fusion.technician.task.map</field>
<field name="model">fusion.technician.task</field>
<field name="arch" type="xml">
<map res_partner="address_partner_id" default_order="time_start"
routing="1" js_class="fusion_task_map">
<field name="partner_id" string="Client"/>
<field name="task_type" string="Type"/>
<field name="technician_id" string="Technician"/>
<field name="time_start_display" string="Start"/>
<field name="time_end_display" string="End"/>
<field name="status" string="Status"/>
<field name="travel_time_minutes" string="Travel (min)"/>
</map>
</field>
</record>
<!-- ================================================================== -->
<!-- ACTIONS -->
<!-- ================================================================== -->
<!-- Main Tasks Action (List/Kanban) -->
<record id="action_technician_tasks" model="ir.actions.act_window">
<field name="name">Technician Tasks</field>
<field name="res_model">fusion.technician.task</field>
<field name="view_mode">list,kanban,form,calendar,map</field>
<field name="search_view_id" ref="view_technician_task_search"/>
<field name="context">{'search_default_filter_active': 1}</field>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create your first technician task
</p>
<p>Schedule deliveries, repairs, and other field tasks for your technicians.</p>
</field>
</record>
<!-- Schedule Action (Map default) -->
<record id="action_technician_schedule" model="ir.actions.act_window">
<field name="name">Schedule</field>
<field name="res_model">fusion.technician.task</field>
<field name="view_mode">map,calendar,list,kanban,form</field>
<field name="search_view_id" ref="view_technician_task_search"/>
<field name="context">{'search_default_filter_active': 1}</field>
</record>
<!-- Map View Action (for app landing page) -->
<record id="action_technician_map_view" model="ir.actions.act_window">
<field name="name">Task Map</field>
<field name="res_model">fusion.technician.task</field>
<field name="view_mode">map,list,kanban,form,calendar</field>
<field name="search_view_id" ref="view_technician_task_search"/>
<field name="context">{'search_default_filter_active': 1}</field>
</record>
<!-- Today's Tasks Action -->
<record id="action_technician_tasks_today" model="ir.actions.act_window">
<field name="name">Today's Tasks</field>
<field name="res_model">fusion.technician.task</field>
<field name="view_mode">kanban,list,form,map</field>
<field name="search_view_id" ref="view_technician_task_search"/>
<field name="context">{'search_default_filter_today': 1, 'search_default_filter_active': 1}</field>
</record>
<!-- My Tasks Action -->
<record id="action_technician_my_tasks" model="ir.actions.act_window">
<field name="name">My Tasks</field>
<field name="res_model">fusion.technician.task</field>
<field name="view_mode">list,kanban,form,calendar,map</field>
<field name="search_view_id" ref="view_technician_task_search"/>
<field name="context">{'search_default_filter_my_tasks': 1, 'search_default_filter_active': 1}</field>
</record>
<!-- Pending Tasks Action -->
<record id="action_technician_tasks_pending" model="ir.actions.act_window">
<field name="name">Pending Tasks</field>
<field name="res_model">fusion.technician.task</field>
<field name="view_mode">list,kanban,form</field>
<field name="search_view_id" ref="view_technician_task_search"/>
<field name="context">{'search_default_filter_pending': 1}</field>
</record>
<!-- Calendar Action -->
<record id="action_technician_calendar" model="ir.actions.act_window">
<field name="name">Task Calendar</field>
<field name="res_model">fusion.technician.task</field>
<field name="view_mode">calendar,list,kanban,form,map</field>
<field name="search_view_id" ref="view_technician_task_search"/>
<field name="context">{'search_default_filter_active': 1}</field>
</record>
<!-- ================================================================== -->
<!-- MENU ITEMS - Standalone Field Service App -->
<!-- ================================================================== -->
<!-- Root app menu -->
<menuitem id="menu_field_service_root"
name="Field Service"
web_icon="fusion_tasks,static/description/icon.png"
groups="fusion_tasks.group_field_technician"
sequence="45"/>
<!-- Tasks - main task list -->
<menuitem id="menu_technician_tasks"
name="Tasks"
parent="menu_field_service_root"
action="action_technician_tasks"
sequence="10"
groups="fusion_tasks.group_field_technician"/>
<!-- Map View -->
<menuitem id="menu_technician_map"
name="Map View"
parent="menu_field_service_root"
action="action_technician_map_view"
sequence="20"
groups="fusion_tasks.group_field_technician"/>
<!-- Calendar -->
<menuitem id="menu_technician_calendar"
name="Calendar"
parent="menu_field_service_root"
action="action_technician_calendar"
sequence="30"
groups="fusion_tasks.group_field_technician"/>
<!-- Task Sync (submenu) -->
<menuitem id="menu_technician_config"
name="Configuration"
parent="menu_field_service_root"
sequence="90"
groups="fusion_tasks.group_field_technician"/>
</odoo>