256 lines
10 KiB
Python
256 lines
10 KiB
Python
# -*- coding: utf-8 -*-
|
||
"""Encuestas de salida y medición de satisfacción (NPS).
|
||
|
||
Cuando una cita pasa a completada y `encuestas.activo=true`, tras N horas se
|
||
envía al paciente la encuesta configurada (por WhatsApp o correo) usando el
|
||
motor de notificaciones y la página pública de formularios que ya existe. Hay
|
||
recordatorio opcional y una opción de enviarla solo a la primera visita.
|
||
|
||
La pregunta de recomendación se guarda con un tipo de campo PROPIO (`nps`,
|
||
puntaje de 0 a 10) para poder analizarla sin depender del texto. Se calcula de
|
||
la forma estándar: promotores 9-10, detractores 0-6 y
|
||
NPS = %promotores − %detractores (de −100 a 100).
|
||
"""
|
||
|
||
import json
|
||
from datetime import timedelta
|
||
|
||
from odoo import api, fields, models
|
||
|
||
from odoo.addons.skeen_citas.clinic_settings import get_encuestas
|
||
|
||
ENCUESTA_LOOKBACK_DIAS = 30
|
||
PROCESAR_POR_DEFECTO = 100
|
||
|
||
|
||
class SkeenFormularioRespuesta(models.Model):
|
||
_inherit = 'skeen.formulario.respuesta'
|
||
|
||
nps_puntaje = fields.Integer(
|
||
string='NPS (0-10)', compute='_compute_nps', store=True, index=True,
|
||
help='-1 cuando la respuesta no incluye la pregunta de recomendación.')
|
||
nps_categoria = fields.Selection([
|
||
('promotor', 'Promotor'), ('pasivo', 'Pasivo'), ('detractor', 'Detractor'),
|
||
], string='Categoría NPS', compute='_compute_nps', store=True, index=True)
|
||
nps_comentario = fields.Text(string='Comentario', compute='_compute_nps', store=True)
|
||
|
||
@api.depends('respuestas', 'plantilla_id')
|
||
def _compute_nps(self):
|
||
for r in self:
|
||
puntaje = -1
|
||
comentario = ''
|
||
datos = {}
|
||
try:
|
||
datos = json.loads(r.respuestas or '{}')
|
||
except (ValueError, TypeError):
|
||
datos = {}
|
||
for campo in r.plantilla_id.campos_lista():
|
||
clave = campo.get('clave')
|
||
tipo = campo.get('tipo')
|
||
valor = datos.get(clave)
|
||
if tipo == 'nps':
|
||
try:
|
||
n = int(valor)
|
||
except (TypeError, ValueError):
|
||
continue
|
||
if 0 <= n <= 10:
|
||
puntaje = n
|
||
elif tipo == 'texto_largo' and not comentario and valor:
|
||
comentario = str(valor)
|
||
r.nps_puntaje = puntaje
|
||
r.nps_comentario = comentario
|
||
if puntaje < 0:
|
||
r.nps_categoria = False
|
||
elif puntaje >= 9:
|
||
r.nps_categoria = 'promotor'
|
||
elif puntaje >= 7:
|
||
r.nps_categoria = 'pasivo'
|
||
else:
|
||
r.nps_categoria = 'detractor'
|
||
|
||
@api.model
|
||
def nps_de(self, respuestas):
|
||
"""Cálculo estándar del NPS sobre un conjunto de respuestas."""
|
||
validas = respuestas.filtered(lambda r: r.nps_puntaje >= 0)
|
||
total = len(validas)
|
||
if not total:
|
||
return {'total': 0, 'promotores': 0, 'pasivos': 0, 'detractores': 0,
|
||
'nps': None, 'promotores_pct': 0.0, 'detractores_pct': 0.0,
|
||
'pasivos_pct': 0.0}
|
||
promotores = len(validas.filtered(lambda r: r.nps_categoria == 'promotor'))
|
||
pasivos = len(validas.filtered(lambda r: r.nps_categoria == 'pasivo'))
|
||
detractores = len(validas.filtered(lambda r: r.nps_categoria == 'detractor'))
|
||
pct_p = promotores * 100.0 / total
|
||
pct_d = detractores * 100.0 / total
|
||
return {
|
||
'total': total,
|
||
'promotores': promotores,
|
||
'pasivos': pasivos,
|
||
'detractores': detractores,
|
||
'promotores_pct': round(pct_p, 1),
|
||
'detractores_pct': round(pct_d, 1),
|
||
'pasivos_pct': round(pasivos * 100.0 / total, 1),
|
||
'nps': round(pct_p - pct_d),
|
||
}
|
||
|
||
|
||
class SkeenEncuestaService(models.AbstractModel):
|
||
"""Envío automático de la encuesta de salida (encolado y procesado)."""
|
||
_name = 'skeen.encuesta.service'
|
||
_description = 'Servicio de encuestas de satisfacción'
|
||
|
||
@api.model
|
||
def _cfg(self):
|
||
return get_encuestas(self.env)
|
||
|
||
@api.model
|
||
def _plantilla(self, cfg):
|
||
pid = cfg.get('plantilla_id')
|
||
Plantilla = self.env['skeen.formulario.plantilla'].sudo()
|
||
if not pid:
|
||
return Plantilla.browse()
|
||
plantilla = Plantilla.browse(int(pid))
|
||
return plantilla if plantilla.exists() and plantilla.activo else Plantilla.browse()
|
||
|
||
@api.model
|
||
def _canales(self, cfg):
|
||
if cfg['canal'] == 'ambos':
|
||
return ['whatsapp', 'email']
|
||
return [cfg['canal']]
|
||
|
||
@api.model
|
||
def _enlace(self, token):
|
||
from odoo.addons.skeen_citas.clinic_settings import url_publica
|
||
base = url_publica(self.env, '')
|
||
ruta = '/formulario/%s' % token
|
||
return (base + ruta) if base else ruta
|
||
|
||
# ------------------------------------------------------------------
|
||
# Encolado
|
||
# ------------------------------------------------------------------
|
||
@api.model
|
||
def _encolar_citas(self, cfg, plantilla):
|
||
from odoo.addons.skeen_formularios.controllers.comun import firmar_token
|
||
ahora = fields.Datetime.now()
|
||
limite = ahora - timedelta(hours=cfg['enviar_tras_horas'])
|
||
desde = ahora - timedelta(days=ENCUESTA_LOOKBACK_DIAS)
|
||
citas = self.env['skeen.cita'].sudo().search([
|
||
('state', '=', 'done'),
|
||
('datetime_end', '<=', limite),
|
||
('datetime_end', '>=', desde),
|
||
])
|
||
Respuesta = self.env['skeen.formulario.respuesta'].sudo()
|
||
servicio = self.env['skeen.notificacion.service'].sudo()
|
||
notif_cfg = servicio._cfg()
|
||
creadas = 0
|
||
for cita in citas:
|
||
partner = cita.partner_id
|
||
if not partner:
|
||
continue
|
||
if cfg['solo_primera_visita']:
|
||
hechas = self.env['skeen.cita'].sudo().search_count([
|
||
('partner_id', '=', partner.id), ('state', '=', 'done')])
|
||
if hechas > 1:
|
||
continue
|
||
if Respuesta.search([
|
||
('cita_id', '=', cita.id), ('plantilla_id', '=', plantilla.id)],
|
||
limit=1):
|
||
continue
|
||
respuesta = Respuesta.create({
|
||
'plantilla_id': plantilla.id,
|
||
'plantilla_version': plantilla.version,
|
||
'partner_id': partner.id,
|
||
'cita_id': cita.id,
|
||
'estado': 'enviado',
|
||
'enviado_at': ahora,
|
||
'canal_envio': cfg['canal'] if cfg['canal'] != 'ambos' else 'whatsapp',
|
||
'respuestas': '{}',
|
||
})
|
||
token = firmar_token(self.env, respuesta)
|
||
respuesta.write({'token': token})
|
||
enlace = self._enlace(token)
|
||
mensaje = servicio.render(
|
||
notif_cfg.get('plantilla_formulario') or '', cita=cita,
|
||
partner=partner, cfg=notif_cfg,
|
||
extra={'enlace': enlace, 'formulario': plantilla.name})
|
||
for canal in self._canales(cfg):
|
||
clave = 'encuesta:c%s:%s' % (cita.id, canal)
|
||
creadas += len(servicio.encolar(
|
||
cita=cita, tipo='encuesta', canal=canal, cuando=ahora,
|
||
mensaje=mensaje, clave_unica=clave, forzar=True,
|
||
vals_extra={'destino': False}))
|
||
return creadas
|
||
|
||
@api.model
|
||
def _encolar_recordatorios(self, cfg, plantilla):
|
||
horas = cfg['recordatorio_horas']
|
||
if horas <= 0:
|
||
return 0
|
||
ahora = fields.Datetime.now()
|
||
tope = ahora - timedelta(hours=horas)
|
||
Respuesta = self.env['skeen.formulario.respuesta'].sudo()
|
||
pendientes = Respuesta.search([
|
||
('plantilla_id', '=', plantilla.id),
|
||
('estado', '=', 'enviado'),
|
||
('enviado_at', '<=', tope),
|
||
])
|
||
servicio = self.env['skeen.notificacion.service'].sudo()
|
||
notif_cfg = servicio._cfg()
|
||
creados = 0
|
||
for r in pendientes:
|
||
enlace = self._enlace(r.token)
|
||
mensaje = servicio.render(
|
||
notif_cfg.get('plantilla_formulario') or '', cita=r.cita_id or None,
|
||
partner=r.partner_id, cfg=notif_cfg,
|
||
extra={'enlace': enlace, 'formulario': plantilla.name})
|
||
for canal in self._canales(cfg):
|
||
clave = 'encuesta-rec:r%s:%s' % (r.id, canal)
|
||
creados += len(servicio.encolar(
|
||
cita=r.cita_id or None, partner=r.partner_id, tipo='encuesta',
|
||
canal=canal, cuando=ahora, mensaje=mensaje,
|
||
clave_unica=clave, forzar=True))
|
||
return creados
|
||
|
||
# ------------------------------------------------------------------
|
||
# Procesado de la cola de encuestas
|
||
# ------------------------------------------------------------------
|
||
@api.model
|
||
def _procesar(self, cfg, limite=None):
|
||
servicio = self.env['skeen.notificacion.service'].sudo()
|
||
notif_cfg = servicio._cfg()
|
||
ahora = fields.Datetime.now()
|
||
pendientes = self.env['skeen.notificacion'].sudo().search([
|
||
('tipo', '=', 'encuesta'),
|
||
('estado', '=', 'pendiente'),
|
||
('programada_para', '<=', ahora),
|
||
], order='programada_para asc, id asc', limit=limite or PROCESAR_POR_DEFECTO)
|
||
resultado = {'enviadas': 0, 'simuladas': 0, 'fallidas': 0}
|
||
for notif in pendientes:
|
||
estado = servicio._intentar(notif, notif_cfg)
|
||
if estado == 'enviada':
|
||
resultado['enviadas'] += 1
|
||
elif estado == 'simulada':
|
||
resultado['simuladas'] += 1
|
||
elif estado == 'fallida':
|
||
resultado['fallidas'] += 1
|
||
return resultado
|
||
|
||
@api.model
|
||
def ejecutar(self, limite=None):
|
||
cfg = self._cfg()
|
||
resultado = {'activo': cfg['activo'], 'encoladas': 0, 'recordatorios': 0,
|
||
'enviadas': 0, 'simuladas': 0, 'fallidas': 0}
|
||
if not cfg['activo']:
|
||
return resultado
|
||
plantilla = self._plantilla(cfg)
|
||
if not plantilla:
|
||
return resultado
|
||
resultado['encoladas'] = self._encolar_citas(cfg, plantilla)
|
||
resultado['recordatorios'] = self._encolar_recordatorios(cfg, plantilla)
|
||
resultado.update(self._procesar(cfg, limite))
|
||
return resultado
|
||
|
||
@api.model
|
||
def _cron_tick(self):
|
||
return self.ejecutar()
|