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

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