# -*- 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', 'additional_technician_ids', 'task_type', 'status', 'scheduled_date', 'time_start', 'time_end', 'duration_hours', 'address_street', 'address_street2', 'address_city', 'address_zip', 'address_state_id', 'address_buzz_code', 'address_lat', 'address_lng', 'priority', 'partner_id', 'partner_phone', 'pod_required', 'description', 'travel_time_minutes', 'travel_distance_km', 'travel_origin', 'completed_latitude', 'completed_longitude', 'action_latitude', 'action_longitude', 'completion_datetime', ] TERMINAL_STATUSES = ('completed', 'cancelled') 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 (uses /jsonrpc dispatch, muted on receiving side) # ------------------------------------------------------------------ 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.""" 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. Maps additional_technician_ids via sync IDs so the remote instance also blocks those technicians' schedules. """ 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 # Map additional technicians to remote user IDs remote_additional_ids = [] for tech in task.additional_technician_ids: add_sync_id = local_map.get(tech.id) if add_sync_id: remote_add_uid = remote_map.get(add_sync_id) if remote_add_uid: remote_additional_ids.append(remote_add_uid) 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, 'additional_technician_ids': [(6, 0, remote_additional_ids)], '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 '', 'travel_time_minutes': task.travel_time_minutes or 0, 'travel_distance_km': float(task.travel_distance_km or 0), 'travel_origin': task.travel_origin or '', 'completed_latitude': float(task.completed_latitude or 0), 'completed_longitude': float(task.completed_longitude or 0), 'action_latitude': float(task.action_latitude or 0), 'action_longitude': float(task.action_longitude or 0), } if task.completion_datetime: task_data['completion_datetime'] = str(task.completion_datetime) 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) @api.model def _push_shadow_status(self, shadow_tasks): """Push local status changes on shadow tasks back to their source instance. When a tech changes a shadow task status locally, update the original task on the remote instance and trigger the appropriate client emails there. Only the parent (originating) instance sends client-facing emails -- the child instance skips them via x_fc_sync_source guards. """ 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) if task.completed_latitude and task.completed_longitude: update_vals['completed_latitude'] = task.completed_latitude update_vals['completed_longitude'] = task.completed_longitude if task.action_latitude and task.action_longitude: update_vals['action_latitude'] = task.action_latitude update_vals['action_longitude'] = task.action_longitude 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) self._trigger_parent_notifications(config, task) except Exception: _logger.exception( "Failed to push status for shadow task %s to %s", task.name, config.name) @api.model def _push_technician_location(self, user_id, latitude, longitude, accuracy=0): """Push a technician's location update to all remote instances. Called when a technician performs a task action (en_route, complete) so the other instance immediately knows where the tech is, without waiting for the next pull cron cycle. """ configs = self.sudo().search([('active', '=', True)]) if not configs: return local_map = configs[0]._get_local_tech_map() sync_id = local_map.get(user_id) if not sync_id: return for config in configs: try: remote_map = config._get_remote_tech_map() remote_uid = remote_map.get(sync_id) if not remote_uid: continue # Create location record on remote instance config._rpc( 'fusion.technician.location', 'create', [[{ 'user_id': remote_uid, 'latitude': latitude, 'longitude': longitude, 'accuracy': accuracy, 'source': 'sync', 'sync_instance': configs[0]._get_local_instance_id(), }]]) except Exception: _logger.warning( "Failed to push location for tech %s to %s", user_id, config.name) def _trigger_parent_notifications(self, config, task): """After pushing a shadow status, trigger appropriate emails and notifications on the parent instance so the client gets notified exactly once (from the originating instance only).""" remote_id = task.x_fc_sync_remote_id if task.status == 'completed': for method in ('_notify_scheduler_on_completion', '_send_task_completion_email'): try: config._rpc('fusion.technician.task', method, [[remote_id]]) except Exception: _logger.warning( "Could not call %s on remote for %s", method, task.name) elif task.status == 'en_route': try: config._rpc( 'fusion.technician.task', '_send_task_en_route_email', [[remote_id]]) except Exception: _logger.warning( "Could not trigger en-route email on remote for %s", task.name) elif task.status == 'cancelled': try: config._rpc( 'fusion.technician.task', '_send_task_cancelled_email', [[remote_id]]) except Exception: _logger.warning( "Could not trigger cancel email on remote for %s", task.name) # ------------------------------------------------------------------ # PULL: cron-based full reconciliation # ------------------------------------------------------------------ @api.model def _cron_pull_remote_tasks(self): """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, }) 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. 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: 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), ('additional_technician_ids', '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() affected_combos = 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 '' 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 = [] remote_add_raw = rt.get('additional_technician_ids', []) if remote_add_raw and isinstance(remote_add_raw, list): for add_uid in remote_add_raw: add_sync_id = remote_syncid_by_uid.get(add_uid) if add_sync_id: local_add_uid = local_syncid_to_uid.get(add_sync_id) 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, 'x_fc_sync_remote_id': rt['id'], 'name': f"[{self.instance_id.upper()}] {rt.get('name', '')}", 'technician_id': local_uid, 'additional_technician_ids': [(6, 0, local_additional_ids)], 'task_type': rt.get('task_type', 'delivery'), 'status': rt.get('status', 'scheduled'), '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), '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_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, 'travel_time_minutes': rt.get('travel_time_minutes', 0), 'travel_distance_km': rt.get('travel_distance_km', 0), 'travel_origin': rt.get('travel_origin', ''), 'completed_latitude': rt.get('completed_latitude', 0), 'completed_longitude': rt.get('completed_longitude', 0), 'action_latitude': rt.get('action_latitude', 0), 'action_longitude': rt.get('action_longitude', 0), } if rt.get('completion_datetime'): vals['completion_datetime'] = rt['completion_datetime'] 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)), ('scheduled_date', '>=', str(cutoff)), ('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 # ------------------------------------------------------------------ @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._pull_technician_locations() 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), ]) 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}. ' f'{shadow_count} shadow task(s), ' f'{loc_count} technician location(s) visible.'), 'type': 'success', 'sticky': False, }, }