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,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()