# -*- coding: utf-8 -*- """ Proxy/caché entre WACRM (Supabase) y Odoo. Expone endpoints REST para el frontend SKEEN. """ from odoo import http, fields from odoo.http import request from .frontend import json_response, _parse_json_body from .auth import require_role, require_perm import os import logging import requests import re import time import uuid from datetime import datetime, timedelta _logger = logging.getLogger(__name__) def _get_supabase_config(): """Lee configuración de Supabase desde variables de entorno o ir.config_parameter.""" get_param = request.env['ir.config_parameter'].sudo().get_param url = os.environ.get('SUPABASE_URL') or get_param('skeen.supabase.url') key = os.environ.get('SUPABASE_SERVICE_ROLE_KEY') or get_param('skeen.supabase.service_role_key') if not url or not key: raise Exception('Falta configuración SUPABASE_URL o SUPABASE_SERVICE_ROLE_KEY') return url.rstrip('/'), key def _supabase_request(method, table, params=None, json=None, headers_extra=None): url, key = _get_supabase_config() headers = { 'apikey': key, 'Authorization': f'Bearer {key}', 'Content-Type': 'application/json', 'Accept': 'application/json', } if headers_extra: headers.update(headers_extra) response = requests.request(method, f"{url}/rest/v1/{table}", headers=headers, params=params or {}, json=json, timeout=30) response.raise_for_status() return response.json() if response.text else {} def _get_wacrm_config(): """Lee configuración de WACRM API desde variables de entorno o ir.config_parameter.""" get_param = request.env['ir.config_parameter'].sudo().get_param url = os.environ.get('WACRM_API_URL') or get_param('skeen.wacrm.api_url') or 'http://localhost:3000' key = os.environ.get('WACRM_API_KEY') or get_param('skeen.wacrm.api_key') if not url or not key: raise Exception('Falta configuración WACRM_API_URL o WACRM_API_KEY') return url.rstrip('/'), key def _wacrm_request(method, path, json=None, params=None): url, key = _get_wacrm_config() headers = { 'Authorization': f'Bearer {key}', 'Content-Type': 'application/json', 'Accept': 'application/json', } response = requests.request(method, f"{url}/api/v1{path}", headers=headers, json=json, params=params or {}, timeout=30) response.raise_for_status() return response.json() def _get_hermes_bridge_config(): """Lee configuración del bridge de Hermes.""" get_param = request.env['ir.config_parameter'].sudo().get_param url = os.environ.get('HERMES_BRIDGE_URL') or get_param('skeen.hermes.bridge_url') or 'http://localhost:8090' return url.rstrip('/') def _hermes_bridge_request(path, json=None): url = _get_hermes_bridge_config() headers = { 'Content-Type': 'application/json', 'Accept': 'application/json', } response = requests.request('POST', f"{url}{path}", headers=headers, json=json or {}, timeout=30) response.raise_for_status() return response.json() def _supabase_request_paginated(table, params=None, page_size=1000): """Itera sobre todas las páginas de resultados de PostgREST. PostgREST limita `limit` al máximo configurado (por defecto 1000). Usamos offset para traer todos los registros en lotes. """ all_data = [] offset = 0 params = dict(params or {}) params['limit'] = page_size params['order'] = params.get('order', 'id.asc') while True: params['offset'] = offset page = _supabase_request('GET', table, params) if not page: break all_data.extend(page) if len(page) < page_size: break offset += page_size return all_data def _parse_datetime(value): if not value: return None try: from datetime import timezone dt = datetime.fromisoformat(value.replace('Z', '+00:00')) if dt.tzinfo: dt = dt.astimezone(timezone.utc).replace(tzinfo=None) return dt except Exception: return None class SkeenWacrmProxyController(http.Controller): """Endpoints proxy WACRM para el frontend.""" # ============================================================ # Sincronización # ============================================================ @http.route('/skeen/frontend/v1/wacrm/sync', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) def sync_wacrm(self, **kw): """Sincroniza contactos, conversaciones, mensajes, pipelines y deals desde Supabase hacia Odoo. El frontend tiene auto-sync cada 15 s y un botón manual: si ambos coinciden, dos transacciones escriben las mismas filas del caché y Postgres aborta una con "could not serialize access" (500). Se reintenta con la transacción limpia, que es lo que hace Odoo en sus propios reintentos. """ # Un solo sync a la vez: el frontend auto-sincroniza cada 15 s y tiene # botón manual; dos syncs simultáneos chocan en las mismas filas del # caché y Postgres aborta uno. El lock de transacción se libera solo. try: request.env.cr.execute( "SELECT pg_try_advisory_xact_lock(hashtext('skeen_wacrm_sync'))") libre = request.env.cr.fetchone()[0] except Exception: libre = True if not libre: return json_response({ 'status': 'busy', 'message': 'Ya hay una sincronización en curso; se actualizará en unos segundos', }) ultimo = None for intento in range(3): try: result = self._do_sync() return json_response({'status': 'success', 'result': result}) except Exception as e: ultimo = e if 'could not serialize' not in str(e).lower(): break request.env.cr.rollback() _logger.warning('Sync WACRM en conflicto (intento %s/3), reintentando', intento + 1) time.sleep(0.8 * (intento + 1)) _logger.exception('Error sincronizando WACRM') return json_response({'status': 'error', 'message': str(ultimo)}, 500) def _do_sync(self): account_id = None # 1. Contacts (paginado por si PostgREST limita a 1000 filas) contacts_data = _supabase_request_paginated('contacts', {'select': '*'}) contacts_map = {} for item in contacts_data: if not account_id and item.get('account_id'): account_id = item['account_id'] vals = { 'external_id': item['id'], 'account_id': item.get('account_id'), 'phone': item.get('phone'), 'name': item.get('name') or item.get('phone'), 'email': item.get('email'), 'company': item.get('company'), 'avatar_url': item.get('avatar_url'), 'created_at': _parse_datetime(item.get('created_at')), 'updated_at': _parse_datetime(item.get('updated_at')), } existing = request.env['skeen.wacrm.contact'].sudo().search([('external_id', '=', item['id'])], limit=1) if existing: existing.write(vals) contacts_map[item['id']] = existing else: contacts_map[item['id']] = request.env['skeen.wacrm.contact'].sudo().create(vals) # 2. Conversations # Además del upsert, se REFLEJAN ELIMINACIONES: lo que ya no exista # en Supabase se borra del caché local (antes el caché acumulaba # conversaciones borradas y la bandeja de SKEEN mostraba chats muertos). conversations_data = _supabase_request_paginated('conversations', {'select': '*'}) conversations_map = {} for item in conversations_data: vals = { 'external_id': item['id'], 'contact_id': contacts_map.get(item.get('contact_id'), request.env['skeen.wacrm.contact']).id if contacts_map.get(item.get('contact_id')) else False, 'status': item.get('status', 'open'), 'assigned_agent_id': item.get('assigned_agent_id') or None, 'assigned_agent': str(item.get('assigned_agent_id')) if item.get('assigned_agent_id') else None, 'last_message_text': item.get('last_message_text'), 'last_message_at': _parse_datetime(item.get('last_message_at')), 'unread_count': item.get('unread_count', 0), 'created_at': _parse_datetime(item.get('created_at')), 'updated_at': _parse_datetime(item.get('updated_at')), } existing = request.env['skeen.wacrm.conversation'].sudo().search([('external_id', '=', item['id'])], limit=1) if existing: existing.write(vals) conversations_map[item['id']] = existing else: conversations_map[item['id']] = request.env['skeen.wacrm.conversation'].sudo().create(vals) remote_conv_ids = set(conversations_map.keys()) stale_convs = request.env['skeen.wacrm.conversation'].sudo().search([ ('external_id', 'not in', list(remote_conv_ids))]) if remote_conv_ids else \ request.env['skeen.wacrm.conversation'].sudo().search([]) if stale_convs: stale_convs.unlink() conversations_map = {k: v for k, v in conversations_map.items()} # 3. Messages (todos los del origen, sin recorte) messages_data = _supabase_request_paginated( 'messages', {'select': '*', 'order': 'created_at.desc'}, page_size=1000 ) remote_msg_ids = {item['id'] for item in messages_data} stale_msgs = request.env['skeen.wacrm.message'].sudo().search([ ('external_id', 'not in', list(remote_msg_ids))]) if remote_msg_ids else \ request.env['skeen.wacrm.message'].sudo().search([]) stale_msgs.unlink() for item in messages_data: conversation = conversations_map.get(item.get('conversation_id')) if not conversation: continue vals = { 'external_id': item['id'], 'conversation_id': conversation.id, 'sender_type': {'contact': 'customer', 'user': 'agent'}.get(item.get('sender_type', 'customer'), item.get('sender_type', 'customer')), 'content_type': item.get('content_type', 'text'), 'content_text': item.get('content_text') or item.get('content'), 'media_url': item.get('media_url'), 'template_name': item.get('template_name'), 'status': item.get('status', 'sent'), 'created_at': _parse_datetime(item.get('created_at')), } existing = request.env['skeen.wacrm.message'].sudo().search([('external_id', '=', item['id'])], limit=1) if existing: existing.write(vals) else: request.env['skeen.wacrm.message'].sudo().create(vals) # 4. Pipelines & Stages pipelines_data = _supabase_request_paginated('pipelines', {'select': '*'}) pipelines_map = {} stages_map = {} for item in pipelines_data: vals = { 'external_id': item['id'], 'name': item.get('name'), 'created_at': _parse_datetime(item.get('created_at')), } existing = request.env['skeen.wacrm.pipeline'].sudo().search([('external_id', '=', item['id'])], limit=1) if existing: existing.write(vals) pipelines_map[item['id']] = existing else: pipelines_map[item['id']] = request.env['skeen.wacrm.pipeline'].sudo().create(vals) stages_data = _supabase_request_paginated('pipeline_stages', {'select': '*'}) for item in stages_data: vals = { 'external_id': item['id'], 'pipeline_id': pipelines_map.get(item.get('pipeline_id'), request.env['skeen.wacrm.pipeline']).id if pipelines_map.get(item.get('pipeline_id')) else False, 'name': item.get('name'), 'position': item.get('position', 0), 'color': item.get('color', '#3b82f6'), } existing = request.env['skeen.wacrm.stage'].sudo().search([('external_id', '=', item['id'])], limit=1) if existing: existing.write(vals) stages_map[item['id']] = existing else: stages_map[item['id']] = request.env['skeen.wacrm.stage'].sudo().create(vals) # 5. Deals # Mapa profiles.id → user_id (Supabase guarda assigned_to como profiles.id; # localmente usamos user_id, consistente con conversations.assigned_agent_id) profiles_usermap = {} try: for p in _supabase_request('GET', 'profiles', params={'select': 'id,user_id'}): profiles_usermap[p['id']] = p['user_id'] except Exception: pass deals_data = _supabase_request_paginated('deals', {'select': '*'}) for item in deals_data: vals = { 'external_id': item['id'], 'title': item.get('title') or 'Sin título', 'contact_id': contacts_map.get(item.get('contact_id'), request.env['skeen.wacrm.contact']).id if contacts_map.get(item.get('contact_id')) else False, 'conversation_id': item.get('conversation_id'), 'pipeline_id': pipelines_map.get(item.get('pipeline_id'), request.env['skeen.wacrm.pipeline']).id if pipelines_map.get(item.get('pipeline_id')) else False, 'stage_id': stages_map.get(item.get('stage_id'), request.env['skeen.wacrm.stage']).id if stages_map.get(item.get('stage_id')) else False, 'value': float(item.get('value', 0) or 0), 'currency': item.get('currency', 'USD'), 'status': item.get('status', 'open'), 'expected_close_date': item.get('expected_close_date'), 'notes': item.get('notes'), 'assigned_to': profiles_usermap.get(item.get('assigned_to')) if item.get('assigned_to') else None, 'created_at': _parse_datetime(item.get('created_at')), 'updated_at': _parse_datetime(item.get('updated_at')), } existing = request.env['skeen.wacrm.deal'].sudo().search([('external_id', '=', item['id'])], limit=1) if existing: existing.write(vals) else: request.env['skeen.wacrm.deal'].sudo().create(vals) # 6. Auto-crear deals: contactos con conversación sin deal abierto. # Se crean EN SUPABASE (la app WACRM nativa los ve) y se espejan local. # Idempotente: un deal abierto por contacto. deals_created = 0 try: if conversations_map and pipelines_map and account_id: pipeline = sorted(pipelines_map.values(), key=lambda p: p.name or '')[0] stage = request.env['skeen.wacrm.stage'].sudo().search( [('pipeline_id', '=', pipeline.id)], order='position asc', limit=1) # user_id requerido por Supabase: el primer profile (owner de la cuenta) owner = _supabase_request('GET', 'profiles', params={ 'select': 'user_id', 'order': 'created_at.asc', 'limit': 1}) owner_id = owner[0]['user_id'] if owner else None if stage and owner_id: Deal = request.env['skeen.wacrm.deal'].sudo() # Un deal por contacto: no crear si ya tiene cualquier deal (open o ganado) contacts_with_deals = {str(d.get('contact_id')) for d in deals_data if d.get('contact_id')} for conv in conversations_map.values(): contact = conv.contact_id if not contact: continue if contact.external_id in contacts_with_deals: continue if Deal.search([('contact_id', '=', contact.id)], limit=1): continue new_id = str(uuid.uuid4()) try: _supabase_request('POST', 'deals', json={ 'id': new_id, 'user_id': owner_id, 'account_id': account_id, 'pipeline_id': pipeline.external_id, 'stage_id': stage.external_id, 'contact_id': contact.external_id, 'conversation_id': conv.external_id, 'title': contact.name or contact.phone or 'Lead WhatsApp', 'value': 0, 'currency': 'MXN', 'status': 'open', }, headers_extra={'Prefer': 'return=minimal'}) Deal.create({ 'external_id': new_id, 'title': contact.name or contact.phone or 'Lead WhatsApp', 'contact_id': contact.id, 'conversation_id': conv.external_id, 'pipeline_id': pipeline.id, 'stage_id': stage.id, 'value': 0, 'currency': 'MXN', 'status': 'open', }) contacts_with_deals.add(contact.external_id) deals_created += 1 except Exception as e: _logger.warning('No se pudo auto-crear deal para contacto %s: %s', contact.external_id, e) except Exception as e: _logger.warning('Auto-deals omitidos: %s', e) return { 'contacts': len(contacts_data), 'conversations': len(conversations_data), 'messages': len(messages_data), 'pipelines': len(pipelines_data), 'stages': len(stages_data), 'deals': len(deals_data), 'deals_created': deals_created, 'account_id': account_id, } # ============================================================ # Mensajes # ============================================================ @http.route('/skeen/frontend/v1/wacrm/messages', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('lectura') @require_perm('whatsapp.responder') def send_message(self, **kw): try: data = _parse_json_body() conversation_id = data.get('conversation_id') to = data.get('to') text = data.get('text') assigned_agent_id = data.get('assigned_agent_id') if not text: return json_response({'status': 'error', 'message': 'Falta texto del mensaje'}, 400) if not conversation_id and not to: return json_response({'status': 'error', 'message': 'Falta conversation_id o destinatario'}, 400) # Resolver teléfono de destino phone = to conv_external_id = conversation_id if conversation_id and not phone: conv = request.env['skeen.wacrm.conversation'].sudo().search([('external_id', '=', conversation_id)], limit=1) if conv and conv.contact_id: phone = conv.contact_id.phone elif conv: phone = conv.contact_phone # Normalizar a E.164 if phone and not phone.startswith('+'): digits = re.sub(r'\D', '', phone) phone = '+' + digits.lstrip('0') if digits else '' if not phone: return json_response({'status': 'error', 'message': 'No se pudo resolver el teléfono destino'}, 400) # Enviar mensaje vía bridge de Hermes (tiene la API key real de WACRM) result = _hermes_bridge_request('/send-message', { 'conversation_id': conv_external_id, 'text': text, 'assigned_agent_id': assigned_agent_id, }) return json_response({'status': 'success', 'message': 'Mensaje enviado', 'result': result}) except Exception as e: _logger.exception('Error enviando mensaje WACRM') return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/wacrm/messages', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) def list_messages(self, **kw): try: domain = [] if kw.get('conversation_id'): conv = request.env['skeen.wacrm.conversation'].sudo().search([('external_id', '=', kw.get('conversation_id'))], limit=1) if conv: domain.append(('conversation_id', '=', conv.id)) if kw.get('contact_id'): contact = request.env['skeen.wacrm.contact'].sudo().search([('external_id', '=', kw.get('contact_id'))], limit=1) if contact: domain.append(('contact_id', '=', contact.id)) if kw.get('search'): search = kw.get('search') domain += ['|', ('content_text', 'ilike', search), ('contact_name', 'ilike', search)] messages = request.env['skeen.wacrm.message'].sudo().search(domain, order='created_at desc', limit=200) return json_response({ 'status': 'success', 'messages': [self._message_to_dict(m) for m in messages], }) except Exception as e: _logger.exception('Error listando mensajes WACRM') return json_response({'status': 'error', 'message': str(e)}, 500) def _supabase_message_to_dict(self, m): """Mensaje de Supabase en el formato que consume el frontend.""" return { 'id': m.get('id'), 'external_id': m.get('id'), 'conversation_id': m.get('conversation_id'), 'sender_type': {'contact': 'customer', 'user': 'agent'}.get(m.get('sender_type', 'customer'), m.get('sender_type', 'customer')), 'content_type': m.get('content_type', 'text'), 'content_text': m.get('content_text') or m.get('content') or '', 'media_url': m.get('media_url') or '', 'template_name': m.get('template_name') or '', 'status': m.get('status', 'sent'), 'created_at': (m.get('created_at') or '')[:19].replace('T', ' '), } @http.route('/skeen/frontend/v1/wacrm/conversations//messages', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) def list_conversation_messages_origin(self, external_id, **kw): """Historial COMPLETO de una conversación, leído directo del origen (Supabase del WACRM), paginado. Devuelve los mensajes en orden ascendente dentro de la página; `has_more` indica si hay página previa (mensajes más antiguos).""" try: page = max(int(kw.get('page', 1) or 1), 1) page_size = min(max(int(kw.get('page_size', 50) or 50), 1), 200) data = _supabase_request('GET', 'messages', params={ 'select': '*', 'conversation_id': f'eq.{external_id}', 'order': 'created_at.desc', 'limit': page_size, 'offset': (page - 1) * page_size, }) msgs = [self._supabase_message_to_dict(m) for m in data] msgs.reverse() # ascendente dentro de la página return json_response({ 'status': 'success', 'messages': msgs, 'page': page, 'page_size': page_size, 'has_more': len(data) == page_size, }) except Exception as e: _logger.exception('Error listando mensajes del origen WACRM') return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/wacrm/conversations', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) def list_conversations(self, **kw): try: domain = [] if kw.get('search'): search = kw.get('search') domain += ['|', ('contact_name', 'ilike', search), ('contact_phone', 'ilike', search)] conversations = request.env['skeen.wacrm.conversation'].sudo().search(domain, order='last_message_at desc', limit=200) return json_response({ 'status': 'success', 'conversations': [self._conversation_to_dict(c) for c in conversations], }) except Exception as e: _logger.exception('Error listando conversaciones WACRM') return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/wacrm/members', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) def list_members(self, **kw): try: rows = _supabase_request( 'GET', 'profiles', params={'select': 'user_id,full_name,email,avatar_url,account_role,created_at', 'order': 'created_at.asc'} ) members = [{ 'id': r.get('user_id'), 'profile_id': r.get('id'), 'name': r.get('full_name') or r.get('email') or r.get('user_id'), 'email': r.get('email'), 'avatar_url': r.get('avatar_url'), 'role': r.get('account_role') or 'user', } for r in rows] return json_response({'status': 'success', 'members': members}) except Exception as e: _logger.exception('Error listando miembros WACRM') return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/wacrm/conversations//assign', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('lectura') @require_perm('whatsapp.asignar') def assign_conversation(self, external_id, **kw): try: data = _parse_json_body() assigned_agent_id = data.get('assigned_agent_id') # Actualizar directamente en Supabase update = {'assigned_agent_id': assigned_agent_id, 'updated_at': datetime.utcnow().isoformat() + 'Z'} _supabase_request( 'PATCH', 'conversations', params={'id': f'eq.{external_id}'}, json=update, headers_extra={'Prefer': 'return=minimal'} ) # Actualizar cache local en Odoo conv = request.env['skeen.wacrm.conversation'].sudo().search([('external_id', '=', external_id)], limit=1) if conv: conv.write({ 'assigned_agent_id': assigned_agent_id or None, 'assigned_agent': str(assigned_agent_id) if assigned_agent_id else None, }) return json_response({'status': 'success', 'assigned_agent_id': assigned_agent_id}) except Exception as e: _logger.exception('Error asignando conversación WACRM') return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/wacrm/conversations//assigned', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) def is_conversation_assigned(self, external_id, **kw): try: conv = request.env['skeen.wacrm.conversation'].sudo().search([('external_id', '=', external_id)], limit=1) if not conv: return json_response({'status': 'success', 'assigned': False}) return json_response({ 'status': 'success', 'assigned': bool(conv.assigned_agent_id), 'assigned_agent_id': conv.assigned_agent_id or None, 'assigned_agent_name': conv.assigned_agent or None, }) except Exception as e: _logger.exception('Error consultando asignación WACRM') return json_response({'status': 'error', 'message': str(e)}, 500) # ============================================================ # Leads / Deals # ============================================================ @http.route('/skeen/frontend/v1/wacrm/pipelines', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) def list_pipelines(self, **kw): try: pipelines = request.env['skeen.wacrm.pipeline'].sudo().search([], order='name asc') return json_response({ 'status': 'success', 'pipelines': [{ 'id': p.id, 'external_id': p.external_id, 'name': p.name, 'stages': [{ 'id': s.id, 'external_id': s.external_id, 'name': s.name, 'position': s.position, 'color': s.color or '#3b82f6', } for s in request.env['skeen.wacrm.stage'].sudo().search([('pipeline_id', '=', p.id)], order='position asc')] } for p in pipelines], }) except Exception as e: _logger.exception('Error listando pipelines WACRM') return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/wacrm/leads', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) def list_leads(self, **kw): try: domain = [] if kw.get('status'): domain.append(('status', '=', kw.get('status'))) if kw.get('pipeline_id'): pipe = request.env['skeen.wacrm.pipeline'].sudo().search([('external_id', '=', kw.get('pipeline_id'))], limit=1) if pipe: domain.append(('pipeline_id', '=', pipe.id)) if kw.get('search'): search = kw.get('search') domain += ['|', ('title', 'ilike', search), ('contact_name', 'ilike', search)] deals = request.env['skeen.wacrm.deal'].sudo().search(domain, order='created_at desc', limit=200) return json_response({ 'status': 'success', 'leads': [self._deal_to_dict(d) for d in deals], }) except Exception as e: _logger.exception('Error listando leads WACRM') return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/wacrm/leads//status', type='http', auth='none', methods=['PUT'], csrf=False) @require_role('lectura') @require_perm('whatsapp.asignar') def update_lead_status(self, lead_id, **kw): try: deal = request.env['skeen.wacrm.deal'].sudo().browse(lead_id) if not deal.exists(): return json_response({'status': 'error', 'message': 'Lead no encontrado'}, 404) data = _parse_json_body() update_remote = {} if data.get('status') in ('open', 'won', 'lost'): deal.write({'status': data.get('status')}) update_remote['status'] = data.get('status') if data.get('stage_id'): stage = request.env['skeen.wacrm.stage'].sudo().search([('external_id', '=', data.get('stage_id'))], limit=1) if stage: deal.write({'stage_id': stage.id}) update_remote['stage_id'] = stage.external_id if 'assigned_to' in data: # La UI manda user_id; el FK remoto deals.assigned_to apunta a profiles.id assigned = data.get('assigned_to') or None deal.write({'assigned_to': assigned}) if assigned: prof = _supabase_request('GET', 'profiles', params={ 'select': 'id', 'user_id': f'eq.{assigned}', 'limit': 1}) update_remote['assigned_to'] = prof[0]['id'] if prof else None else: update_remote['assigned_to'] = None if update_remote: # Reflejar en Supabase para que el próximo sync no lo revierta update_remote['updated_at'] = datetime.utcnow().isoformat() + 'Z' _supabase_request( 'PATCH', 'deals', params={'id': f'eq.{deal.external_id}'}, json=update_remote, headers_extra={'Prefer': 'return=minimal'}) return json_response({'status': 'success', 'lead': self._deal_to_dict(deal)}) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/wacrm/leads//convert', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('lectura') @require_perm('whatsapp.asignar') def convert_lead(self, lead_id, **kw): """Convierte el lead en paciente SKEEN (crea o liga por teléfono) y lo marca ganado""" try: deal = request.env['skeen.wacrm.deal'].sudo().browse(lead_id) if not deal.exists(): return json_response({'status': 'error', 'message': 'Lead no encontrado'}, 404) # Idempotente: ya convertido if deal.partner_id: return json_response({ 'status': 'success', 'lead': self._deal_to_dict(deal), 'patient_id': deal.partner_id.id, 'created': False, }) contact = deal.contact_id if not contact: return json_response({'status': 'error', 'message': 'El lead no tiene contacto'}, 400) # Normalizar teléfono (52 + 10 dígitos, como el padrón de pacientes) digits = re.sub(r'\D', '', contact.phone or '') if len(digits) == 10: phone = '52' + digits else: phone = digits if not phone: return json_response({'status': 'error', 'message': 'El contacto no tiene teléfono'}, 400) Partner = request.env['res.partner'].sudo() partner = Partner.search([('is_patient', '=', True), '|', ('phone', '=', phone), ('mobile', '=', phone)], limit=1) created = False if not partner: partner = Partner.create({ 'name': contact.name or phone, 'phone': phone, 'is_patient': True, 'source': 'whatsapp', }) created = True deal.write({'partner_id': partner.id, 'status': 'won'}) _supabase_request( 'PATCH', 'deals', params={'id': f'eq.{deal.external_id}'}, json={'status': 'won', 'updated_at': datetime.utcnow().isoformat() + 'Z'}, headers_extra={'Prefer': 'return=minimal'}) return json_response({ 'status': 'success', 'lead': self._deal_to_dict(deal), 'patient_id': partner.id, 'created': created, }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) def _message_to_dict(self, m): return { 'id': m.id, 'external_id': m.external_id, 'conversation_id': m.conversation_id.external_id if m.conversation_id else None, 'contact_name': m.contact_id.name if m.contact_id else '', 'contact_phone': m.contact_id.phone if m.contact_id else '', 'sender_type': m.sender_type, 'content_type': m.content_type, 'content_text': m.content_text or '', 'media_url': m.media_url or '', 'template_name': m.template_name or '', 'status': m.status, 'created_at': m.created_at.strftime('%Y-%m-%d %H:%M:%S') if m.created_at else None, } def _conversation_to_dict(self, c): return { 'id': c.id, 'external_id': c.external_id, 'contact_id': c.contact_id.external_id if c.contact_id else None, 'contact_name': c.contact_name, 'contact_phone': c.contact_phone, 'status': c.status, 'assigned_agent': c.assigned_agent or '', 'assigned_agent_id': c.assigned_agent_id or '', 'last_message_text': c.last_message_text or '', 'last_message_at': c.last_message_at.strftime('%Y-%m-%d %H:%M:%S') if c.last_message_at else None, 'unread_count': c.unread_count, 'created_at': c.created_at.strftime('%Y-%m-%d %H:%M:%S') if c.created_at else None, 'tags': self._tags_de(c.external_id), } # ============================================================ # Notas internas y etiquetas (viven en Odoo, sobreviven al sync) # ============================================================ def _tags_de(self, external_id): rows = request.env['skeen.wacrm.conversation.tag'].sudo().search([ ('conversation_external_id', '=', external_id)]) return [{'id': r.tag_id.id, 'name': r.tag_id.name, 'color': r.tag_id.color} for r in rows if r.tag_id.active] def _note_to_dict(self, n): return { 'id': n.id, 'body': n.body, 'author': n.author_name or n.author_login or '', 'date': n.create_date.strftime('%Y-%m-%d %H:%M') if n.create_date else None, 'resolved': n.resolved, 'resolved_at': n.resolved_at.strftime('%Y-%m-%d %H:%M') if n.resolved_at else None, 'resolved_by': n.resolved_by or '', } def _tag_to_dict(self, t): return {'id': t.id, 'name': t.name, 'color': t.color, 'sequence': t.sequence, 'active': t.active} # ---- Notas internas ---- @http.route('/skeen/frontend/v1/wacrm/conversations//notes', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') def list_notes(self, external_id, **kw): """Notas internas de la conversación. Nunca se envían al paciente.""" try: notes = request.env['skeen.wacrm.note'].sudo().search([ ('conversation_external_id', '=', external_id)]) return json_response({ 'status': 'success', 'notes': [self._note_to_dict(n) for n in notes], 'pendientes': notes.filtered(lambda n: not n.resolved).__len__(), }) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/wacrm/conversations//notes', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('recepcion') @require_perm('whatsapp.responder') def create_note(self, external_id, **kw): try: data = _parse_json_body() body = (data.get('body') or '').strip() if not body: return json_response({'status': 'error', 'message': 'La nota no puede estar vacía'}, 400) me = getattr(request, 'frontend_user', None) conv = request.env['skeen.wacrm.conversation'].sudo().search( [('external_id', '=', external_id)], limit=1) note = request.env['skeen.wacrm.note'].sudo().create({ 'conversation_external_id': external_id, 'contact_name': conv.contact_name if conv else '', 'body': body, 'author_name': getattr(me, 'name', '') or '', 'author_login': getattr(me, 'login', '') or '', }) return json_response({'status': 'success', 'note': self._note_to_dict(note)}, 201) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/wacrm/notes/', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False) @require_role('recepcion') @require_perm('whatsapp.responder') def update_note(self, note_id, **kw): """Marca/desmarca una nota como atendida, o edita su texto.""" try: note = request.env['skeen.wacrm.note'].sudo().browse(note_id) if not note.exists(): return json_response({'status': 'error', 'message': 'Nota no encontrada'}, 404) data = _parse_json_body() vals = {} if 'body' in data: body = (data.get('body') or '').strip() if not body: return json_response({'status': 'error', 'message': 'La nota no puede estar vacía'}, 400) vals['body'] = body if 'resolved' in data: me = getattr(request, 'frontend_user', None) resolved = bool(data.get('resolved')) vals['resolved'] = resolved vals['resolved_at'] = fields.Datetime.now() if resolved else False vals['resolved_by'] = (getattr(me, 'name', '') or getattr(me, 'login', '')) if resolved else '' if vals: note.write(vals) return json_response({'status': 'success', 'note': self._note_to_dict(note)}) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/wacrm/notes/', type='http', auth='none', methods=['DELETE'], csrf=False) @require_role('recepcion') @require_perm('whatsapp.responder') def delete_note(self, note_id, **kw): try: note = request.env['skeen.wacrm.note'].sudo().browse(note_id) if not note.exists(): return json_response({'status': 'error', 'message': 'Nota no encontrada'}, 404) note.unlink() return json_response({'status': 'success', 'message': 'Nota eliminada'}) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) # ---- Etiquetas ---- @http.route('/skeen/frontend/v1/wacrm/tags', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') def list_tags(self, **kw): try: tags = request.env['skeen.wacrm.tag'].sudo().search([('active', '=', True)]) return json_response({'status': 'success', 'tags': [self._tag_to_dict(t) for t in tags]}) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/wacrm/tags', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('recepcion') @require_perm('whatsapp.responder') def create_tag(self, **kw): try: data = _parse_json_body() name = (data.get('name') or '').strip() if not name: return json_response({'status': 'error', 'message': 'El nombre es obligatorio'}, 400) # active_test=False: una etiqueta desactivada debe poder reactivarse Tag = request.env['skeen.wacrm.tag'].sudo().with_context(active_test=False) existente = Tag.search([('name', '=ilike', name)], limit=1) if existente and existente.active: return json_response({'status': 'error', 'message': 'Ya existe una etiqueta con ese nombre'}, 409) if existente: # Estaba desactivada: se reactiva en vez de fallar por el nombre existente.write({ 'active': True, 'color': data.get('color') or existente.color or '#605ca8', 'sequence': int(data.get('sequence') or existente.sequence or 10), }) return json_response({'status': 'success', 'tag': self._tag_to_dict(existente)}) tag = Tag.create({ 'name': name, 'color': data.get('color') or '#605ca8', 'sequence': int(data.get('sequence') or 10), }) return json_response({'status': 'success', 'tag': self._tag_to_dict(tag)}, 201) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/wacrm/tags/', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False) @require_role('recepcion') @require_perm('whatsapp.responder') def update_tag(self, tag_id, **kw): try: tag = request.env['skeen.wacrm.tag'].sudo().browse(tag_id) if not tag.exists(): return json_response({'status': 'error', 'message': 'Etiqueta no encontrada'}, 404) data = _parse_json_body() vals = {} if 'name' in data: name = (data.get('name') or '').strip() if not name: return json_response({'status': 'error', 'message': 'El nombre es obligatorio'}, 400) dup = request.env['skeen.wacrm.tag'].sudo().with_context(active_test=False).search( [('name', '=ilike', name), ('id', '!=', tag_id), ('active', '=', True)], limit=1) if dup: return json_response({'status': 'error', 'message': 'Ya existe una etiqueta con ese nombre'}, 409) vals['name'] = name if 'color' in data: vals['color'] = data.get('color') or '#605ca8' if 'sequence' in data: vals['sequence'] = int(data.get('sequence') or 10) if 'active' in data: vals['active'] = bool(data.get('active')) if vals: tag.write(vals) return json_response({'status': 'success', 'tag': self._tag_to_dict(tag)}) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/wacrm/tags/', type='http', auth='none', methods=['DELETE'], csrf=False) @require_role('recepcion') @require_perm('whatsapp.responder') def delete_tag(self, tag_id, **kw): """Desactiva la etiqueta (conserva el histórico de asignaciones).""" try: tag = request.env['skeen.wacrm.tag'].sudo().browse(tag_id) if not tag.exists(): return json_response({'status': 'error', 'message': 'Etiqueta no encontrada'}, 404) tag.write({'active': False}) return json_response({'status': 'success', 'message': 'Etiqueta desactivada'}) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) @http.route('/skeen/frontend/v1/wacrm/conversations//tags', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False) @require_role('recepcion') def assign_tag(self, external_id, **kw): """Asigna o quita una etiqueta de la conversación. Body: {"tag_id": 3, "action": "add" | "remove"} (action por defecto add). """ try: data = _parse_json_body() tag_id = int(data.get('tag_id') or 0) tag = request.env['skeen.wacrm.tag'].sudo().browse(tag_id) if not tag.exists(): return json_response({'status': 'error', 'message': 'Etiqueta no encontrada'}, 404) Rel = request.env['skeen.wacrm.conversation.tag'].sudo() action = (data.get('action') or 'add').lower() if action == 'remove': Rel.search([('conversation_external_id', '=', external_id), ('tag_id', '=', tag_id)]).unlink() else: if not Rel.search([('conversation_external_id', '=', external_id), ('tag_id', '=', tag_id)], limit=1): me = getattr(request, 'frontend_user', None) Rel.create({ 'conversation_external_id': external_id, 'tag_id': tag_id, 'assigned_by': getattr(me, 'login', '') or '', }) return json_response({'status': 'success', 'tags': self._tags_de(external_id)}) except Exception as e: return json_response({'status': 'error', 'message': str(e)}, 500) # ============================================================ # Métricas para gerencia # ============================================================ @http.route('/skeen/frontend/v1/wacrm/metrics', type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False) @require_role('lectura') def wacrm_metrics(self, **kw): """Tablero de atención de WhatsApp para gerencia. Criterios (documentados también en la respuesta): - "primera respuesta": minutos entre el PRIMER mensaje entrante del cliente y el PRIMER mensaje saliente posterior en la misma conversación. En WACRM el bot se registra como sender_type='agent', así que cuenta como respuesta del equipo. - "sin responder": conversaciones cuyo ÚLTIMO mensaje es del cliente y lleva más de N horas (parámetro `sin_respuesta_horas`, 4 por defecto). - "leads ganados/perdidos del mes": deals con status won/lost y updated_at dentro del mes en curso. """ try: # Sólo admin/gerencia: el rango de roles no distingue gerencia de # medico (ambos 50), así que se comprueba el rol explícitamente. if request.frontend_user.role not in ('admin', 'gerencia'): return json_response({'status': 'error', 'message': 'Sólo gerencia puede ver las métricas'}, 403) from statistics import median hoy = datetime.utcnow() horas = float(kw.get('sin_respuesta_horas') or 4) convs = _supabase_request_paginated('conversations', {'select': '*'}) msgs = _supabase_request_paginated( 'messages', {'select': 'conversation_id,sender_type,created_at', 'order': 'created_at.asc'}) def en_rango(dt, ini, fin=None): return dt is not None and dt >= ini and (fin is None or dt <= fin) ini_hoy = hoy.replace(hour=0, minute=0, second=0, microsecond=0) ini_semana = ini_hoy - timedelta(days=ini_hoy.weekday()) ini_mes = ini_hoy.replace(day=1) creadas_hoy = creadas_semana = creadas_mes = 0 for c in convs: dt = _parse_datetime(c.get('created_at')) if not dt: continue if dt >= ini_hoy: creadas_hoy += 1 if dt >= ini_semana: creadas_semana += 1 if dt >= ini_mes: creadas_mes += 1 entrantes_hoy = salientes_hoy = 0 entrantes_semana = salientes_semana = 0 entrantes_mes = salientes_mes = 0 for m in msgs: dt = _parse_datetime(m.get('created_at')) if not dt: continue es_in = m.get('sender_type') == 'customer' if dt >= ini_hoy: entrantes_hoy += 1 if es_in else 0 salientes_hoy += 0 if es_in else 1 if dt >= ini_semana: entrantes_semana += 1 if es_in else 0 salientes_semana += 0 if es_in else 1 if dt >= ini_mes: entrantes_mes += 1 if es_in else 0 salientes_mes += 0 if es_in else 1 # Primera respuesta por conversación por_conv = {} for m in msgs: dt = _parse_datetime(m.get('created_at')) if not dt: continue por_conv.setdefault(m.get('conversation_id'), []).append((dt, m.get('sender_type'))) tiempos = [] for cid, lista in por_conv.items(): lista.sort(key=lambda x: x[0]) primera_in = next((dt for dt, st in lista if st == 'customer'), None) if not primera_in: continue respuesta = next((dt for dt, st in lista if st != 'customer' and dt >= primera_in), None) if respuesta: tiempos.append((respuesta - primera_in).total_seconds() / 60.0) sin_responder = 0 detalle_sin_responder = [] for c in convs: lista = por_conv.get(c.get('id')) if not lista: continue ultimo_dt, ultimo_st = max(lista, key=lambda x: x[0]) if ultimo_st != 'customer': continue horas_esperando = (hoy - ultimo_dt).total_seconds() / 3600.0 if horas_esperando >= horas: sin_responder += 1 detalle_sin_responder.append({ 'conversation_id': c.get('id'), 'horas': round(horas_esperando, 1), 'ultimo_mensaje': (c.get('last_message_text') or '')[:120], }) detalle_sin_responder.sort(key=lambda d: -d['horas']) # Leads del mes deals = _supabase_request_paginated('deals', {'select': 'id,status,created_at,updated_at,title,value'}) ganados = perdidos = abiertos = 0 valor_ganado = 0.0 for d in deals: dt = _parse_datetime(d.get('updated_at')) or _parse_datetime(d.get('created_at')) if not dt or dt < ini_mes: continue st = d.get('status') if st == 'won': ganados += 1 valor_ganado += float(d.get('value') or 0) elif st == 'lost': perdidos += 1 elif st == 'open': abiertos += 1 return json_response({ 'status': 'success', 'generado': hoy.strftime('%Y-%m-%d %H:%M'), 'periodo': {'desde': ini_mes.strftime('%Y-%m-%d'), 'hasta': hoy.strftime('%Y-%m-%d')}, 'sin_respuesta_horas': horas, 'conversaciones': { 'hoy': creadas_hoy, 'semana': creadas_semana, 'mes': creadas_mes, 'total': len(convs), }, 'mensajes': { 'hoy': {'entrantes': entrantes_hoy, 'salientes': salientes_hoy}, 'semana': {'entrantes': entrantes_semana, 'salientes': salientes_semana}, 'mes': {'entrantes': entrantes_mes, 'salientes': salientes_mes}, }, 'primera_respuesta': { 'muestras': len(tiempos), 'promedio_min': round(sum(tiempos) / len(tiempos), 1) if tiempos else None, 'mediana_min': round(median(tiempos), 1) if tiempos else None, }, 'sin_responder': { 'total': sin_responder, 'horas_minimas': horas, 'detalle': detalle_sin_responder[:20], }, 'leads': { 'ganados': ganados, 'perdidos': perdidos, 'abiertos': abiertos, 'valor_ganado': round(valor_ganado, 2), }, 'criterios': { 'primera_respuesta': 'Minutos entre el primer mensaje entrante del cliente y el primer mensaje saliente posterior en la misma conversación. En WACRM el bot se registra como "agent", así que cuenta como respuesta del equipo.', 'sin_responder': f'Conversaciones cuyo último mensaje es del cliente y lleva más de {horas:g} h sin respuesta.', 'leads': 'Deals con status won/lost y updated_at dentro del mes en curso.', }, }) except Exception as e: _logger.exception('Error calculando métricas WACRM') return json_response({'status': 'error', 'message': str(e)}, 500) def _deal_to_dict(self, d): return { 'id': d.id, 'external_id': d.external_id, 'title': d.title, 'contact_id': d.contact_id.external_id if d.contact_id else None, 'contact_name': d.contact_name, 'contact_phone': d.contact_phone, 'pipeline': d.pipeline_id.name if d.pipeline_id else '', 'pipeline_id': d.pipeline_id.id if d.pipeline_id else None, 'stage': d.stage_id.name if d.stage_id else '', 'stage_id': d.stage_id.id if d.stage_id else None, 'stage_color': d.stage_id.color if d.stage_id else '#3b82f6', 'value': d.value, 'currency': d.currency, 'status': d.status, 'expected_close_date': d.expected_close_date.strftime('%Y-%m-%d') if d.expected_close_date else None, 'notes': d.notes or '', 'assigned_to': d.assigned_to or '', 'partner_id': d.partner_id.id if d.partner_id else None, 'conversation_id': d.conversation_id or '', 'created_at': d.created_at.strftime('%Y-%m-%d %H:%M:%S') if d.created_at else None, }