bloque 4: campanas por segmentos con medicion (modulo skeen_campanas: segmentos reutilizables con conteo verificado, campanas por WhatsApp y correo con programacion, prueba previa, limite por minuto, pausa por errores, consentimiento por canal y atribucion de citas) + encuesta al salir con NPS (campo propio, envio automatico y recordatorio, reporte con tendencia y desglose); apagado por defecto

This commit is contained in:
2026-10-12 02:34:34 +00:00
parent 705b5a2842
commit a6d6df4ebd
37 changed files with 4619 additions and 44 deletions

View File

@@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from . import campanas
from . import encuestas

View File

@@ -0,0 +1,576 @@
# -*- coding: utf-8 -*-
"""API autenticada de campañas y segmentos.
Rutas bajo `/skeen/frontend/v1/campanas/*`. Mismo guard doble que el resto del
frontend: `@require_role` para el rango del rol y `@require_perm` para la acción
concreta (`campanas.ver|gestionar|enviar|medir`).
El módulo nace APAGADO (`campanas.activo=false`): las acciones que envían
mensajes responden con un aviso claro y no tocan nada. Leer y administrar
segmentos/campañas sigue disponible para poder configurarlas.
"""
import json
from odoo import fields, http
from odoo.http import request
from odoo.addons.skeen_whatsapp.controllers.auth import (
_json, current_user, require_role, require_perm,
)
from odoo.addons.skeen_citas.clinic_settings import get_campanas
from ..models.segmento import normalizar_criterios, validar_criterios
PREFIX = '/skeen/frontend/v1/campanas'
def _body():
try:
return json.loads(request.httprequest.get_data(as_text=True) or '{}')
except Exception:
return {}
def _error(message, status=400):
return _json({'status': 'error', 'message': message}, status)
def _parse_dt(valor):
"""Acepta 'YYYY-MM-DD HH:MM', 'YYYY-MM-DDTHH:MM' o vacío."""
texto = str(valor or '').strip().replace('T', ' ')
if not texto:
return False
if len(texto) == 10:
texto += ' 00:00:00'
try:
return fields.Datetime.to_datetime(texto)
except Exception:
raise ValueError('La fecha no es válida (usa AAAA-MM-DD HH:MM)')
def _seg_dict(seg):
return {
'id': seg.id,
'name': seg.name,
'descripcion': seg.descripcion or '',
'activo': seg.activo,
'criterios': seg.criterios_dict(),
'ultimo_conteo': seg.ultimo_conteo,
'muestra': seg.muestra_lista(),
'preview_at': seg.preview_at.strftime('%Y-%m-%d %H:%M') if seg.preview_at else None,
'creado_por': seg.creado_por or '',
}
def _camp_dict(c):
return {
'id': c.id,
'name': c.name,
'descripcion': c.descripcion or '',
'activo': c.activo,
'segmento_id': c.segmento_id.id if c.segmento_id else None,
'segmento': c.segmento_id.name if c.segmento_id else '',
'canal': c.canal,
'canales': c.canales_lista(),
'plantilla': c.plantilla or '',
'enlace': c.enlace or '',
'programacion': c.programacion,
'fecha_programada': c.fecha_programada.strftime('%Y-%m-%d %H:%M')
if c.fecha_programada else None,
'hora': c.hora or '',
'estado': c.estado,
'ultima_ejecucion': c.ultima_ejecucion.strftime('%Y-%m-%d %H:%M')
if c.ultima_ejecucion else None,
'proxima_ejecucion': c.proxima_ejecucion.strftime('%Y-%m-%d %H:%M')
if c.proxima_ejecucion else None,
'motivo_pausa': c.motivo_pausa or '',
'creado_por': c.creado_por or '',
'notas': c.notas or '',
}
class SkeenCampanasController(http.Controller):
# ------------------------------------------------------------------
# Utilidades
# ------------------------------------------------------------------
def _cfg(self):
return get_campanas(request.env)
def _requiere_activo(self):
if not self._cfg()['activo']:
return _error('El módulo de campañas está apagado. Actívalo en '
'Configuración para enviar.', 400)
return None
def _vals_segmento(self, data, crear):
vals = {}
if 'name' in data or crear:
name = str(data.get('name') or '').strip()
if not name:
raise ValueError('El nombre del segmento es obligatorio')
vals['name'] = name[:120]
if 'descripcion' in data:
vals['descripcion'] = str(data.get('descripcion') or '')[:2000]
if 'activo' in data:
vals['activo'] = bool(data['activo'])
if 'criterios' in data or crear:
vals['criterios'] = json.dumps(
validar_criterios(data.get('criterios') or {}), ensure_ascii=False)
return vals
def _vals_campana(self, data, crear):
vals = {}
if 'name' in data or crear:
name = str(data.get('name') or '').strip()
if not name:
raise ValueError('El nombre de la campaña es obligatorio')
vals['name'] = name[:200]
if 'descripcion' in data:
vals['descripcion'] = str(data.get('descripcion') or '')[:2000]
if 'activo' in data:
vals['activo'] = bool(data['activo'])
if 'segmento_id' in data:
sid = data.get('segmento_id')
if sid in (None, '', 0):
vals['segmento_id'] = False
else:
seg = request.env['skeen.segmento'].sudo().browse(int(sid))
if not seg.exists():
raise ValueError('El segmento no existe')
vals['segmento_id'] = seg.id
if 'canal' in data:
canal = str(data.get('canal') or '').strip()
if canal not in ('whatsapp', 'email', 'ambos'):
raise ValueError('El canal debe ser whatsapp, email o ambos')
vals['canal'] = canal
if 'plantilla' in data:
vals['plantilla'] = str(data.get('plantilla') or '')[:2000]
if 'enlace' in data:
vals['enlace'] = str(data.get('enlace') or '').strip()[:500]
if 'programacion' in data or crear:
prog = str(data.get('programacion') or 'una_vez').strip()
if prog not in ('una_vez', 'mensual', 'semanal', 'segmento'):
raise ValueError('Programación no válida')
vals['programacion'] = prog
if 'fecha_programada' in data:
vals['fecha_programada'] = _parse_dt(data.get('fecha_programada'))
if 'hora' in data:
hora = str(data.get('hora') or '').strip()
partes = hora.split(':')
if len(partes) != 2:
raise ValueError('La hora debe tener el formato HH:MM')
try:
h, m = int(partes[0]), int(partes[1])
except (TypeError, ValueError):
raise ValueError('La hora debe tener el formato HH:MM')
if not (0 <= h <= 23 and 0 <= m <= 59):
raise ValueError('La hora debe tener el formato HH:MM')
vals['hora'] = '%02d:%02d' % (h, m)
if 'notas' in data:
vals['notas'] = str(data.get('notas') or '')[:2000]
return vals
# ------------------------------------------------------------------
# Segmentos
# ------------------------------------------------------------------
@http.route(PREFIX + '/segmentos', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('campanas.ver')
def listar_segmentos(self, **kw):
try:
incluir_inactivos = str(kw.get('include_inactive') or '') in ('1', 'true', 'True')
dominio = [] if incluir_inactivos else [('activo', '=', True)]
segmentos = request.env['skeen.segmento'].sudo().search(dominio, order='name, id')
return _json({'status': 'success',
'segmentos': [_seg_dict(s) for s in segmentos],
'total': len(segmentos)})
except Exception as e:
return _error(str(e), 500)
@http.route(PREFIX + '/segmentos/preview', type='http', auth='none',
methods=['POST', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('campanas.ver')
def preview_criterios(self, **kw):
"""Vista previa (conteo + muestra) SIN guardar el segmento."""
try:
data = _body()
resultado = request.env['skeen.segmento'].sudo().previsualizar(
data.get('criterios') or {})
return _json({'status': 'success', **resultado})
except Exception as e:
return _error(str(e), 500)
@http.route(PREFIX + '/segmentos/<int:segmento_id>', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('campanas.ver')
def get_segmento(self, segmento_id, **kw):
seg = request.env['skeen.segmento'].sudo().browse(segmento_id)
if not seg.exists():
return _error('Segmento no encontrado', 404)
return _json({'status': 'success', 'segmento': _seg_dict(seg)})
@http.route(PREFIX + '/segmentos', type='http', auth='none',
methods=['POST'], csrf=False)
@require_role('recepcion')
@require_perm('campanas.gestionar')
def crear_segmento(self, **kw):
data = _body()
try:
vals = self._vals_segmento(data, crear=True)
usuario = current_user(request.env)
vals['creado_por'] = usuario.name if usuario else ''
seg = request.env['skeen.segmento'].sudo().create(vals)
seg.guardar_preview()
return _json({'status': 'success', 'segmento': _seg_dict(seg)}, 201)
except ValueError as e:
request.env.cr.rollback()
return _error(str(e), 400)
except Exception as e:
request.env.cr.rollback()
return _error(str(e), 500)
@http.route(PREFIX + '/segmentos/<int:segmento_id>', type='http', auth='none',
methods=['PUT', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('campanas.gestionar')
def actualizar_segmento(self, segmento_id, **kw):
seg = request.env['skeen.segmento'].sudo().browse(segmento_id)
if not seg.exists():
return _error('Segmento no encontrado', 404)
data = _body()
try:
seg.write(self._vals_segmento(data, crear=False))
if 'criterios' in data:
seg.guardar_preview()
return _json({'status': 'success', 'segmento': _seg_dict(seg)})
except ValueError as e:
request.env.cr.rollback()
return _error(str(e), 400)
except Exception as e:
request.env.cr.rollback()
return _error(str(e), 500)
@http.route(PREFIX + '/segmentos/<int:segmento_id>', type='http', auth='none',
methods=['DELETE'], csrf=False)
@require_role('recepcion')
@require_perm('campanas.gestionar')
def borrar_segmento(self, segmento_id, **kw):
seg = request.env['skeen.segmento'].sudo().browse(segmento_id)
if not seg.exists():
return _error('Segmento no encontrado', 404)
try:
usadas = request.env['skeen.campana'].sudo().search_count(
[('segmento_id', '=', seg.id)])
if usadas:
seg.write({'activo': False})
return _json({'status': 'success', 'desactivado': True,
'message': 'El segmento está en uso: se desactivó '
'en lugar de borrarse.'})
seg.unlink()
return _json({'status': 'success', 'desactivado': False,
'message': 'Segmento eliminado'})
except Exception as e:
request.env.cr.rollback()
return _error(str(e), 500)
@http.route(PREFIX + '/segmentos/<int:segmento_id>/preview', type='http',
auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('campanas.ver')
def recalcular_segmento(self, segmento_id, **kw):
seg = request.env['skeen.segmento'].sudo().browse(segmento_id)
if not seg.exists():
return _error('Segmento no encontrado', 404)
try:
seg.guardar_preview()
return _json({'status': 'success', 'segmento': _seg_dict(seg)})
except Exception as e:
request.env.cr.rollback()
return _error(str(e), 500)
# ------------------------------------------------------------------
# Consentimiento
# ------------------------------------------------------------------
@http.route(PREFIX + '/consentimiento/<int:partner_id>', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('campanas.ver')
def get_consentimiento(self, partner_id, **kw):
partner = request.env['res.partner'].sudo().browse(partner_id)
if not partner.exists():
return _error('Paciente no encontrado', 404)
return _json({'status': 'success', 'consentimiento': _consent_dict(partner)})
@http.route(PREFIX + '/consentimiento/<int:partner_id>', type='http', auth='none',
methods=['PUT', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('campanas.gestionar')
def set_consentimiento(self, partner_id, **kw):
partner = request.env['res.partner'].sudo().browse(partner_id)
if not partner.exists() or not partner.is_patient:
return _error('Paciente no encontrado', 404)
data = _body()
try:
usuario = current_user(request.env)
request.env['res.partner']._marcar_consentimiento(
partner,
general=data.get('general'),
whatsapp=data.get('whatsapp'),
email=data.get('email'),
origen=(usuario.name if usuario else '') or str(data.get('origen') or ''))
return _json({'status': 'success',
'consentimiento': _consent_dict(partner)})
except Exception as e:
request.env.cr.rollback()
return _error(str(e), 500)
# ------------------------------------------------------------------
# Campañas
# ------------------------------------------------------------------
@http.route(PREFIX, type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('campanas.ver')
def listar_campanas(self, **kw):
try:
dominio = []
if kw.get('estado'):
dominio.append(('estado', '=', kw['estado']))
campanas = request.env['skeen.campana'].sudo().search(
dominio, order='create_date desc, id desc', limit=500)
return _json({'status': 'success',
'campanas': [_camp_dict(c) for c in campanas],
'total': len(campanas)})
except Exception as e:
return _error(str(e), 500)
@http.route(PREFIX, type='http', auth='none', methods=['POST'], csrf=False)
@require_role('recepcion')
@require_perm('campanas.gestionar')
def crear_campana(self, **kw):
data = _body()
try:
vals = self._vals_campana(data, crear=True)
usuario = current_user(request.env)
vals['creado_por'] = usuario.name if usuario else ''
campana = request.env['skeen.campana'].sudo().create(vals)
return _json({'status': 'success', 'campana': _camp_dict(campana)}, 201)
except ValueError as e:
request.env.cr.rollback()
return _error(str(e), 400)
except Exception as e:
request.env.cr.rollback()
return _error(str(e), 500)
@http.route(PREFIX + '/<int:campana_id>', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('campanas.ver')
def get_campana(self, campana_id, **kw):
campana = request.env['skeen.campana'].sudo().browse(campana_id)
if not campana.exists():
return _error('Campaña no encontrada', 404)
return _json({'status': 'success', 'campana': _camp_dict(campana)})
@http.route(PREFIX + '/<int:campana_id>', type='http', auth='none',
methods=['PUT', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('campanas.gestionar')
def actualizar_campana(self, campana_id, **kw):
campana = request.env['skeen.campana'].sudo().browse(campana_id)
if not campana.exists():
return _error('Campaña no encontrada', 404)
data = _body()
try:
campana.write(self._vals_campana(data, crear=False))
return _json({'status': 'success', 'campana': _camp_dict(campana)})
except ValueError as e:
request.env.cr.rollback()
return _error(str(e), 400)
except Exception as e:
request.env.cr.rollback()
return _error(str(e), 500)
@http.route(PREFIX + '/<int:campana_id>', type='http', auth='none',
methods=['DELETE'], csrf=False)
@require_role('recepcion')
@require_perm('campanas.gestionar')
def borrar_campana(self, campana_id, **kw):
campana = request.env['skeen.campana'].sudo().browse(campana_id)
if not campana.exists():
return _error('Campaña no encontrada', 404)
try:
campana.write({'activo': False, 'estado': 'cancelada'})
return _json({'status': 'success',
'message': 'La campaña se archivó (cancelada y desactivada).'})
except Exception as e:
request.env.cr.rollback()
return _error(str(e), 500)
# ------------------------------------------------------------------
# Acciones
# ------------------------------------------------------------------
def _accion(self, campana_id, accion, **kw):
campana = request.env['skeen.campana'].sudo().browse(campana_id)
if not campana.exists():
return _error('Campaña no encontrada', 404)
try:
if accion == 'lanzar':
if not campana.segmento_id:
return _error('La campaña necesita un segmento para lanzarse')
if not (campana.plantilla or '').strip():
return _error('La campaña necesita un mensaje para lanzarse')
if campana.programacion == 'una_vez' and not campana.fecha_programada:
campana.fecha_programada = fields.Datetime.now()
campana.action_lanzar()
elif accion == 'pausar':
campana.action_pausar()
elif accion == 'reanudar':
campana.action_reanudar()
elif accion == 'cancelar':
campana.action_cancelar()
elif accion == 'borrador':
campana.action_borrador()
elif accion == 'procesar':
servicio = request.env['skeen.campana.service'].sudo()
resultado = servicio.ejecutar()
return _json({'status': 'success', **resultado,
'campana': _camp_dict(campana)})
return _json({'status': 'success', 'campana': _camp_dict(campana)})
except Exception as e:
request.env.cr.rollback()
return _error(str(e), 500)
@http.route(PREFIX + '/<int:campana_id>/<string:accion>', type='http', auth='none',
methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('campanas.enviar')
def accion_campana(self, campana_id, accion, **kw):
bloqueo = self._requiere_activo()
if bloqueo:
return bloqueo
if accion not in ('lanzar', 'pausar', 'reanudar', 'cancelar', 'borrador',
'procesar'):
return _error('Acción no válida', 404)
return self._accion(campana_id, accion, **kw)
@http.route(PREFIX + '/procesar', type='http', auth='none',
methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('campanas.enviar')
def procesar_campanas(self, **kw):
bloqueo = self._requiere_activo()
if bloqueo:
return bloqueo
try:
resultado = request.env['skeen.campana.service'].sudo().ejecutar()
return _json({'status': 'success', **resultado})
except Exception as e:
request.env.cr.rollback()
return _error(str(e), 500)
@http.route(PREFIX + '/<int:campana_id>/prueba', type='http', auth='none',
methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('campanas.enviar')
def prueba_campana(self, campana_id, **kw):
bloqueo = self._requiere_activo()
if bloqueo:
return bloqueo
campana = request.env['skeen.campana'].sudo().browse(campana_id)
if not campana.exists():
return _error('Campaña no encontrada', 404)
data = _body()
partner = None
if data.get('partner_id'):
partner = request.env['res.partner'].sudo().browse(int(data['partner_id']))
if not partner.exists() or not partner.is_patient:
return _error('El paciente indicado no existe', 404)
try:
resultado = request.env['skeen.campana.service'].sudo().enviar_prueba(
campana, partner=partner,
telefono=str(data.get('telefono') or '').strip(),
correo=str(data.get('correo') or '').strip())
return _json({'status': 'success' if resultado.get('ok') else 'error',
**resultado}, 200 if resultado.get('ok') else 400)
except Exception as e:
request.env.cr.rollback()
return _error(str(e), 500)
# ------------------------------------------------------------------
# Medición
# ------------------------------------------------------------------
@http.route(PREFIX + '/<int:campana_id>/metricas', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('campanas.medir')
def metricas_campana(self, campana_id, **kw):
campana = request.env['skeen.campana'].sudo().browse(campana_id)
if not campana.exists():
return _error('Campaña no encontrada', 404)
try:
servicio = request.env['skeen.campana.service'].sudo()
campana_res = campana.with_context(lang=campana.env.user.lang)
datos = campana_res.metricas(start=kw.get('start'), end=kw.get('end'))
return _json({'status': 'success', 'metricas': datos})
except Exception as e:
return _error(str(e), 500)
@http.route(PREFIX + '/metricas', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('campanas.medir')
def metricas_todas(self, **kw):
try:
servicio = request.env['skeen.campana.service'].sudo()
campanas = request.env['skeen.campana'].sudo().search([], limit=500)
datos = [campana.metricas(start=kw.get('start'), end=kw.get('end'))
for campana in campanas]
total = {
'alcanzados': sum(d['alcanzados'] for d in datos),
'enviados': sum(d['enviados'] for d in datos),
'citas_atribuidas': sum(d['citas_atribuidas'] for d in datos),
'valor_atribuido': round(sum(d['valor_atribuido'] for d in datos), 2),
}
return _json({'status': 'success', 'metricas': datos, 'totales': total})
except Exception as e:
return _error(str(e), 500)
@http.route(PREFIX + '/<int:campana_id>/envios', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('campanas.medir')
def envios_campana(self, campana_id, **kw):
campana = request.env['skeen.campana'].sudo().browse(campana_id)
if not campana.exists():
return _error('Campaña no encontrada', 404)
try:
servicio = request.env['skeen.campana.service'].sudo()
dominio = [('campana_id', '=', campana.id)]
if str(kw.get('incluir_pruebas') or '') not in ('1', 'true', 'True'):
dominio.append(('es_prueba', '=', False))
notifs = request.env['skeen.notificacion'].sudo().search(
dominio, order='id desc', limit=500)
return _json({'status': 'success',
'envios': [servicio._notif_dict(n) for n in notifs],
'total': len(notifs)})
except Exception as e:
return _error(str(e), 500)
def _consent_dict(partner):
return {
'partner_id': partner.id,
'name': partner.name,
'general': bool(partner.consentimiento_marketing),
'whatsapp': bool(partner.consentimiento_whatsapp),
'email': bool(partner.consentimiento_email),
'at': partner.consentimiento_at.strftime('%Y-%m-%d %H:%M')
if partner.consentimiento_at else None,
'origen': partner.consentimiento_origen or '',
}

View File

@@ -0,0 +1,258 @@
# -*- coding: utf-8 -*-
"""API autenticada de encuestas de satisfacción (NPS).
Rutas bajo `/skeen/frontend/v1/encuestas/*`. Guard doble `@require_role` +
`@require_perm` (`encuestas.ver|gestionar|medir`).
Con `encuestas.activo=false` (default) no se encola ni se envía nada; el
reporte simplemente muestra lo que haya (cero en una instancia recién
instalada).
"""
import json
from datetime import date, timedelta
from odoo import fields, http
from odoo.http import request
from odoo.addons.skeen_whatsapp.controllers.auth import (
_json, current_user, require_role, require_perm,
)
from odoo.addons.skeen_citas.clinic_settings import get_encuestas
PREFIX = '/skeen/frontend/v1/encuestas'
def _error(message, status=400):
return _json({'status': 'error', 'message': message}, status)
def _body():
try:
return json.loads(request.httprequest.get_data(as_text=True) or '{}')
except Exception:
return {}
def _rango(kw):
hoy = date.today()
start = kw.get('start') or (hoy.replace(day=1)).strftime('%Y-%m-%d')
end = kw.get('end') or hoy.strftime('%Y-%m-%d')
return start, end
def _respuesta_dict(r, sucursales=None):
cita = r.cita_id
sucursal = ''
if cita and cita.branch:
sucursal = (sucursales or {}).get(cita.branch, cita.branch)
return {
'id': r.id,
'name': r.name,
'partner_id': r.partner_id.id,
'patient': r.partner_id.name,
'phone': r.partner_id.whatsapp or r.partner_id.phone or '',
'nps_puntaje': r.nps_puntaje,
'nps_categoria': r.nps_categoria or '',
'comentario': r.nps_comentario or '',
'llenado_at': r.llenado_at.strftime('%Y-%m-%d %H:%M') if r.llenado_at else None,
'cita_id': cita.id if cita else None,
'sucursal': sucursal,
'doctor': cita.doctor_id.name if cita and cita.doctor_id else '',
'servicio': cita.servicio_id.name if cita and cita.servicio_id else '',
}
class SkeenEncuestasController(http.Controller):
def _cfg(self):
return get_encuestas(request.env)
def _plantilla(self):
pid = self._cfg().get('plantilla_id')
if not pid:
return request.env['skeen.formulario.plantilla'].sudo().browse()
p = request.env['skeen.formulario.plantilla'].sudo().browse(int(pid))
return p if p.exists() else request.env['skeen.formulario.plantilla'].sudo().browse()
def _respuestas(self, start, end, solo_nps=False):
plantilla = self._plantilla()
if not plantilla:
return request.env['skeen.formulario.respuesta'].sudo().browse()
dominio = [
('plantilla_id', '=', plantilla.id),
('estado', '=', 'llenado'),
('llenado_at', '>=', '%s 00:00:00' % start),
('llenado_at', '<=', '%s 23:59:59' % end),
]
if solo_nps:
dominio.append(('nps_puntaje', '>=', 0))
return request.env['skeen.formulario.respuesta'].sudo().search(
dominio, order='llenado_at desc, id desc')
def _sucursales(self):
return {s.code: s.name for s in
request.env['skeen.sucursal'].sudo().search([])}
# ------------------------------------------------------------------
# Configuración
# ------------------------------------------------------------------
@http.route(PREFIX, type='http', auth='none', methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('encuestas.ver')
def config_encuestas(self, **kw):
try:
cfg = self._cfg()
plantillas = request.env['skeen.formulario.plantilla'].sudo().search(
[('tipo', '=', 'encuesta')], order='name')
plantilla = self._plantilla()
return _json({
'status': 'success',
'config': cfg,
'plantilla': {
'id': plantilla.id, 'name': plantilla.name,
'activo': plantilla.activo,
} if plantilla else None,
'plantillas': [{
'id': p.id, 'name': p.name, 'activo': p.activo,
'tiene_nps': any(c.get('tipo') == 'nps' for c in p.campos_lista()),
} for p in plantillas],
})
except Exception as e:
return _error(str(e), 500)
# ------------------------------------------------------------------
# Reporte de satisfacción
# ------------------------------------------------------------------
@http.route(PREFIX + '/reporte', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('encuestas.medir')
def reporte_satisfaccion(self, **kw):
try:
start, end = _rango(kw)
Respuesta = request.env['skeen.formulario.respuesta'].sudo()
respuestas = self._respuestas(start, end, solo_nps=True)
general = Respuesta.nps_de(respuestas)
sucursales = self._sucursales()
# Tendencia por mes (últimos 12 meses, independiente del rango).
hoy = date.today()
tendencia = []
for i in range(11, -1, -1):
m = (hoy.replace(day=1) - timedelta(days=i * 31)).replace(day=1)
fin = (m.replace(day=28) + timedelta(days=4)).replace(day=1) - timedelta(days=1)
del_mes = Respuesta.search([
('plantilla_id', '=', self._plantilla().id),
('estado', '=', 'llenado'),
('nps_puntaje', '>=', 0),
('llenado_at', '>=', '%s 00:00:00' % m.strftime('%Y-%m-%d')),
('llenado_at', '<=', '%s 23:59:59' % fin.strftime('%Y-%m-%d')),
])
datos = Respuesta.nps_de(del_mes)
tendencia.append({
'mes': m.strftime('%Y-%m'),
'total': datos['total'],
'nps': datos['nps'],
'promotores': datos['promotores'],
'detractores': datos['detractores'],
})
def desglose(etiqueta):
grupos = {}
for r in respuestas:
valor = etiqueta(r) or 'Sin dato'
grupos.setdefault(valor, Respuesta.browse())
grupos[valor] |= r
salida = []
for valor, resps in grupos.items():
d = Respuesta.nps_de(resps)
salida.append({'nombre': valor, **d})
salida.sort(key=lambda x: (-(x['nps'] if x['nps'] is not None else -999),
-x['total']))
return salida
return _json({
'status': 'success',
'start': start,
'end': end,
'general': general,
'tendencia': tendencia,
'por_sucursal': desglose(lambda r: (r.cita_id.branch
and sucursales.get(r.cita_id.branch))
or (r.cita_id.branch if r.cita_id else '')),
'por_doctor': desglose(
lambda r: r.cita_id.doctor_id.name if r.cita_id and r.cita_id.doctor_id else ''),
'por_servicio': desglose(
lambda r: r.cita_id.servicio_id.name if r.cita_id and r.cita_id.servicio_id else ''),
})
except Exception as e:
return _error(str(e), 500)
@http.route(PREFIX + '/respuestas', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('encuestas.medir')
def listar_respuestas(self, **kw):
try:
start, end = _rango(kw)
respuestas = self._respuestas(start, end, solo_nps=False)
sucursales = self._sucursales()
return _json({
'status': 'success',
'respuestas': [_respuesta_dict(r, sucursales) for r in respuestas[:500]],
'total': len(respuestas),
})
except Exception as e:
return _error(str(e), 500)
# ------------------------------------------------------------------
# Acciones
# ------------------------------------------------------------------
@http.route(PREFIX + '/procesar', type='http', auth='none',
methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('encuestas.gestionar')
def procesar_encuestas(self, **kw):
if not self._cfg()['activo']:
return _error('Las encuestas de salida están apagadas. Actívalas en '
'Configuración.', 400)
try:
resultado = request.env['skeen.encuesta.service'].sudo().ejecutar()
return _json({'status': 'success', **resultado})
except Exception as e:
request.env.cr.rollback()
return _error(str(e), 500)
@http.route(PREFIX + '/respuestas/<int:respuesta_id>/whatsapp', type='http',
auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('encuestas.medir')
def responder_whatsapp(self, respuesta_id, **kw):
"""Responde por WhatsApp al paciente desde el reporte (vía el motor)."""
respuesta = request.env['skeen.formulario.respuesta'].sudo().browse(respuesta_id)
if not respuesta.exists():
return _error('Respuesta no encontrada', 404)
data = _body()
mensaje = str(data.get('mensaje') or '').strip()
if not mensaje:
return _error('Escribe un mensaje para enviar')
partner = respuesta.partner_id
servicio = request.env['skeen.notificacion.service'].sudo()
try:
import uuid as _uuid
notifs = servicio.encolar(
partner=partner, tipo='manual', canal='whatsapp',
cuando=fields.Datetime.now(), mensaje=mensaje[:1000],
clave_unica='encuesta-wa:r%s:%s' % (respuesta.id, _uuid.uuid4().hex[:10]))
if not notifs:
return _error('No se pudo enviar. Revisa que las notificaciones '
'estén activas y el paciente tenga teléfono.', 400)
cfg = servicio._cfg()
estados = [servicio._intentar(n, cfg) for n in notifs]
return _json({'status': 'success',
'simulado': bool(cfg['modo_prueba']),
'notificaciones': len(notifs)})
except Exception as e:
request.env.cr.rollback()
return _error(str(e), 500)