This commit is contained in:
gsinghpal
2026-03-01 14:42:49 -05:00
parent b925766966
commit a3e85a23ef
28 changed files with 2283 additions and 195 deletions

View File

@@ -30,9 +30,13 @@ SYNC_TASK_FIELDS = [
'task_type', 'status',
'scheduled_date', 'time_start', 'time_end', 'duration_hours',
'address_street', 'address_street2', 'address_city', 'address_zip',
'address_lat', 'address_lng', 'priority', 'partner_id',
'address_state_id', 'address_buzz_code',
'address_lat', 'address_lng', 'priority', 'partner_id', 'partner_phone',
'pod_required', 'description',
]
TERMINAL_STATUSES = ('completed', 'cancelled')
class FusionTaskSyncConfig(models.Model):
_name = 'fusion.task.sync.config'
@@ -268,17 +272,58 @@ class FusionTaskSyncConfig(models.Model):
self._rpc('fusion.technician.task', 'write',
[existing, {'status': 'cancelled', 'active': False}], ctx)
@api.model
def _push_shadow_status(self, shadow_tasks):
"""Push local status changes on shadow tasks back to their source instance.
When a tech completes (or cancels) a shadow task locally, update the
original task on the remote instance so both sides stay in sync.
"""
configs = self.sudo().search([('active', '=', True)])
config_by_instance = {c.instance_id: c for c in configs}
ctx = {'context': {'skip_task_sync': True, 'skip_travel_recalc': True}}
for task in shadow_tasks:
config = config_by_instance.get(task.x_fc_sync_source)
if not config or not task.x_fc_sync_remote_id:
continue
try:
update_vals = {'status': task.status}
if task.status == 'completed' and task.completion_datetime:
update_vals['completion_datetime'] = str(task.completion_datetime)
config._rpc(
'fusion.technician.task', 'write',
[[task.x_fc_sync_remote_id], update_vals], ctx)
_logger.info(
"Pushed status '%s' for shadow task %s back to %s (remote id %d)",
task.status, task.name, config.name, task.x_fc_sync_remote_id)
if task.status == 'completed':
try:
config._rpc(
'fusion.technician.task',
'_notify_scheduler_on_completion',
[[task.x_fc_sync_remote_id]])
except Exception:
_logger.warning(
"Could not trigger completion notification on remote for %s",
task.name)
except Exception:
_logger.exception(
"Failed to push status for shadow task %s to %s",
task.name, config.name)
# ------------------------------------------------------------------
# PULL: cron-based full reconciliation
# ------------------------------------------------------------------
@api.model
def _cron_pull_remote_tasks(self):
"""Cron job: pull tasks from all active remote instances."""
"""Cron job: pull tasks and technician locations from all active remote instances."""
configs = self.sudo().search([('active', '=', True)])
for config in configs:
try:
config._pull_tasks_from_remote()
config._pull_technician_locations()
config.sudo().write({
'last_sync': fields.Datetime.now(),
'last_sync_error': False,
@@ -288,7 +333,11 @@ class FusionTaskSyncConfig(models.Model):
config.sudo().write({'last_sync_error': str(e)})
def _pull_tasks_from_remote(self):
"""Pull all active tasks for matched technicians from the remote instance."""
"""Pull all active tasks for matched technicians from the remote instance.
After syncing, recalculates travel chains for all affected tech+date
combos so route planning accounts for both local and shadow tasks.
"""
self.ensure_one()
local_syncid_to_uid = self._get_local_syncid_to_uid()
if not local_syncid_to_uid:
@@ -325,6 +374,8 @@ class FusionTaskSyncConfig(models.Model):
skip_task_sync=True, skip_travel_recalc=True)
remote_uuids = set()
affected_combos = set()
for rt in remote_tasks:
sync_uuid = rt.get('x_fc_sync_uuid')
if not sync_uuid:
@@ -340,6 +391,12 @@ class FusionTaskSyncConfig(models.Model):
partner_raw = rt.get('partner_id')
client_name = partner_raw[1] if isinstance(partner_raw, (list, tuple)) and len(partner_raw) > 1 else ''
client_phone = rt.get('partner_phone', '') or ''
state_raw = rt.get('address_state_id')
state_name = ''
if isinstance(state_raw, (list, tuple)) and len(state_raw) > 1:
state_name = state_raw[1]
# Map additional technicians from remote to local
local_additional_ids = []
@@ -352,6 +409,8 @@ class FusionTaskSyncConfig(models.Model):
if local_add_uid:
local_additional_ids.append(local_add_uid)
sched_date = rt.get('scheduled_date')
vals = {
'x_fc_sync_uuid': sync_uuid,
'x_fc_sync_source': self.instance_id,
@@ -361,7 +420,7 @@ class FusionTaskSyncConfig(models.Model):
'additional_technician_ids': [(6, 0, local_additional_ids)],
'task_type': rt.get('task_type', 'delivery'),
'status': rt.get('status', 'scheduled'),
'scheduled_date': rt.get('scheduled_date'),
'scheduled_date': sched_date,
'time_start': rt.get('time_start', 9.0),
'time_end': rt.get('time_end', 10.0),
'duration_hours': rt.get('duration_hours', 1.0),
@@ -369,19 +428,36 @@ class FusionTaskSyncConfig(models.Model):
'address_street2': rt.get('address_street2', ''),
'address_city': rt.get('address_city', ''),
'address_zip': rt.get('address_zip', ''),
'address_buzz_code': rt.get('address_buzz_code', ''),
'address_lat': rt.get('address_lat', 0),
'address_lng': rt.get('address_lng', 0),
'priority': rt.get('priority', 'normal'),
'pod_required': rt.get('pod_required', False),
'description': rt.get('description', ''),
'x_fc_sync_client_name': client_name,
'x_fc_sync_client_phone': client_phone,
}
if state_name:
state_rec = self.env['res.country.state'].sudo().search(
[('name', '=', state_name)], limit=1)
if state_rec:
vals['address_state_id'] = state_rec.id
existing = Task.search([('x_fc_sync_uuid', '=', sync_uuid)], limit=1)
if existing:
if existing.status in TERMINAL_STATUSES:
vals.pop('status', None)
existing.write(vals)
else:
vals['sale_order_id'] = False
Task.create([vals])
if sched_date:
affected_combos.add((local_uid, sched_date))
for add_uid in local_additional_ids:
affected_combos.add((add_uid, sched_date))
stale_shadows = Task.search([
('x_fc_sync_source', '=', self.instance_id),
('x_fc_sync_uuid', 'not in', list(remote_uuids)),
@@ -389,10 +465,149 @@ class FusionTaskSyncConfig(models.Model):
('active', '=', True),
])
if stale_shadows:
for st in stale_shadows:
if st.scheduled_date and st.technician_id:
affected_combos.add((st.technician_id.id, st.scheduled_date))
for tech in st.additional_technician_ids:
if st.scheduled_date:
affected_combos.add((tech.id, st.scheduled_date))
stale_shadows.write({'active': False, 'status': 'cancelled'})
_logger.info("Deactivated %d stale shadow tasks from %s",
len(stale_shadows), self.instance_id)
if affected_combos:
today = fields.Date.today()
today_str = str(today)
future_combos = set()
for tid, d in affected_combos:
if not d:
continue
d_str = str(d) if not isinstance(d, str) else d
if d_str >= today_str:
future_combos.add((tid, d_str))
if future_combos:
TaskModel = self.env['fusion.technician.task'].sudo()
try:
ungeocode = TaskModel.search([
('x_fc_sync_source', '=', self.instance_id),
('active', '=', True),
('scheduled_date', '>=', today_str),
('status', 'not in', ['cancelled']),
'|',
('address_lat', '=', 0), ('address_lat', '=', False),
])
geocoded = 0
for shadow in ungeocode:
if shadow.address_display:
if shadow.with_context(skip_travel_recalc=True)._geocode_address():
geocoded += 1
if geocoded:
_logger.info("Geocoded %d shadow tasks from %s",
geocoded, self.name)
except Exception:
_logger.exception(
"Shadow task geocoding after sync from %s failed", self.name)
try:
TaskModel._recalculate_combos_travel(future_combos)
_logger.info(
"Recalculated travel for %d tech+date combos after sync from %s",
len(future_combos), self.name)
except Exception:
_logger.exception(
"Travel recalculation after sync from %s failed", self.name)
# ------------------------------------------------------------------
# PULL: technician locations from remote instance
# ------------------------------------------------------------------
def _pull_technician_locations(self):
"""Pull latest GPS locations for matched technicians from the remote instance.
Creates local location records with source='sync' so the map view
shows technician positions from both instances. Only keeps the single
most recent synced location per technician (replaces older synced
records to avoid clutter).
"""
self.ensure_one()
local_syncid_to_uid = self._get_local_syncid_to_uid()
if not local_syncid_to_uid:
return
remote_map = self._get_remote_tech_map()
if not remote_map:
return
matched_sync_ids = set(local_syncid_to_uid.keys()) & set(remote_map.keys())
if not matched_sync_ids:
return
remote_tech_ids = [remote_map[sid] for sid in matched_sync_ids]
remote_syncid_by_uid = {v: k for k, v in remote_map.items()}
remote_locations = self._rpc(
'fusion.technician.location', 'search_read',
[[
('user_id', 'in', remote_tech_ids),
('logged_at', '>', str(fields.Datetime.subtract(
fields.Datetime.now(), hours=24))),
('source', '!=', 'sync'),
]],
{
'fields': ['user_id', 'latitude', 'longitude',
'accuracy', 'logged_at'],
'order': 'logged_at desc',
})
if not remote_locations:
return
Location = self.env['fusion.technician.location'].sudo()
seen_techs = set()
synced_count = 0
for rloc in remote_locations:
remote_uid_raw = rloc['user_id']
remote_uid = (remote_uid_raw[0]
if isinstance(remote_uid_raw, (list, tuple))
else remote_uid_raw)
if remote_uid in seen_techs:
continue
seen_techs.add(remote_uid)
sync_id = remote_syncid_by_uid.get(remote_uid)
local_uid = local_syncid_to_uid.get(sync_id) if sync_id else None
if not local_uid:
continue
lat = rloc.get('latitude', 0)
lng = rloc.get('longitude', 0)
if not lat or not lng:
continue
old_synced = Location.search([
('user_id', '=', local_uid),
('source', '=', 'sync'),
('sync_instance', '=', self.instance_id),
])
if old_synced:
old_synced.unlink()
Location.create({
'user_id': local_uid,
'latitude': lat,
'longitude': lng,
'accuracy': rloc.get('accuracy', 0),
'logged_at': rloc.get('logged_at', fields.Datetime.now()),
'source': 'sync',
'sync_instance': self.instance_id,
})
synced_count += 1
if synced_count:
_logger.info("Synced %d technician location(s) from %s",
synced_count, self.name)
# ------------------------------------------------------------------
# CLEANUP
# ------------------------------------------------------------------
@@ -419,6 +634,7 @@ class FusionTaskSyncConfig(models.Model):
"""Manually trigger a full sync for this config."""
self.ensure_one()
self._pull_tasks_from_remote()
self._pull_technician_locations()
self.sudo().write({
'last_sync': fields.Datetime.now(),
'last_sync_error': False,
@@ -426,12 +642,18 @@ class FusionTaskSyncConfig(models.Model):
shadow_count = self.env['fusion.technician.task'].sudo().search_count([
('x_fc_sync_source', '=', self.instance_id),
])
loc_count = self.env['fusion.technician.location'].sudo().search_count([
('source', '=', 'sync'),
('sync_instance', '=', self.instance_id),
])
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Sync Complete',
'message': f'Synced from {self.name}. {shadow_count} shadow task(s) now visible.',
'message': (f'Synced from {self.name}. '
f'{shadow_count} shadow task(s), '
f'{loc_count} technician location(s) visible.'),
'type': 'success',
'sticky': False,
},

View File

@@ -48,7 +48,12 @@ class FusionTechnicianLocation(models.Model):
source = fields.Selection([
('portal', 'Portal'),
('app', 'Mobile App'),
('sync', 'Synced'),
], string='Source', default='portal')
sync_instance = fields.Char(
'Sync Instance', index=True,
help='Source instance ID if synced (e.g. westin, mobility)',
)
@api.model
def log_location(self, latitude, longitude, accuracy=None):
@@ -63,18 +68,27 @@ class FusionTechnicianLocation(models.Model):
@api.model
def get_latest_locations(self):
"""Get the most recent location for each technician (for map view)."""
"""Get the most recent location for each technician (for map view).
Includes both local GPS pings and synced locations from remote
instances, so the map shows all shared technicians regardless of
which Odoo instance they are clocked into.
"""
self.env.cr.execute("""
SELECT DISTINCT ON (user_id)
user_id, latitude, longitude, accuracy, logged_at
user_id, latitude, longitude, accuracy, logged_at,
COALESCE(sync_instance, '') AS sync_instance
FROM fusion_technician_location
WHERE logged_at > NOW() - INTERVAL '24 hours'
ORDER BY user_id, logged_at DESC
""")
rows = self.env.cr.dictfetchall()
local_id = self.env['ir.config_parameter'].sudo().get_param(
'fusion_claims.sync_instance_id', '')
result = []
for row in rows:
user = self.env['res.users'].sudo().browse(row['user_id'])
src = row.get('sync_instance') or local_id
result.append({
'user_id': row['user_id'],
'name': user.name,
@@ -82,6 +96,7 @@ class FusionTechnicianLocation(models.Model):
'longitude': row['longitude'],
'accuracy': row['accuracy'],
'logged_at': str(row['logged_at']),
'sync_instance': src,
})
return result

View File

@@ -95,6 +95,17 @@ class FusionTechnicianTask(models.Model):
'Synced Client Name', readonly=True,
help='Client name from the remote instance (shadow tasks only)',
)
x_fc_sync_client_phone = fields.Char(
'Synced Client Phone', readonly=True,
help='Client phone from the remote instance (shadow tasks only)',
)
client_display_name = fields.Char(
compute='_compute_client_display', string='Client Name (Display)',
)
client_display_phone = fields.Char(
compute='_compute_client_display', string='Client Phone (Display)',
)
x_fc_source_label = fields.Char(
'Source', compute='_compute_is_shadow', store=True,
@@ -108,6 +119,17 @@ class FusionTechnicianTask(models.Model):
task.x_fc_is_shadow = bool(task.x_fc_sync_source)
task.x_fc_source_label = task.x_fc_sync_source or local_id
@api.depends('x_fc_sync_source', 'x_fc_sync_client_name',
'x_fc_sync_client_phone', 'partner_id')
def _compute_client_display(self):
for task in self:
if task.x_fc_sync_source:
task.client_display_name = task.x_fc_sync_client_name or task.name or ''
task.client_display_phone = task.x_fc_sync_client_phone or ''
else:
task.client_display_name = task.partner_id.name if task.partner_id else ''
task.client_display_phone = task.partner_id.phone if task.partner_id else ''
technician_id = fields.Many2one(
'res.users',
string='Technician',
@@ -288,6 +310,14 @@ class FusionTechnicianTask(models.Model):
help='Combined end datetime for calendar display',
)
calendar_event_id = fields.Many2one(
'calendar.event',
string='Calendar Event',
copy=False,
ondelete='set null',
help='Linked calendar event for external calendar sync',
)
# Schedule info helper for the form
schedule_info_html = fields.Html(
string='Schedule Info',
@@ -377,6 +407,17 @@ class FusionTechnicianTask(models.Model):
default=False,
help='Proof of Delivery signature required',
)
pod_signature = fields.Binary(
string='POD Signature', attachment=True,
)
pod_client_name = fields.Char(string='POD Signer Name')
pod_signature_date = fields.Date(string='POD Signature Date')
pod_signed_by_user_id = fields.Many2one(
'res.users', string='POD Collected By', readonly=True,
)
pod_signed_datetime = fields.Datetime(
string='POD Collected At', readonly=True,
)
# ------------------------------------------------------------------
# COMPLETION
@@ -1442,11 +1483,22 @@ class FusionTechnicianTask(models.Model):
local_records = records.filtered(lambda r: not r.x_fc_sync_source)
if local_records and not self.env.context.get('skip_task_sync'):
self.env['fusion.task.sync.config']._push_tasks(local_records, 'create')
# Sync to calendar for external calendar integrations
records._sync_calendar_event()
return records
def write(self, vals):
if self.env.context.get('skip_travel_recalc'):
return super().write(vals)
res = super().write(vals)
if ('status' in vals and vals['status'] in ('completed', 'cancelled')
and not self.env.context.get('skip_task_sync')):
shadow_records = self.filtered(lambda r: r.x_fc_sync_source)
if shadow_records:
self.env['fusion.task.sync.config']._push_shadow_status(shadow_records)
local_records = self.filtered(lambda r: not r.x_fc_sync_source)
if local_records:
self.env['fusion.task.sync.config']._push_tasks(local_records, 'write')
return res
# Safety: ensure time_end is consistent when start/duration change
# but time_end wasn't sent (readonly field in view may not save)
@@ -1516,8 +1568,63 @@ class FusionTechnicianTask(models.Model):
local_records = self.filtered(lambda r: not r.x_fc_sync_source)
if local_records:
self.env['fusion.task.sync.config']._push_tasks(local_records, 'write')
if 'status' in vals and vals['status'] in ('completed', 'cancelled'):
shadow_records = self.filtered(lambda r: r.x_fc_sync_source)
if shadow_records:
self.env['fusion.task.sync.config']._push_shadow_status(shadow_records)
# Re-sync calendar event when schedule fields change
cal_fields = {'scheduled_date', 'time_start', 'time_end',
'duration_hours', 'technician_id', 'task_type',
'partner_id', 'address_street', 'address_city', 'notes'}
if cal_fields & set(vals.keys()):
self._sync_calendar_event()
return res
def _sync_calendar_event(self):
"""Create or update a linked calendar.event for external calendar sync.
Only syncs tasks that have a scheduled date and an assigned technician.
Uses sudo() because portal users should not need calendar write access.
"""
CalendarEvent = self.env['calendar.event'].sudo()
for task in self:
if not task.datetime_start or not task.datetime_end or not task.technician_id:
if task.calendar_event_id:
task.calendar_event_id.unlink()
task.with_context(skip_travel_recalc=True).write({'calendar_event_id': False})
continue
partner = task.partner_id or task.sale_order_id.partner_id if task.sale_order_id else task.partner_id
client_name = partner.name if partner else ''
type_label = dict(self._fields['task_type'].selection).get(task.task_type, task.task_type or '')
event_name = f"{type_label}: {client_name}" if client_name else f"{type_label} - {task.name}"
location_parts = [task.address_street, task.address_city]
location = ', '.join(p for p in location_parts if p) or ''
description_parts = []
if task.sale_order_id:
description_parts.append(f"SO: {task.sale_order_id.name}")
if task.notes:
description_parts.append(task.notes)
vals = {
'name': event_name,
'start': task.datetime_start,
'stop': task.datetime_end,
'user_id': task.technician_id.id,
'location': location,
'partner_ids': [(6, 0, [task.technician_id.partner_id.id])],
'show_as': 'busy',
'description': '\n'.join(description_parts),
}
if task.calendar_event_id:
task.calendar_event_id.write(vals)
else:
event = CalendarEvent.create(vals)
task.with_context(skip_travel_recalc=True).write({'calendar_event_id': event.id})
@api.model
def _fill_address_vals(self, vals, partner):
"""Helper to fill address vals dict from a partner record."""
@@ -1674,19 +1781,43 @@ class FusionTechnicianTask(models.Model):
return 0.0, 0.0
def _recalculate_combos_travel(self, combos):
"""Recalculate travel for a set of (tech_id, date) combinations."""
"""Recalculate travel for a set of (tech_id, date) combinations.
Start-point priority per technician (for today only):
1. Actual GPS from today's fusion_clock check-in
2. Personal start address (x_fc_start_address)
3. Company default HQ address
For future dates, only 2 and 3 apply.
"""
ICP = self.env['ir.config_parameter'].sudo()
enabled = ICP.get_param('fusion_claims.google_distance_matrix_enabled', False)
if not enabled:
return
api_key = self._get_google_maps_api_key()
# Cache geocoded start addresses per technician to avoid repeated API calls
start_coords_cache = {}
today = fields.Date.today()
today_str = str(today)
today_tech_ids = {tid for tid, d in combos
if tid and str(d) == today_str}
clock_locations = {}
if today_tech_ids:
clock_locations = self._get_clock_in_locations(today_tech_ids, today)
for tech_id, date in combos:
if not tech_id or not date:
continue
cache_key = (tech_id, str(date))
if cache_key not in start_coords_cache:
if str(date) == today_str and tech_id in clock_locations:
cl = clock_locations[tech_id]
start_coords_cache[cache_key] = (cl['lat'], cl['lng'])
else:
addr = self._get_technician_start_address(tech_id)
start_coords_cache[cache_key] = self._geocode_address_string(addr, api_key)
all_day_tasks = self.sudo().search([
'|',
('technician_id', '=', tech_id),
@@ -1697,12 +1828,7 @@ class FusionTechnicianTask(models.Model):
if not all_day_tasks:
continue
# Get this technician's start location (personal or company default)
if tech_id not in start_coords_cache:
addr = self._get_technician_start_address(tech_id)
start_coords_cache[tech_id] = self._geocode_address_string(addr, api_key)
prev_lat, prev_lng = start_coords_cache[tech_id]
prev_lat, prev_lng = start_coords_cache[cache_key]
for i, task in enumerate(all_day_tasks):
if not (task.address_lat and task.address_lng):
task._geocode_address()
@@ -1710,7 +1836,7 @@ class FusionTechnicianTask(models.Model):
if prev_lat and prev_lng and task.address_lat and task.address_lng:
task.with_context(skip_travel_recalc=True)._calculate_travel_time(prev_lat, prev_lng)
travel_vals['previous_task_id'] = all_day_tasks[i - 1].id if i > 0 else False
travel_vals['travel_origin'] = 'Start Location' if i == 0 else f'Task {all_day_tasks[i - 1].name}'
travel_vals['travel_origin'] = 'Clock-In Location' if i == 0 and str(date) == today_str and tech_id in clock_locations else ('Start Location' if i == 0 else f'Task {all_day_tasks[i - 1].name}')
if travel_vals:
task.with_context(skip_travel_recalc=True).write(travel_vals)
prev_lat = task.address_lat or prev_lat
@@ -2044,53 +2170,66 @@ class FusionTechnicianTask(models.Model):
)
def _notify_scheduler_on_completion(self):
"""Send an Odoo notification to whoever created/scheduled the task."""
"""Send an Odoo notification to the person who scheduled the task.
Shadow tasks skip this -- the push-back to the source instance
triggers the notification there where the real scheduler exists.
"""
self.ensure_one()
# Notify the task creator (scheduler) if they're not the technician
if self.create_uid and self.create_uid not in self.all_technician_ids:
task_type_label = dict(self._fields['task_type'].selection).get(self.task_type, self.task_type)
task_url = f'/web#id={self.id}&model=fusion.technician.task&view_type=form'
client_name = self.partner_id.name or 'N/A'
order = self.sale_order_id or self.purchase_order_id
case_ref = order.name if order else ''
# Build address string
addr_parts = [p for p in [
self.address_street,
self.address_street2,
self.address_city,
self.address_state_id.name if self.address_state_id else '',
self.address_zip,
] if p]
address_str = ', '.join(addr_parts) or 'No address'
# Build subject
subject = f'Task Completed: {client_name}'
if case_ref:
subject += f' ({case_ref})'
body = Markup(
f'<div style="background:#d4edda;border-left:4px solid #28a745;padding:12px;border-radius:4px;margin-bottom:8px;">'
f'<p style="margin:0 0 8px 0;"><i class="fa fa-check-circle" style="color:#28a745;"></i> '
f'<strong>{task_type_label} Completed</strong></p>'
f'<table style="width:100%;border-collapse:collapse;">'
f'<tr><td style="padding:3px 8px 3px 0;font-weight:bold;white-space:nowrap;vertical-align:top;">Client:</td>'
f'<td style="padding:3px 0;">{client_name}</td></tr>'
f'<tr><td style="padding:3px 8px 3px 0;font-weight:bold;white-space:nowrap;vertical-align:top;">Case:</td>'
f'<td style="padding:3px 0;">{case_ref or "N/A"}</td></tr>'
f'<tr><td style="padding:3px 8px 3px 0;font-weight:bold;white-space:nowrap;vertical-align:top;">Task:</td>'
f'<td style="padding:3px 0;">{self.name}</td></tr>'
f'<tr><td style="padding:3px 8px 3px 0;font-weight:bold;white-space:nowrap;vertical-align:top;">Technician(s):</td>'
f'<td style="padding:3px 0;">{self.all_technician_names or self.technician_id.name}</td></tr>'
f'<tr><td style="padding:3px 8px 3px 0;font-weight:bold;white-space:nowrap;vertical-align:top;">Location:</td>'
f'<td style="padding:3px 0;">{address_str}</td></tr>'
f'</table>'
f'<p style="margin:8px 0 0 0;"><a href="{task_url}">View Task</a></p>'
f'</div>'
)
# Use Odoo's internal notification system
self.env['mail.thread'].sudo().message_notify(
partner_ids=[self.create_uid.partner_id.id],
body=body,
subject=subject,
)
if self.x_fc_sync_source:
return
recipient = None
if self.sale_order_id and self.sale_order_id.user_id:
recipient = self.sale_order_id.user_id
elif self.purchase_order_id and self.purchase_order_id.user_id:
recipient = self.purchase_order_id.user_id
elif self.create_uid:
recipient = self.create_uid
if not recipient or recipient in self.all_technician_ids:
return
task_type_label = dict(self._fields['task_type'].selection).get(self.task_type, self.task_type)
task_url = f'/web#id={self.id}&model=fusion.technician.task&view_type=form'
client_name = self.client_display_name or 'N/A'
order = self.sale_order_id or self.purchase_order_id
case_ref = order.name if order else ''
addr_parts = [p for p in [
self.address_street,
self.address_street2,
self.address_city,
self.address_state_id.name if self.address_state_id else '',
self.address_zip,
] if p]
address_str = ', '.join(addr_parts) or 'No address'
subject = f'Task Completed: {client_name}'
if case_ref:
subject += f' ({case_ref})'
body = Markup(
f'<div style="background:#d4edda;border-left:4px solid #28a745;padding:12px;border-radius:4px;margin-bottom:8px;">'
f'<p style="margin:0 0 8px 0;"><i class="fa fa-check-circle" style="color:#28a745;"></i> '
f'<strong>{task_type_label} Completed</strong></p>'
f'<table style="width:100%;border-collapse:collapse;">'
f'<tr><td style="padding:3px 8px 3px 0;font-weight:bold;white-space:nowrap;vertical-align:top;">Client:</td>'
f'<td style="padding:3px 0;">{client_name}</td></tr>'
f'<tr><td style="padding:3px 8px 3px 0;font-weight:bold;white-space:nowrap;vertical-align:top;">Case:</td>'
f'<td style="padding:3px 0;">{case_ref or "N/A"}</td></tr>'
f'<tr><td style="padding:3px 8px 3px 0;font-weight:bold;white-space:nowrap;vertical-align:top;">Task:</td>'
f'<td style="padding:3px 0;">{self.name}</td></tr>'
f'<tr><td style="padding:3px 8px 3px 0;font-weight:bold;white-space:nowrap;vertical-align:top;">Technician(s):</td>'
f'<td style="padding:3px 0;">{self.all_technician_names or self.technician_id.name}</td></tr>'
f'<tr><td style="padding:3px 8px 3px 0;font-weight:bold;white-space:nowrap;vertical-align:top;">Location:</td>'
f'<td style="padding:3px 0;">{address_str}</td></tr>'
f'</table>'
f'<p style="margin:8px 0 0 0;"><a href="{task_url}">View Task</a></p>'
f'</div>'
)
self.env['mail.thread'].sudo().message_notify(
partner_ids=[recipient.partner_id.id],
body=body,
subject=subject,
)
# ------------------------------------------------------------------
# TASK EMAIL NOTIFICATIONS
@@ -2483,7 +2622,13 @@ class FusionTechnicianTask(models.Model):
@api.model
def _get_clock_in_locations(self, tech_ids, today):
"""Get today's clock-in lat/lng from fusion_clock if installed."""
"""Get today's clock-in lat/lng from fusion_clock if installed.
Uses the technician's actual GPS position at the moment they clocked
in (from the activity log), not the geofenced location's fixed
coordinates. Falls back to the geofence center if no activity-log
GPS is available.
"""
result = {}
try:
module = self.env['ir.module.module'].sudo().search([
@@ -2498,6 +2643,7 @@ class FusionTechnicianTask(models.Model):
try:
Attendance = self.env['hr.attendance'].sudo()
Employee = self.env['hr.employee'].sudo()
ActivityLog = self.env['fusion.clock.activity.log'].sudo()
except KeyError:
return result
@@ -2522,12 +2668,31 @@ class FusionTechnicianTask(models.Model):
uid = emp_to_user.get(att.employee_id.id)
if not uid or uid in result:
continue
loc = att.x_fclk_location_id if hasattr(att, 'x_fclk_location_id') else False
if loc and loc.latitude and loc.longitude:
lat, lng, address = 0, 0, ''
log = ActivityLog.search([
('attendance_id', '=', att.id),
('log_type', '=', 'clock_in'),
('latitude', '!=', 0),
('longitude', '!=', 0),
], limit=1)
if log:
lat, lng = log.latitude, log.longitude
loc = att.x_fclk_location_id if hasattr(att, 'x_fclk_location_id') else False
address = (loc.address or loc.name) if loc else ''
if not lat or not lng:
loc = att.x_fclk_location_id if hasattr(att, 'x_fclk_location_id') else False
if loc and loc.latitude and loc.longitude:
lat, lng = loc.latitude, loc.longitude
address = loc.address or loc.name or ''
if lat and lng:
result[uid] = {
'lat': loc.latitude,
'lng': loc.longitude,
'address': loc.address or loc.name or '',
'lat': lat,
'lng': lng,
'address': address,
'source': 'clock_in',
}