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

@@ -48,6 +48,8 @@ TIPOS = [
('cobro_membresia', 'Cobro de membresía'),
('acceso_codigo', 'Código de acceso al portal'),
('acceso_enlace', 'Enlace de acceso al portal'),
('campana', 'Campaña'),
('encuesta', 'Encuesta'),
('manual', 'Manual'),
]
CANALES = [('whatsapp', 'WhatsApp'), ('email', 'Correo')]
@@ -98,7 +100,7 @@ class SkeenNotificacion(models.Model):
_description = 'Notificación de SKEEN'
_order = 'programada_para desc, id desc'
partner_id = fields.Many2one('res.partner', string='Paciente', required=True,
partner_id = fields.Many2one('res.partner', string='Paciente', required=False,
index=True, ondelete='cascade')
cita_id = fields.Many2one('skeen.cita', string='Cita', index=True,
ondelete='cascade')
@@ -108,6 +110,12 @@ class SkeenNotificacion(models.Model):
default='pendiente', index=True)
asunto = fields.Char(string='Asunto')
mensaje = fields.Text(string='Mensaje')
# Destino alterno (teléfono o correo) para avisos sin paciente, como el
# envío de prueba de una campaña a un número propio. Vacío = usar el del
# paciente.
destino = fields.Char(string='Destino alterno')
es_prueba = fields.Boolean(string='Es prueba', default=False, index=True,
help='Prueba de campaña: no cuenta en la medición.')
programada_para = fields.Datetime(string='Programada para', required=True,
default=fields.Datetime.now, index=True)
enviada_at = fields.Datetime(string='Enviada el')
@@ -241,51 +249,64 @@ class SkeenNotificacionService(models.AbstractModel):
@api.model
def encolar(self, cita=None, tipo='recordatorio', cuando=None, canal=None,
partner=None, ventana=None, clave_unica=None, asunto=None,
contexto_extra=None):
contexto_extra=None, mensaje=None, vals_extra=None, forzar=False,
destino=None):
"""Crea las notificaciones pendientes de una cita (una por canal).
Con `activo=False` no hace nada. No duplica: si ya existe la misma
`clave_unica` (misma cita + tipo + ventana + canal) se salta. Devuelve el
recordset de lo creado. `contexto_extra` alimenta variables adicionales
del render (p. ej. `{enlace}` del formulario digital).
`mensaje` permite encolar un texto ya resuelto (campañas y encuestas
traen su propia plantilla) en lugar de la del bloque `notificaciones`.
`vals_extra` añade campos al registro (p. ej. `campana_id`). `forzar=True`
salta el interruptor `notificaciones.activo` para los subsistemas que ya
validaron su propio interruptor (campañas, encuestas). `destino` permite
mandar a un número/correo explícito SIN paciente (envío de prueba).
"""
Notif = self.env['skeen.notificacion'].sudo()
cfg = self._cfg()
if not cfg['activo']:
if not cfg['activo'] and not forzar:
return Notif
partner = partner or (cita.partner_id if cita else None)
if not partner:
if not partner and not destino:
return Notif
plantilla = cfg.get(PLANTILLA_POR_TIPO.get(tipo, 'plantilla_recordatorio')) or ''
if not plantilla.strip():
return Notif
mensaje = self.render(plantilla, cita=cita, partner=partner, cfg=cfg,
extra=contexto_extra)
if mensaje is None:
plantilla = cfg.get(PLANTILLA_POR_TIPO.get(tipo, 'plantilla_recordatorio')) or ''
if not plantilla.strip():
return Notif
mensaje = self.render(plantilla, cita=cita, partner=partner, cfg=cfg,
extra=contexto_extra)
if asunto is None:
asunto = self._asunto(tipo)
canales = [canal] if canal else self._canales(cfg)
creadas = Notif
for ch in canales:
if not self._destino_disponible(partner, ch):
if not destino and not self._destino_disponible(partner, ch):
continue
clave = clave_unica or self._clave(tipo, cita, partner, ch, ventana)
if clave_unica and len(canales) > 1:
clave = '%s:%s' % (clave_unica, ch)
if Notif.search([('clave_unica', '=', clave)], limit=1):
continue
creadas |= Notif.create({
'partner_id': partner.id,
vals = {
'partner_id': partner.id if partner else False,
'cita_id': cita.id if cita is not None else False,
'tipo': tipo,
'canal': ch,
'estado': 'pendiente',
'asunto': asunto,
'mensaje': mensaje,
'destino': destino or False,
'programada_para': cuando or fields.Datetime.now(),
'clave_unica': clave,
})
}
if vals_extra:
vals.update(vals_extra)
creadas |= Notif.create(vals)
return creadas
@api.model
@@ -476,11 +497,13 @@ class SkeenNotificacionService(models.AbstractModel):
return 'simulada'
try:
destino = notif.destino or None
if notif.canal == 'whatsapp':
self.enviar_whatsapp(notif.partner_id, notif.mensaje, cfg=cfg)
self.enviar_whatsapp(notif.partner_id, notif.mensaje, cfg=cfg,
destino=destino)
else:
self.enviar_email(notif.partner_id, notif.asunto or 'Notificación',
notif.mensaje, cfg=cfg)
notif.mensaje, cfg=cfg, destino=destino)
except Exception as exc:
intentos = notif.intentos + 1
vals = {'intentos': intentos, 'error': str(exc)[:500]}
@@ -509,17 +532,21 @@ class SkeenNotificacionService(models.AbstractModel):
return (url or '').rstrip('/'), (key or '')
@api.model
def enviar_whatsapp(self, partner, mensaje, cfg=None):
def enviar_whatsapp(self, partner, mensaje, cfg=None, destino=None):
"""Envía un WhatsApp real vía la API v1 del WACRM.
Sin credenciales configuradas lanza un error claro (nunca envía a
ciegas ni revienta el proceso de la cola).
`destino` permite mandar a un número explícito (envío de prueba de una
campaña) sin depender del paciente. Sin credenciales configuradas lanza
un error claro (nunca envía a ciegas ni revienta el proceso de la cola).
"""
url, key = self._wacrm_credenciales()
if not url or not key:
raise UserError(_('No hay credenciales de WhatsApp (WACRM) configuradas '
'en esta instancia.'))
phone = (partner.whatsapp or partner.phone or '').strip() if partner else ''
if destino:
phone = str(destino).strip()
else:
phone = (partner.whatsapp or partner.phone or '').strip() if partner else ''
if not phone:
raise UserError(_('El paciente no tiene teléfono ni WhatsApp registrado.'))
digits = re.sub(r'\D', '', phone)
@@ -547,12 +574,17 @@ class SkeenNotificacionService(models.AbstractModel):
return bool(self.env['ir.mail_server'].sudo().search_count([]))
@api.model
def enviar_email(self, partner, asunto, mensaje, cfg=None):
"""Envía un correo real con `mail.mail`. Sin SMTP, error claro."""
if not partner:
raise UserError(_('Falta el paciente destinatario.'))
destino = (partner.email or '').strip()
if not destino:
def enviar_email(self, partner, asunto, mensaje, cfg=None, destino=None):
"""Envía un correo real con `mail.mail`. Sin SMTP, error claro.
`destino` permite mandar a un correo explícito (envío de prueba de una
campaña) sin depender del paciente.
"""
if destino:
email = str(destino).strip()
else:
email = (partner.email or '').strip() if partner else ''
if not email:
raise UserError(_('El paciente no tiene correo registrado.'))
if not self._smtp_configurado():
raise UserError(_('No hay servidor SMTP configurado en Odoo.'))
@@ -564,7 +596,7 @@ class SkeenNotificacionService(models.AbstractModel):
'subject': asunto or 'Notificación',
'body_html': '<div style="white-space:pre-wrap">%s</div>'
% html.escape(mensaje or ''),
'email_to': destino,
'email_to': email,
'email_from': remitente or None,
})
mail.send()