fix(task_sync): defend against silent sync_id integrity violations
The cross-instance sync silently drops tasks when x_fc_tech_sync_id is missing on the technician, and silently collapses duplicates via dict comprehension. Both make sync break in ways that are invisible until someone notices a missing task on the other instance. - _get_remote_tech_map / _get_local_syncid_to_uid: warn on duplicates - _push_tasks_to_remote: info-log when a task is skipped because the tech has no sync_id or no remote counterpart - res.users onchange: warn in the form when entering a sync_id that is already used by another active field staff Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
{
|
{
|
||||||
'name': 'Fusion Tasks',
|
'name': 'Fusion Tasks',
|
||||||
'version': '19.0.1.0.0',
|
'version': '19.0.1.1.0',
|
||||||
'category': 'Services/Field Service',
|
'category': 'Services/Field Service',
|
||||||
'summary': 'Technician scheduling, route planning, GPS tracking, and cross-instance sync.',
|
'summary': 'Technician scheduling, route planning, GPS tracking, and cross-instance sync.',
|
||||||
'author': 'Nexa Systems Inc.',
|
'author': 'Nexa Systems Inc.',
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
# Copyright 2024-2026 Nexa Systems Inc.
|
# Copyright 2024-2026 Nexa Systems Inc.
|
||||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||||
|
|
||||||
from odoo import models, fields
|
from odoo import models, fields, api
|
||||||
|
|
||||||
|
|
||||||
class ResUsers(models.Model):
|
class ResUsers(models.Model):
|
||||||
@@ -24,3 +24,26 @@ class ResUsers(models.Model):
|
|||||||
'Must be the same value on all instances for the same person.',
|
'Must be the same value on all instances for the same person.',
|
||||||
copy=False,
|
copy=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@api.onchange('x_fc_tech_sync_id')
|
||||||
|
def _onchange_x_fc_tech_sync_id_dup_warning(self):
|
||||||
|
if not self.x_fc_tech_sync_id:
|
||||||
|
return
|
||||||
|
dup = self.env['res.users'].sudo().search([
|
||||||
|
('id', '!=', self._origin.id or self.id),
|
||||||
|
('x_fc_tech_sync_id', '=', self.x_fc_tech_sync_id),
|
||||||
|
('x_fc_is_field_staff', '=', True),
|
||||||
|
('active', '=', True),
|
||||||
|
], limit=1)
|
||||||
|
if dup:
|
||||||
|
return {
|
||||||
|
'warning': {
|
||||||
|
'title': "Duplicate Tech Sync ID",
|
||||||
|
'message': (
|
||||||
|
f"Tech Sync ID {self.x_fc_tech_sync_id!r} is already used "
|
||||||
|
f"by {dup.login} ({dup.partner_id.name}). Cross-instance "
|
||||||
|
f"task sync only routes to ONE user per sync ID — "
|
||||||
|
f"pick a unique value or only one tech's tasks will sync."
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -135,11 +135,18 @@ class FusionTaskSyncConfig(models.Model):
|
|||||||
], {'fields': ['id', 'x_fc_tech_sync_id']})
|
], {'fields': ['id', 'x_fc_tech_sync_id']})
|
||||||
if not remote_users:
|
if not remote_users:
|
||||||
return {}
|
return {}
|
||||||
return {
|
by_sync_id = {}
|
||||||
ru['x_fc_tech_sync_id']: ru['id']
|
for ru in remote_users:
|
||||||
for ru in remote_users
|
sid = ru.get('x_fc_tech_sync_id')
|
||||||
if ru.get('x_fc_tech_sync_id')
|
if not sid:
|
||||||
}
|
continue
|
||||||
|
if sid in by_sync_id:
|
||||||
|
_logger.warning(
|
||||||
|
"Task sync: duplicate x_fc_tech_sync_id %r on remote %s "
|
||||||
|
"(uids %d and %d) — only the last seen will be reachable",
|
||||||
|
sid, self.name, by_sync_id[sid], ru['id'])
|
||||||
|
by_sync_id[sid] = ru['id']
|
||||||
|
return by_sync_id
|
||||||
|
|
||||||
def _get_local_syncid_to_uid(self):
|
def _get_local_syncid_to_uid(self):
|
||||||
"""Build {x_fc_tech_sync_id: local_user_id} for local field staff."""
|
"""Build {x_fc_tech_sync_id: local_user_id} for local field staff."""
|
||||||
@@ -148,7 +155,16 @@ class FusionTaskSyncConfig(models.Model):
|
|||||||
('x_fc_tech_sync_id', '!=', False),
|
('x_fc_tech_sync_id', '!=', False),
|
||||||
('active', '=', True),
|
('active', '=', True),
|
||||||
])
|
])
|
||||||
return {u.x_fc_tech_sync_id: u.id for u in techs}
|
by_sync_id = {}
|
||||||
|
for u in techs:
|
||||||
|
sid = u.x_fc_tech_sync_id
|
||||||
|
if sid in by_sync_id:
|
||||||
|
_logger.warning(
|
||||||
|
"Task sync: duplicate x_fc_tech_sync_id %r locally "
|
||||||
|
"(uids %d and %d) — only the last seen will be reachable",
|
||||||
|
sid, by_sync_id[sid], u.id)
|
||||||
|
by_sync_id[sid] = u.id
|
||||||
|
return by_sync_id
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Connection test
|
# Connection test
|
||||||
@@ -219,9 +235,15 @@ class FusionTaskSyncConfig(models.Model):
|
|||||||
for task in tasks:
|
for task in tasks:
|
||||||
sync_id = local_map.get(task.technician_id.id)
|
sync_id = local_map.get(task.technician_id.id)
|
||||||
if not sync_id:
|
if not sync_id:
|
||||||
|
_logger.info(
|
||||||
|
"Task sync: skipping task %s — technician %s has no x_fc_tech_sync_id",
|
||||||
|
task.name, task.technician_id.login or task.technician_id.id)
|
||||||
continue
|
continue
|
||||||
remote_tech_uid = remote_map.get(sync_id)
|
remote_tech_uid = remote_map.get(sync_id)
|
||||||
if not remote_tech_uid:
|
if not remote_tech_uid:
|
||||||
|
_logger.info(
|
||||||
|
"Task sync: skipping task %s — sync_id %r has no matching tech on %s",
|
||||||
|
task.name, sync_id, self.name)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Map additional technicians to remote user IDs
|
# Map additional technicians to remote user IDs
|
||||||
|
|||||||
Reference in New Issue
Block a user