changes
This commit is contained in:
@@ -163,7 +163,7 @@
|
||||
<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">5</field>
|
||||
<field name="interval_number">2</field>
|
||||
<field name="interval_type">minutes</field>
|
||||
<field name="active">True</field>
|
||||
</record>
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
|
||||
|
||||
@@ -496,15 +496,21 @@ export class FusionTaskMapController extends Component {
|
||||
if (!loc.latitude || !loc.longitude) continue;
|
||||
const pos = { lat: loc.latitude, lng: loc.longitude };
|
||||
const initials = initialsOf(loc.name);
|
||||
const src = loc.sync_instance || this.localInstanceId || "";
|
||||
const isRemote = src && src !== this.localInstanceId;
|
||||
const pinColor = isRemote
|
||||
? (SOURCE_COLORS[src] || "#6c757d")
|
||||
: "#1d4ed8";
|
||||
const srcLabel = src ? src.charAt(0).toUpperCase() + src.slice(1) : "";
|
||||
const svg =
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" width="48" height="48" 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"/>` +
|
||||
`<rect x="2" y="2" width="44" height="44" rx="12" ry="12" fill="${pinColor}" stroke="#fff" stroke-width="3"/>` +
|
||||
`<text x="24" y="30" text-anchor="middle" fill="#fff" font-size="17" font-family="Arial,Helvetica,sans-serif" font-weight="bold">${initials}</text>` +
|
||||
`</svg>`;
|
||||
const marker = new google.maps.Marker({
|
||||
position: pos,
|
||||
map: this.map,
|
||||
title: loc.name,
|
||||
title: loc.name + (isRemote ? ` [${srcLabel}]` : ""),
|
||||
icon: {
|
||||
url: "data:image/svg+xml;charset=UTF-8," + encodeURIComponent(svg),
|
||||
scaledSize: new google.maps.Size(44, 44),
|
||||
@@ -515,8 +521,9 @@ export class FusionTaskMapController extends Component {
|
||||
marker.addListener("click", () => {
|
||||
this.infoWindow.setContent(`
|
||||
<div style="font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;min-width:200px;color:#1f2937;">
|
||||
<div style="background:#1d4ed8;color:#fff;padding:10px 14px;">
|
||||
<div style="background:${pinColor};color:#fff;padding:10px 14px;">
|
||||
<strong><i class="fa fa-user" style="margin-right:6px;"></i>${loc.name}</strong>
|
||||
${srcLabel ? `<span style="float:right;font-size:10px;font-weight:600;background:rgba(255,255,255,.2);padding:2px 8px;border-radius:8px;">${srcLabel}</span>` : ""}
|
||||
</div>
|
||||
<div style="padding:12px 14px;font-size:13px;line-height:1.8;color:#1f2937;">
|
||||
<div><strong style="color:#374151;">Last seen:</strong> <span style="color:#111827;">${loc.logged_at || "Unknown"}</span></div>
|
||||
|
||||
Reference in New Issue
Block a user