# -*- 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()