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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user