410 lines
16 KiB
Python
410 lines
16 KiB
Python
# -*- 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', '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',
|
|
]
|
|
|
|
|
|
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
|
|
# ------------------------------------------------------------------
|
|
|
|
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.
|
|
execute_kw(db, uid, password, model, method, [args], {kwargs})
|
|
"""
|
|
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."""
|
|
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
|
|
|
|
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,
|
|
'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 '',
|
|
}
|
|
|
|
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)
|
|
|
|
# ------------------------------------------------------------------
|
|
# PULL: cron-based full reconciliation
|
|
# ------------------------------------------------------------------
|
|
|
|
@api.model
|
|
def _cron_pull_remote_tasks(self):
|
|
"""Cron job: pull tasks from all active remote instances."""
|
|
configs = self.sudo().search([('active', '=', True)])
|
|
for config in configs:
|
|
try:
|
|
config._pull_tasks_from_remote()
|
|
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."""
|
|
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),
|
|
('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()
|
|
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 ''
|
|
|
|
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,
|
|
'task_type': rt.get('task_type', 'delivery'),
|
|
'status': rt.get('status', 'scheduled'),
|
|
'scheduled_date': rt.get('scheduled_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_lat': rt.get('address_lat', 0),
|
|
'address_lng': rt.get('address_lng', 0),
|
|
'priority': rt.get('priority', 'normal'),
|
|
'x_fc_sync_client_name': client_name,
|
|
}
|
|
|
|
existing = Task.search([('x_fc_sync_uuid', '=', sync_uuid)], limit=1)
|
|
if existing:
|
|
existing.write(vals)
|
|
else:
|
|
vals['sale_order_id'] = False
|
|
Task.create([vals])
|
|
|
|
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:
|
|
stale_shadows.write({'active': False, 'status': 'cancelled'})
|
|
_logger.info("Deactivated %d stale shadow tasks from %s",
|
|
len(stale_shadows), self.instance_id)
|
|
|
|
# ------------------------------------------------------------------
|
|
# 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.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),
|
|
])
|
|
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.',
|
|
'type': 'success',
|
|
'sticky': False,
|
|
},
|
|
}
|