Oleada UX 7: Chat (presencia, busqueda en historial) y WACRM (metricas para gerencia, notas internas y etiquetas que sobreviven al sync) + fixes de concurrencia

This commit is contained in:
2026-09-17 23:46:40 +00:00
parent 31c92ac419
commit c38783eb31
13 changed files with 1589 additions and 36 deletions

View File

@@ -6,7 +6,7 @@ restringidos al usuario autenticado: solo puede listar/leer conversaciones
donde él es sender o recipient, y solo puede enviar como él mismo.
"""
from odoo import http
from odoo import http, fields
from odoo.http import request
from odoo.addons.skeen_whatsapp.controllers.auth import require_role
@@ -15,10 +15,44 @@ from odoo.addons.skeen_whatsapp.controllers.frontend import json_response, _pars
MAX_BODY = 2000
HISTORY_LIMIT = 50
LIST_SCAN_LIMIT = 500
# Presencia: actividad en los últimos 2 minutos = "en línea"
ONLINE_SECONDS = 120
# No reescribir last_seen más seguido que esto (varias pestañas del mismo
# usuario laten a la vez)
TOUCH_INTERVAL = 20
class SkeenChatController(http.Controller):
def _touch(self, me):
"""Marca actividad del usuario (presencia). Nunca debe romper la petición.
Se hace con SQL directo dentro de un savepoint: el frontend consulta
varios endpoints de chat en paralelo (bandeja, badge, heartbeat) y dos
UPDATE concurrentes sobre la misma fila abortan la transacción en
REPEATABLE READ ("could not serialize access due to concurrent update").
Con el savepoint ese fallo se descarta y la petición sigue; además sólo
se escribe si el último registro tiene más de TOUCH_INTERVAL segundos,
que es lo que evita la mayor parte de los choques.
"""
try:
ahora = fields.Datetime.now()
limite = fields.Datetime.subtract(ahora, seconds=TOUCH_INTERVAL)
cr = request.env.cr
with cr.savepoint():
cr.execute(
"UPDATE skeen_frontend_user SET last_seen = %s, write_date = %s "
"WHERE id = %s AND (last_seen IS NULL OR last_seen < %s)",
(ahora, ahora, me.id, limite))
except Exception:
pass
def _online(self, user):
if not user.last_seen:
return False
delta = (fields.Datetime.now() - user.last_seen).total_seconds()
return delta <= ONLINE_SECONDS
def _message_to_dict(self, msg, me_id):
return {
'id': msg.id,
@@ -30,6 +64,36 @@ class SkeenChatController(http.Controller):
'date': msg.create_date.strftime('%Y-%m-%d %H:%M:%S') if msg.create_date else None,
}
def _pair_domain(self, me_id, other_id):
return [
'|', '&', ('sender_id', '=', me_id), ('recipient_id', '=', other_id),
'&', ('sender_id', '=', other_id), ('recipient_id', '=', me_id),
]
@http.route('/skeen/frontend/v1/chat/heartbeat', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('lectura')
def chat_heartbeat(self, **kw):
"""Latido de presencia: el cliente lo manda cada ~30 s con la app abierta.
Es la ÚNICA señal que escribe last_seen: así no compite con las demás
llamadas de chat que el frontend lanza en paralelo.
"""
try:
me = request.frontend_user
self._touch(me)
online = request.env['skeen.frontend.user'].sudo().search_count([
('active', '=', True),
('last_seen', '>=', fields.Datetime.subtract(fields.Datetime.now(), seconds=ONLINE_SECONDS)),
])
return json_response({
'status': 'success',
'server_time': fields.Datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
'online_count': online,
'online_window_seconds': ONLINE_SECONDS,
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/chat/users', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('lectura')
def chat_users(self, **kw):
@@ -79,6 +143,8 @@ class SkeenChatController(http.Controller):
'last_message': last.get(u.id, {}).get('body'),
'last_date': last.get(u.id, {}).get('date'),
'last_mine': last.get(u.id, {}).get('mine'),
'online': self._online(u),
'last_seen': u.last_seen.strftime('%Y-%m-%d %H:%M:%S') if u.last_seen else None,
} for u in conversations + rest],
})
except Exception as e:
@@ -87,7 +153,8 @@ class SkeenChatController(http.Controller):
@http.route('/skeen/frontend/v1/chat/messages', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('lectura')
def chat_messages(self, **kw):
"""Historial con otro usuario (últimos 50; since_id para incremental).
"""Historial con otro usuario (últimos 50; since_id para incremental,
around_id para abrir la ventana que termina en un mensaje concreto).
Marca como leídos los mensajes recibidos de ese usuario.
"""
@@ -100,15 +167,19 @@ class SkeenChatController(http.Controller):
User = request.env['skeen.frontend.user'].sudo()
if not User.search([('id', '=', other_id), ('active', '=', True)], limit=1):
return json_response({'status': 'error', 'message': 'Usuario no encontrado'}, 404)
domain = [
'|', '&', ('sender_id', '=', me.id), ('recipient_id', '=', other_id),
'&', ('sender_id', '=', other_id), ('recipient_id', '=', me.id),
]
domain = self._pair_domain(me.id, other_id)
since_id = int(data.get('since_id') or 0)
around_id = int(data.get('around_id') or 0)
Msg = request.env['skeen.chat.message'].sudo()
if since_id:
domain.append(('id', '>', since_id))
Msg = request.env['skeen.chat.message'].sudo()
msgs = Msg.search(domain, order='id desc', limit=HISTORY_LIMIT).sorted('id')
msgs = Msg.search(domain, order='id desc', limit=HISTORY_LIMIT).sorted('id')
elif around_id:
# Ventana de historial que TERMINA en ese mensaje (contexto hacia atrás)
msgs = Msg.search(domain + [('id', '<=', around_id)],
order='id desc', limit=HISTORY_LIMIT).sorted('id')
else:
msgs = Msg.search(domain, order='id desc', limit=HISTORY_LIMIT).sorted('id')
# Marcar leídos los recibidos en esta conversación
incoming = msgs.filtered(lambda m: m.recipient_id.id == me.id and not m.is_read)
if incoming:
@@ -116,6 +187,41 @@ class SkeenChatController(http.Controller):
return json_response({
'status': 'success',
'messages': [self._message_to_dict(m, me.id) for m in msgs],
'around_id': around_id or None,
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/chat/search', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('lectura')
def chat_search(self, **kw):
"""Busca texto en el historial con otro usuario.
Devuelve los mensajes que coinciden (más recientes primero) con el
texto y el fragmento resaltado del lado del cliente.
"""
try:
me = request.frontend_user
data = _parse_json_body()
other_id = int(data.get('user_id') or 0)
q = (data.get('q') or '').strip()
limit = min(max(int(data.get('limit', 50) or 50), 1), 100)
if not other_id or other_id == me.id:
return json_response({'status': 'error', 'message': 'user_id inválido'}, 400)
if len(q) < 2:
return json_response({'status': 'error', 'message': 'Escribe al menos 2 caracteres'}, 400)
User = request.env['skeen.frontend.user'].sudo()
if not User.search([('id', '=', other_id), ('active', '=', True)], limit=1):
return json_response({'status': 'error', 'message': 'Usuario no encontrado'}, 404)
Msg = request.env['skeen.chat.message'].sudo()
msgs = Msg.search(self._pair_domain(me.id, other_id) + [('body', 'ilike', q)],
order='id desc', limit=limit)
results = [self._message_to_dict(m, me.id) for m in msgs]
return json_response({
'status': 'success',
'query': q,
'count': len(results),
'results': results,
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
@@ -154,11 +260,18 @@ class SkeenChatController(http.Controller):
@http.route('/skeen/frontend/v1/chat/unread_count', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('lectura')
def chat_unread_count(self, **kw):
"""Total global de mensajes no leídos del usuario (badge del menú)."""
"""Total global de mensajes no leídos del usuario (badge del menú).
Mismo universo que /chat/users: sólo remitentes ACTIVOS, para que el
badge cuadre con la suma de los contadores de la bandeja.
"""
try:
me = request.frontend_user
activos = request.env['skeen.frontend.user'].sudo().search([('active', '=', True)]).ids
total = request.env['skeen.chat.message'].sudo().search_count([
('recipient_id', '=', me.id), ('is_read', '=', False),
('recipient_id', '=', me.id),
('is_read', '=', False),
('sender_id', 'in', activos),
])
return json_response({'status': 'success', 'unread': total})
except Exception as e:

View File

@@ -4,15 +4,17 @@ Proxy/caché entre WACRM (Supabase) y Odoo.
Expone endpoints REST para el frontend SKEEN.
"""
from odoo import http
from odoo import http, fields
from odoo.http import request
from .frontend import json_response, _parse_json_body
from .auth import require_role
import os
import logging
import requests
import re
import time
import uuid
from datetime import datetime
from datetime import datetime, timedelta
_logger = logging.getLogger(__name__)
@@ -126,13 +128,42 @@ class SkeenWacrmProxyController(http.Controller):
# ============================================================
@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."""
"""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:
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)
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
@@ -729,8 +760,388 @@ class SkeenWacrmProxyController(http.Controller):
'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/<string:external_id>/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/<string:external_id>/notes', type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
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/<int:note_id>', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
@require_role('recepcion')
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/<int:note_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
@require_role('recepcion')
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')
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/<int:tag_id>', type='http', auth='none', methods=['PUT', 'OPTIONS'], csrf=False)
@require_role('recepcion')
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/<int:tag_id>', type='http', auth='none', methods=['DELETE'], csrf=False)
@require_role('recepcion')
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/<string:external_id>/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,

View File

@@ -2,3 +2,4 @@ from . import main
from . import wacrm_sync
from . import frontend_user
from . import config_audit
from . import wacrm_notes

View File

@@ -27,6 +27,10 @@ class SkeenFrontendUser(models.Model):
active = fields.Boolean(string='Activo', default=True)
must_change_password = fields.Boolean(string='Debe cambiar contraseña', default=False)
last_login = fields.Datetime(string='Último acceso', readonly=True)
# Presencia: se actualiza con cada llamada autenticada del frontend
# (heartbeat explícito cada ~30 s y en cada endpoint de chat).
# "En línea" = actividad en los últimos 2 minutos.
last_seen = fields.Datetime(string='Última actividad', readonly=True, index=True)
notes = fields.Text(string='Notas')
allowed_menus = fields.Text(string='Menús Permitidos',
help='JSON array de hrefs visibles; vacío = defaults por rol')

View File

@@ -0,0 +1,60 @@
# -*- coding: utf-8 -*-
from odoo import models, fields, api
class SkeenWacrmNote(models.Model):
"""Nota interna de una conversación de WhatsApp.
Se liga por `external_id` de la conversación (NO por many2one al caché
local) a propósito: el sync borra y recrea el caché de conversaciones y
mensajes, y las notas deben sobrevivir. Nunca se envía al paciente.
"""
_name = 'skeen.wacrm.note'
_description = 'Nota interna de conversación WACRM'
_order = 'create_date desc, id desc'
conversation_external_id = fields.Char(string='ID conversación WACRM',
required=True, index=True)
contact_name = fields.Char(string='Contacto', help='Sólo para mostrar en la lista')
body = fields.Text(string='Nota', required=True)
author_name = fields.Char(string='Autor')
author_login = fields.Char(string='Autor (usuario)')
resolved = fields.Boolean(string='Atendida', default=False)
resolved_at = fields.Datetime(string='Fecha de atención')
resolved_by = fields.Char(string='Atendida por')
# create_date (heredado) = fecha de la nota
class SkeenWacrmTag(models.Model):
"""Catálogo de etiquetas de conversación (urgente, cotización, etc.)."""
_name = 'skeen.wacrm.tag'
_description = 'Etiqueta de conversación WACRM'
_order = 'sequence, name'
name = fields.Char(string='Nombre', required=True)
color = fields.Char(string='Color', default='#605ca8')
sequence = fields.Integer(string='Orden', default=10)
active = fields.Boolean(string='Activa', default=True)
_sql_constraints = [
('unique_name', 'unique(name)', 'Ya existe una etiqueta con ese nombre.'),
]
class SkeenWacrmConversationTag(models.Model):
"""Asignación etiqueta ↔ conversación, por external_id (sobrevive al sync)."""
_name = 'skeen.wacrm.conversation.tag'
_description = 'Etiqueta asignada a una conversación WACRM'
_order = 'id'
conversation_external_id = fields.Char(string='ID conversación WACRM',
required=True, index=True)
tag_id = fields.Many2one('skeen.wacrm.tag', string='Etiqueta',
required=True, ondelete='cascade', index=True)
assigned_by = fields.Char(string='Asignada por')
_sql_constraints = [
('unique_conv_tag', 'unique(conversation_external_id, tag_id)',
'Esa etiqueta ya está asignada a la conversación.'),
]

View File

@@ -7,3 +7,6 @@ access_skeen_wacrm_stage,skeen.wacrm.stage,model_skeen_wacrm_stage,base.group_us
access_skeen_wacrm_deal,skeen.wacrm.deal,model_skeen_wacrm_deal,base.group_user,1,1,1,1
access_skeen_frontend_user,skeen.frontend.user,model_skeen_frontend_user,base.group_user,1,1,1,1
access_skeen_config_audit,skeen.config.audit,model_skeen_config_audit,base.group_user,1,1,1,1
access_skeen_wacrm_note,Notas internas WACRM,model_skeen_wacrm_note,base.group_user,1,1,1,1
access_skeen_wacrm_tag,Etiquetas WACRM,model_skeen_wacrm_tag,base.group_user,1,1,1,1
access_skeen_wacrm_conversation_tag,Etiquetas por conversacion,model_skeen_wacrm_conversation_tag,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
7 access_skeen_wacrm_deal skeen.wacrm.deal model_skeen_wacrm_deal base.group_user 1 1 1 1
8 access_skeen_frontend_user skeen.frontend.user model_skeen_frontend_user base.group_user 1 1 1 1
9 access_skeen_config_audit skeen.config.audit model_skeen_config_audit base.group_user 1 1 1 1
10 access_skeen_wacrm_note Notas internas WACRM model_skeen_wacrm_note base.group_user 1 1 1 1
11 access_skeen_wacrm_tag Etiquetas WACRM model_skeen_wacrm_tag base.group_user 1 1 1 1
12 access_skeen_wacrm_conversation_tag Etiquetas por conversacion model_skeen_wacrm_conversation_tag base.group_user 1 1 1 1