# -*- 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_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. 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 '', } 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), ('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() 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 '' # 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) 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': 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, }, }