Initial commit: SKEEN Derma Experts - Sistema Integral de Gestión Clínica

- Frontend React (SKEEN Brand) con Vite, TypeScript, Tailwind
- Frontend Homenest (versión alternativa)
- Módulos Odoo 17 custom (citas, pacientes, monedero, pagos, ventas, inventario, whatsapp)
- WACRM fork (Next.js 16 + Supabase)
- Hermes + Bridge + Skills (Qwen3.6 via Nan Builders)
- Scripts de migración y operación
- Documentación extensiva en docs/
This commit is contained in:
2026-07-20 07:44:23 +00:00
commit a718592291
699 changed files with 324602 additions and 0 deletions

View File

@@ -0,0 +1,550 @@
# -*- coding: utf-8 -*-
"""
Proxy/caché entre WACRM (Supabase) y Odoo.
Expone endpoints REST para el frontend SKEEN.
"""
from odoo import http
from odoo.http import request
from .frontend import json_response, _parse_json_body
import os
import logging
import requests
import re
from datetime import datetime
_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."""
try:
result = self._do_sync()
return json_response({'status': 'success', 'result': result})
except Exception as e:
_logger.exception('Error sincronizando WACRM')
return json_response({'status': 'error', 'message': str(e)}, 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
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)
# 3. Messages (últimos 5000 paginados)
messages_data = _supabase_request_paginated(
'messages',
{'select': '*', 'order': 'created_at.desc'},
page_size=1000
)[:5000]
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
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': str(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)
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),
'account_id': account_id,
}
# ============================================================
# Mensajes
# ============================================================
@http.route('/skeen/frontend/v1/wacrm/messages', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
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)
@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'),
'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/<string:external_id>/assign', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
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/<string:external_id>/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/<int:lead_id>/status', type='http', auth='none', methods=['PUT'], csrf=False)
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()
if data.get('status') in ('open', 'won', 'lost'):
deal.write({'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})
return json_response({'status': 'success', 'lead': self._deal_to_dict(deal)})
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,
}
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 '',
'stage': d.stage_id.name if d.stage_id else '',
'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 '',
'created_at': d.created_at.strftime('%Y-%m-%d %H:%M:%S') if d.created_at else None,
}