Files
SKEEN-Proyecto/odoo-addons/skeen_campanas/models/encuesta.py

256 lines
10 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- 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()