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:
5
odoo-addons/skeen_whatsapp/controllers/__init__.py
Normal file
5
odoo-addons/skeen_whatsapp/controllers/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from . import main
|
||||
from . import frontend
|
||||
from . import wacrm_proxy
|
||||
127
odoo-addons/skeen_whatsapp/controllers/auth.py
Normal file
127
odoo-addons/skeen_whatsapp/controllers/auth.py
Normal file
@@ -0,0 +1,127 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""Autenticación ligera (token HMAC) para el frontend React de SKEEN.
|
||||
|
||||
Token = base64url(payload_json) + "." + firma HMAC-SHA256, sin tabla de sesiones.
|
||||
El secreto vive en ir.config_parameter 'skeen.frontend.secret' (se genera al primer uso).
|
||||
"""
|
||||
|
||||
import base64
|
||||
import functools
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import secrets
|
||||
import time
|
||||
|
||||
from odoo.http import request, Response
|
||||
|
||||
TTL_SECONDS = 60 * 60 * 12 # 12 horas
|
||||
SECRET_KEY = 'skeen.frontend.secret'
|
||||
|
||||
RANK = {
|
||||
'lectura': 10,
|
||||
'medico': 50,
|
||||
'recepcion': 50,
|
||||
'admin': 100,
|
||||
}
|
||||
|
||||
|
||||
def _cors_headers():
|
||||
return {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Access-Control-Allow-Methods': 'GET, POST, PUT, DELETE, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type, Authorization',
|
||||
}
|
||||
|
||||
|
||||
def _json(data, status=200):
|
||||
return Response(
|
||||
json.dumps(data, default=str),
|
||||
status=status,
|
||||
mimetype='application/json',
|
||||
headers=_cors_headers(),
|
||||
)
|
||||
|
||||
|
||||
def _b64url_encode(raw: bytes) -> str:
|
||||
return base64.urlsafe_b64encode(raw).rstrip(b'=').decode('ascii')
|
||||
|
||||
|
||||
def _b64url_decode(s: str) -> bytes:
|
||||
pad = '=' * (-len(s) % 4)
|
||||
return base64.urlsafe_b64decode(s + pad)
|
||||
|
||||
|
||||
def _get_secret(env) -> str:
|
||||
ICP = env['ir.config_parameter'].sudo()
|
||||
secret = ICP.get_param(SECRET_KEY)
|
||||
if not secret:
|
||||
secret = secrets.token_urlsafe(48)
|
||||
ICP.set_param(SECRET_KEY, secret)
|
||||
return secret
|
||||
|
||||
|
||||
def sign_token(env, user) -> str:
|
||||
payload = {
|
||||
'uid': user.id,
|
||||
'login': user.login,
|
||||
'role': user.role,
|
||||
'exp': int(time.time()) + TTL_SECONDS,
|
||||
}
|
||||
body = _b64url_encode(json.dumps(payload, separators=(',', ':')).encode('utf-8'))
|
||||
sig = hmac.new(_get_secret(env).encode('utf-8'), body.encode('ascii'), hashlib.sha256).hexdigest()
|
||||
return f'{body}.{sig}'
|
||||
|
||||
|
||||
def verify_token(env, token: str):
|
||||
if not token or '.' not in token:
|
||||
return None
|
||||
body, sig = token.rsplit('.', 1)
|
||||
expected = hmac.new(_get_secret(env).encode('utf-8'), body.encode('ascii'), hashlib.sha256).hexdigest()
|
||||
if not hmac.compare_digest(sig, expected):
|
||||
return None
|
||||
try:
|
||||
payload = json.loads(_b64url_decode(body).decode('utf-8'))
|
||||
except Exception:
|
||||
return None
|
||||
if int(payload.get('exp', 0)) < int(time.time()):
|
||||
return None
|
||||
return payload
|
||||
|
||||
|
||||
def current_user(env):
|
||||
"""Devuelve el usuario autenticado (record) o None."""
|
||||
auth = request.httprequest.headers.get('Authorization', '') or ''
|
||||
if not auth.lower().startswith('bearer '):
|
||||
return None
|
||||
token = auth[7:].strip()
|
||||
payload = verify_token(env, token)
|
||||
if not payload:
|
||||
return None
|
||||
user = env['skeen.frontend.user'].sudo().browse(payload.get('uid'))
|
||||
if not user.exists() or not user.active:
|
||||
return None
|
||||
return user
|
||||
|
||||
|
||||
def require_role(min_role='lectura'):
|
||||
"""Decorator: exige token válido y un rol con rango >= min_role.
|
||||
|
||||
Deja pasar OPTIONS (preflight CORS) sin autenticación.
|
||||
"""
|
||||
required = RANK.get(min_role, 10)
|
||||
|
||||
def decorator(func):
|
||||
@functools.wraps(func)
|
||||
def wrapper(self, *args, **kw):
|
||||
if request.httprequest.method == 'OPTIONS':
|
||||
return _json({}, 200)
|
||||
user = current_user(request.env)
|
||||
if not user:
|
||||
return _json({'status': 'error', 'message': 'No autenticado'}, 401)
|
||||
if user.role_rank() < required:
|
||||
return _json({'status': 'error', 'message': 'Sin permiso'}, 403)
|
||||
request.frontend_user = user
|
||||
return func(self, *args, **kw)
|
||||
return wrapper
|
||||
return decorator
|
||||
1654
odoo-addons/skeen_whatsapp/controllers/frontend.py
Normal file
1654
odoo-addons/skeen_whatsapp/controllers/frontend.py
Normal file
File diff suppressed because it is too large
Load Diff
182
odoo-addons/skeen_whatsapp/controllers/main.py
Normal file
182
odoo-addons/skeen_whatsapp/controllers/main.py
Normal file
@@ -0,0 +1,182 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
import json
|
||||
|
||||
|
||||
class SkeenWhatsAppController(http.Controller):
|
||||
"""Controladores API para integración con Hermes/WACRM"""
|
||||
|
||||
@http.route('/skeen/api/v1/available_slots', type='json', auth='api_key', methods=['POST'], csrf=False)
|
||||
def get_available_slots(self, service_id, date_from, date_to, **kw):
|
||||
"""Consultar slots disponibles para un servicio en un rango de fechas"""
|
||||
try:
|
||||
servicio = request.env['skeen.servicio'].browse(int(service_id))
|
||||
if not servicio.exists():
|
||||
return {'status': 'error', 'message': 'Servicio no encontrado'}
|
||||
|
||||
slots = []
|
||||
from datetime import datetime, timedelta
|
||||
current_date = datetime.strptime(date_from, '%Y-%m-%d').date()
|
||||
end_date = datetime.strptime(date_to, '%Y-%m-%d').date()
|
||||
|
||||
while current_date <= end_date:
|
||||
day_slots = request.env['skeen.cita'].get_available_slots(current_date, int(service_id))
|
||||
for slot in day_slots:
|
||||
slots.append({
|
||||
'date': current_date.strftime('%Y-%m-%d'),
|
||||
'time': slot['time_str'],
|
||||
'datetime': f"{current_date.strftime('%Y-%m-%d')} {slot['time_str']}",
|
||||
})
|
||||
current_date += timedelta(days=1)
|
||||
|
||||
return {'status': 'success', 'slots': slots}
|
||||
except Exception as e:
|
||||
return {'status': 'error', 'message': str(e)}
|
||||
|
||||
@http.route('/skeen/api/v1/create_appointment', type='json', auth='api_key', methods=['POST'], csrf=False)
|
||||
def create_appointment(self, phone, name, service_id, date, time, **kw):
|
||||
"""Crear una nueva cita"""
|
||||
try:
|
||||
# Buscar o crear paciente
|
||||
partner = request.env['res.partner'].search([('phone', '=', phone)], limit=1)
|
||||
if not partner:
|
||||
partner = request.env['res.partner'].create({
|
||||
'name': name,
|
||||
'phone': phone,
|
||||
'is_patient': True,
|
||||
'source': 'whatsapp',
|
||||
})
|
||||
|
||||
# Convertir time a float
|
||||
time_parts = time.split(':')
|
||||
time_float = float(time_parts[0]) + float(time_parts[1]) / 60.0
|
||||
|
||||
# Crear cita
|
||||
cita = request.env['skeen.cita'].create({
|
||||
'partner_id': partner.id,
|
||||
'servicio_id': int(service_id),
|
||||
'date': date,
|
||||
'time': time_float,
|
||||
'state': 'confirmed',
|
||||
})
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'booking_id': cita.id,
|
||||
'reference': cita.name,
|
||||
'patient_id': partner.patient_id,
|
||||
}
|
||||
except Exception as e:
|
||||
return {'status': 'error', 'message': str(e)}
|
||||
|
||||
@http.route('/skeen/api/v1/patient_balance', type='json', auth='api_key', methods=['POST'], csrf=False)
|
||||
def get_patient_balance(self, phone, **kw):
|
||||
"""Obtener saldo pendiente y monedero del paciente"""
|
||||
try:
|
||||
partner = request.env['res.partner'].search([('phone', '=', phone)], limit=1)
|
||||
if not partner:
|
||||
return {'status': 'error', 'message': 'Paciente no encontrado'}
|
||||
|
||||
# Citas pendientes de pago
|
||||
pending_citas = request.env['skeen.cita'].search([
|
||||
('partner_id', '=', partner.id),
|
||||
('payment_state', '!=', 'paid'),
|
||||
('state', 'in', ('confirmed', 'arrived', 'in_progress', 'done')),
|
||||
])
|
||||
|
||||
pending_balance = sum(c.servicio_price for c in pending_citas)
|
||||
|
||||
# Monedero
|
||||
monedero = request.env['skeen.monedero'].search([('partner_id', '=', partner.id)], limit=1)
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'pending_balance': pending_balance,
|
||||
'pending_invoices': [{
|
||||
'id': c.id,
|
||||
'reference': c.name,
|
||||
'service': c.servicio_id.name,
|
||||
'amount': c.servicio_price,
|
||||
'date': c.date.strftime('%Y-%m-%d'),
|
||||
} for c in pending_citas],
|
||||
'wallet_points': monedero.points if monedero else 0,
|
||||
'wallet_mxn': monedero.equivalent_mxn if monedero else 0,
|
||||
}
|
||||
except Exception as e:
|
||||
return {'status': 'error', 'message': str(e)}
|
||||
|
||||
@http.route('/skeen/api/v1/wallet_redeem', type='json', auth='api_key', methods=['POST'], csrf=False)
|
||||
def redeem_wallet(self, phone, points, appointment_id=None, **kw):
|
||||
"""Redimir puntos del monedero"""
|
||||
try:
|
||||
partner = request.env['res.partner'].search([('phone', '=', phone)], limit=1)
|
||||
if not partner:
|
||||
return {'status': 'error', 'message': 'Paciente no encontrado'}
|
||||
|
||||
monedero = request.env['skeen.monedero'].search([('partner_id', '=', partner.id)], limit=1)
|
||||
if not monedero:
|
||||
return {'status': 'error', 'message': 'Monedero no encontrado'}
|
||||
|
||||
monedero.redeem_points(int(points))
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'remaining_points': monedero.points,
|
||||
'remaining_mxn': monedero.equivalent_mxn,
|
||||
}
|
||||
except Exception as e:
|
||||
return {'status': 'error', 'message': str(e)}
|
||||
|
||||
@http.route('/skeen/api/v1/cancel_appointment', type='json', auth='api_key', methods=['POST'], csrf=False)
|
||||
def cancel_appointment(self, phone, booking_id=None, **kw):
|
||||
"""Cancelar una cita"""
|
||||
try:
|
||||
domain = [
|
||||
('partner_id.phone', '=', phone),
|
||||
('state', 'in', ('pending', 'confirmed')),
|
||||
]
|
||||
if booking_id:
|
||||
domain.append(('id', '=', int(booking_id)))
|
||||
|
||||
cita = request.env['skeen.cita'].search(domain, order='date asc', limit=1)
|
||||
if not cita:
|
||||
return {'status': 'error', 'message': 'No se encontró cita activa'}
|
||||
|
||||
cita.write({'state': 'cancelled'})
|
||||
|
||||
return {
|
||||
'status': 'success',
|
||||
'cancelled_booking': cita.name,
|
||||
}
|
||||
except Exception as e:
|
||||
return {'status': 'error', 'message': str(e)}
|
||||
|
||||
@http.route('/skeen/api/v1/services', type='json', auth='api_key', methods=['GET', 'POST'], csrf=False)
|
||||
def list_services(self, **kw):
|
||||
"""Listar todos los servicios disponibles"""
|
||||
try:
|
||||
services = request.env['skeen.servicio'].search([('active', '=', True)])
|
||||
return {
|
||||
'status': 'success',
|
||||
'services': [{
|
||||
'id': s.id,
|
||||
'code': s.code,
|
||||
'name': s.name,
|
||||
'category': s.category,
|
||||
'price': s.price,
|
||||
'package_price': s.package_price,
|
||||
'package_sessions': s.package_sessions,
|
||||
'package_notes': s.package_notes or '',
|
||||
'duration_min': s.duration_min,
|
||||
'description': s.description,
|
||||
} for s in services],
|
||||
}
|
||||
except Exception as e:
|
||||
return {'status': 'error', 'message': str(e)}
|
||||
|
||||
@http.route('/skeen/api/v1/health', type='json', auth='none', methods=['GET'], csrf=False)
|
||||
def health_check(self, **kw):
|
||||
"""Health check para monitoreo"""
|
||||
return {'status': 'ok', 'service': 'skeen-odoo-api', 'version': '1.0.0'}
|
||||
550
odoo-addons/skeen_whatsapp/controllers/wacrm_proxy.py
Normal file
550
odoo-addons/skeen_whatsapp/controllers/wacrm_proxy.py
Normal 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,
|
||||
}
|
||||
Reference in New Issue
Block a user