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:
255
odoo-addons/skeen_campanas/models/encuesta.py
Normal file
255
odoo-addons/skeen_campanas/models/encuesta.py
Normal file
@@ -0,0 +1,255 @@
|
||||
# -*- 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()
|
||||
Reference in New Issue
Block a user