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,561 @@
# -*- coding: utf-8 -*-
"""Campañas por segmentos con medición.
`campanas.activo=false` (default) apaga TODO: no corre el cron, no se encola ni
se envía nada y las campañas nuevas nacen en borrador. El envío real siempre
pasa por el motor de notificaciones (`skeen.notificacion.service`), que respeta
el modo de prueba y registra cada intento en la bitácora con su campaña.
Dos cuidados propios de WhatsApp:
* límite de ENVÍOS POR MINUTO configurable (`campanas.envios_por_minuto`), y
* PAUSA AUTOMÁTICA si se acumulan errores seguidos
(`campanas.pausar_tras_errores`).
Y una regla dura: un paciente SIN consentimiento de marketing nunca recibe una
campaña, aunque cumpla el criterio del segmento. Los avisos transaccionales
(recordatorio de cita, encuesta de salida) no dependen del consentimiento.
"""
import calendar
import re
from datetime import timedelta
from odoo import api, fields, models
from odoo.addons.skeen_citas.clinic_settings import (
get_campanas, get_notificaciones, get_settings as clinic_get_settings,
)
CANALES = [('whatsapp', 'WhatsApp'), ('email', 'Correo'), ('ambos', 'Ambos')]
PROGRAMACION = [
('una_vez', 'Una sola vez'),
('mensual', 'Mensual'),
('semanal', 'Semanal'),
('segmento', 'Según el segmento (recurrente)'),
]
ESTADOS = [
('borrador', 'Borrador'),
('programada', 'Programada'),
('en_ejecucion', 'En ejecución'),
('pausada', 'Pausada'),
('terminada', 'Terminada'),
('cancelada', 'Cancelada'),
]
# Variables disponibles en la plantilla de una campaña.
VARIABLES = ('nombre', 'servicio', 'sucursal', 'enlace', 'doctor', 'fecha',
'hora', 'telefono')
_VARIABLE_RE = re.compile(r'\{([a-zA-Z_]+)\}')
PLANTILLA_MAX = 2000
MIN_SEGUNDOS_TASA = 60
def variables_de(texto):
usadas = []
for m in _VARIABLE_RE.finditer(str(texto or '')):
if m.group(1) not in usadas:
usadas.append(m.group(1))
return usadas
class SkeenNotificacion(models.Model):
_inherit = 'skeen.notificacion'
campana_id = fields.Many2one('skeen.campana', string='Campaña', index=True,
ondelete='set null')
campana_periodo = fields.Char(string='Periodo de campaña', index=True)
class SkeenCampana(models.Model):
_name = 'skeen.campana'
_description = 'Campaña por segmento'
_order = 'create_date desc, id desc'
name = fields.Char(string='Nombre', required=True)
descripcion = fields.Text(string='Descripción')
activo = fields.Boolean(string='Activa', default=True)
segmento_id = fields.Many2one('skeen.segmento', string='Segmento',
ondelete='restrict', index=True)
canal = fields.Selection(CANALES, string='Canal', default='whatsapp', required=True)
plantilla = fields.Text(string='Mensaje', default='')
enlace = fields.Char(string='Enlace para {enlace}')
programacion = fields.Selection(PROGRAMACION, string='Programación',
default='una_vez', required=True)
fecha_programada = fields.Datetime(string='Fecha programada')
hora = fields.Char(string='Hora (HH:MM)', default='10:00')
estado = fields.Selection(ESTADOS, string='Estado', default='borrador',
required=True, index=True)
ultima_ejecucion = fields.Datetime(string='Última ejecución', readonly=True)
proxima_ejecucion = fields.Datetime(string='Próxima ejecución', readonly=True)
ultimo_periodo = fields.Char(string='Último periodo', readonly=True)
motivo_pausa = fields.Text(string='Motivo de pausa', readonly=True)
creado_por = fields.Char(string='Creada por', copy=False)
notas = fields.Text(string='Notas')
def _periodo_actual(self, hoy=None):
"""Clave del periodo vigente (idempotencia de los envíos recurrentes)."""
self.ensure_one()
hoy = hoy or fields.Date.today()
if self.programacion == 'semanal':
semana = hoy.isocalendar()
return '%04d-W%02d' % (semana[0], semana[1])
if self.programacion in ('mensual', 'segmento'):
return '%04d-%02d' % (hoy.year, hoy.month)
return hoy.strftime('%Y-%m-%d')
def _canales(self):
self.ensure_one()
if self.canal == 'ambos':
return ['whatsapp', 'email']
return [self.canal]
def canales_lista(self):
return self._canales()
def _esta_vencida(self, ahora=None):
self.ensure_one()
ahora = ahora or fields.Datetime.now()
if self.programacion == 'una_vez':
if self.ultima_ejecucion:
return False
objetivo = self.proxima_ejecucion or self.fecha_programada
return bool(objetivo and objetivo <= ahora)
if self.proxima_ejecucion:
return self.proxima_ejecucion <= ahora
return True
def _siguiente_ejecucion(self, desde=None):
"""Próxima fecha de disparo de una campaña recurrente."""
self.ensure_one()
desde = desde or fields.Datetime.now()
try:
h, m = [int(x) for x in (self.hora or '10:00').split(':')]
except (ValueError, TypeError):
h, m = 10, 0
base = desde.replace(hour=h, minute=m, second=0, microsecond=0)
if self.programacion == 'semanal':
if base <= desde:
base = base + timedelta(days=7)
return base
# mensual / segmento
if base <= desde:
anio = base.year + (1 if base.month == 12 else 0)
mes = 1 if base.month == 12 else base.month + 1
dia = min(base.day, calendar.monthrange(anio, mes)[1])
base = base.replace(year=anio, month=mes, day=dia)
return base
def action_lanzar(self, inmediato=True):
"""Pasa la campaña a 'programada' y (opcional) la agenda para ya."""
self.ensure_one()
vals = {'estado': 'programada', 'motivo_pausa': False}
if self.programacion == 'una_vez':
vals['proxima_ejecucion'] = self.fecha_programada or fields.Datetime.now()
else:
vals['proxima_ejecucion'] = self._siguiente_ejecucion(
fields.Datetime.now() - timedelta(minutes=1)) if inmediato \
else self._siguiente_ejecucion()
self.write(vals)
return True
def action_pausar(self, motivo=''):
self.write({'estado': 'pausada', 'motivo_pausa': motivo or 'Pausada a mano'})
def action_reanudar(self):
self.write({'estado': 'programada', 'motivo_pausa': False})
def action_cancelar(self):
self.write({'estado': 'cancelada'})
def action_borrador(self):
self.write({'estado': 'borrador'})
def metricas(self, start=None, end=None):
"""Métricas de la campaña (delega en el servicio)."""
self.ensure_one()
return self.env['skeen.campana.service']._metricas(self, start=start, end=end)
class SkeenCampanaService(models.AbstractModel):
"""Motor de campañas: encola, respeta consentimiento y mide."""
_name = 'skeen.campana.service'
_description = 'Servicio de campañas de SKEEN'
@api.model
def _cfg(self):
return get_campanas(self.env)
# ------------------------------------------------------------------
# Render de la plantilla
# ------------------------------------------------------------------
@api.model
def _contexto_paciente(self, partner):
settings = clinic_get_settings(self.env)
ctx = {
'nombre': partner.name or '',
'servicio': '',
'sucursal': '',
'doctor': '',
'fecha': fields.Date.today().strftime('%d/%m/%Y'),
'hora': '',
'telefono': (settings.get('telefono')
or get_notificaciones(self.env).get('whatsapp_numero') or ''),
}
cita = self.env['skeen.cita'].sudo().search([
('partner_id', '=', partner.id), ('state', '=', 'done'),
], order='date desc, id desc', limit=1)
if cita:
ctx['servicio'] = cita.servicio_id.name if cita.servicio_id else ''
ctx['doctor'] = cita.doctor_id.name if cita.doctor_id else ''
ctx['fecha'] = cita.date.strftime('%d/%m/%Y') if cita.date else ctx['fecha']
ctx['hora'] = cita.time_str or ''
if cita.branch:
sucursal = self.env['skeen.sucursal'].sudo().search(
[('code', '=', cita.branch)], limit=1)
ctx['sucursal'] = sucursal.name or cita.branch
if not ctx['sucursal']:
sucursal = self.env['skeen.sucursal'].sudo().search(
[('is_default', '=', True)], limit=1) \
or self.env['skeen.sucursal'].sudo().search([], order='sequence', limit=1)
ctx['sucursal'] = sucursal.name or ''
return ctx
def render(self, campana, partner):
ctx = self._contexto_paciente(partner)
ctx['enlace'] = campana.enlace or ''
texto = str(campana.plantilla or '')
for clave, valor in ctx.items():
texto = texto.replace('{%s}' % clave, str(valor or ''))
return texto
# ------------------------------------------------------------------
# Encolado
# ------------------------------------------------------------------
@api.model
def _encolar_campana(self, campana, periodo=None, ahora=None):
"""Encola la campaña para su segmento vigente. Devuelve el # de creadas."""
ahora = ahora or fields.Datetime.now()
periodo = periodo or campana._periodo_actual()
cfg = self._cfg()
service = self.env['skeen.notificacion.service'].sudo()
segmento = campana.segmento_id
if not segmento:
return 0
pacientes = segmento.pacientes()
creadas = 0
for partner in pacientes:
for canal in campana._canales():
if not service._destino_disponible(partner, canal):
continue
# Consentimiento: un paciente sin autorización nunca recibe
# marketing, aunque cumpla el criterio.
if cfg['exigir_consentimiento'] and not partner._marketing_permitido(canal):
continue
mensaje = self.render(campana, partner)
clave = 'campana:c%s:p%s:%s:%s' % (
campana.id, partner.id, periodo, canal)
notifs = service.encolar(
partner=partner, tipo='campana', canal=canal,
cuando=ahora, mensaje=mensaje,
clave_unica=clave, forzar=True,
vals_extra={'campana_id': campana.id, 'campana_periodo': periodo})
creadas += len(notifs)
return creadas
@api.model
def _campanas_vencidas(self, ahora=None):
ahora = ahora or fields.Datetime.now()
campanas = self.env['skeen.campana'].sudo().search([
('estado', 'in', ('programada', 'en_ejecucion')),
('activo', '=', True),
])
return campanas.filtered(lambda c: c._esta_vencida(ahora))
@api.model
def _procesar_campanas(self, cfg, ahora=None):
"""Encola las campañas vencidas (no manda todavía)."""
ahora = ahora or fields.Datetime.now()
encoladas = 0
for campana in self._campanas_vencidas(ahora):
periodo = campana._periodo_actual()
creadas = self._encolar_campana(campana, periodo=periodo, ahora=ahora)
encoladas += creadas
vals = {'ultima_ejecucion': ahora, 'ultimo_periodo': periodo,
'estado': 'en_ejecucion'}
if campana.programacion != 'una_vez':
vals['proxima_ejecucion'] = campana._siguiente_ejecucion(ahora)
campana.write(vals)
return encoladas
# ------------------------------------------------------------------
# Envío con límite por minuto y pausa por errores
# ------------------------------------------------------------------
@api.model
def _enviadas_ultimo_minuto(self, ahora=None):
ahora = ahora or fields.Datetime.now()
desde = ahora - timedelta(seconds=MIN_SEGUNDOS_TASA)
return self.env['skeen.notificacion'].sudo().search_count([
('tipo', '=', 'campana'),
('estado', 'in', ('enviada', 'simulada')),
('enviada_at', '>=', desde),
])
@api.model
def _procesar_cola(self, cfg, limite=None):
"""Manda la cola de campañas respetando el límite por minuto."""
servicio = self.env['skeen.notificacion.service'].sudo()
notif_cfg = servicio._cfg()
ahora = fields.Datetime.now()
presupuesto = cfg['envios_por_minuto'] - self._enviadas_ultimo_minuto(ahora)
if presupuesto <= 0:
return {'enviadas': 0, 'simuladas': 0, 'fallidas': 0, 'pausadas': 0,
'limite_alcanzado': True}
if limite:
presupuesto = min(presupuesto, limite)
pendientes = self.env['skeen.notificacion'].sudo().search([
('tipo', '=', 'campana'),
('estado', '=', 'pendiente'),
('programada_para', '<=', ahora),
], order='programada_para asc, id asc', limit=presupuesto)
resultado = {'enviadas': 0, 'simuladas': 0, 'fallidas': 0, 'pausadas': 0,
'limite_alcanzado': False}
seguidos = 0
for notif in pendientes:
# Guarda de consentimiento al momento del envío (pudo revocarse
# entre el encolado y ahora).
partner = notif.partner_id
if cfg['exigir_consentimiento'] and partner \
and not partner._marketing_permitido(notif.canal):
notif.write({'estado': 'cancelada',
'error': 'Sin consentimiento de marketing'})
continue
estado = servicio._intentar(notif, notif_cfg)
if estado == 'enviada':
resultado['enviadas'] += 1
seguidos = 0
elif estado == 'simulada':
resultado['simuladas'] += 1
seguidos = 0
elif estado == 'fallida':
resultado['fallidas'] += 1
seguidos += 1
if seguidos >= cfg['pausar_tras_errores']:
campana = notif.campana_id
if campana:
campana.action_pausar(
'Pausada sola tras %d errores seguidos.' % seguidos)
resultado['pausadas'] += 1
break
else:
seguidos = 0
return resultado
@api.model
def _cerrar_campanas(self):
"""Marca como terminadas las campañas de una sola vez ya sin cola."""
pendientes = self.env['skeen.notificacion'].sudo().search([
('tipo', '=', 'campana'), ('estado', '=', 'pendiente')])
con_cola = set(pendientes.mapped('campana_id').ids)
abiertas = self.env['skeen.campana'].sudo().search([
('estado', '=', 'en_ejecucion'), ('programacion', '=', 'una_vez')])
for campana in abiertas:
if campana.id not in con_cola:
campana.write({'estado': 'terminada'})
# ------------------------------------------------------------------
# Entrada del cron
# ------------------------------------------------------------------
@api.model
def ejecutar(self, limite=None):
cfg = self._cfg()
resultado = {'activo': cfg['activo'], 'encoladas': 0, 'enviadas': 0,
'simuladas': 0, 'fallidas': 0, 'pausadas': 0,
'limite_alcanzado': False}
if not cfg['activo']:
return resultado
resultado['encoladas'] = self._procesar_campanas(cfg)
envio = self._procesar_cola(cfg, limite=limite)
resultado.update(envio)
self._cerrar_campanas()
return resultado
@api.model
def _cron_tick(self):
return self.ejecutar()
# ------------------------------------------------------------------
# Envío de prueba
# ------------------------------------------------------------------
@api.model
def enviar_prueba(self, campana, partner=None, telefono='', correo=''):
"""Manda la plantilla a un número/correo propio. Nunca lanza.
Queda registrado en la bitácora marcado como prueba (no cuenta en la
medición). El envío real pasa por el motor de notificaciones.
"""
cfg = self._cfg()
if not cfg['activo']:
return {'ok': False, 'message': 'El módulo de campañas está apagado.'}
if not campana.plantilla or not str(campana.plantilla).strip():
return {'ok': False, 'message': 'La campaña no tiene mensaje todavía.'}
servicio = self.env['skeen.notificacion.service'].sudo()
notif_cfg = servicio._cfg()
canales = campana._canales()
creadas = self.env['skeen.notificacion'].sudo()
for canal in canales:
if canal == 'whatsapp' and not telefono and not partner:
return {'ok': False, 'message': 'Indica un número para la prueba.'}
if canal == 'email' and not correo and not partner:
return {'ok': False, 'message': 'Indica un correo para la prueba.'}
if partner:
mensaje = self.render(campana, partner)
destino = ''
else:
mensaje = (str(campana.plantilla or '')
.replace('{nombre}', 'Prueba')
.replace('{servicio}', 'Servicio de ejemplo')
.replace('{sucursal}', 'Sucursal')
.replace('{doctor}', 'Doctor')
.replace('{fecha}', fields.Date.today().strftime('%d/%m/%Y'))
.replace('{hora}', '10:00')
.replace('{enlace}', campana.enlace or '')
.replace('{telefono}', ''))
destino = telefono if canal == 'whatsapp' else correo
clave = 'campana-prueba:c%s:%s:%s' % (
campana.id, canal, fields.Datetime.now().strftime('%Y%m%d%H%M%S%f'))
notifs = servicio.encolar(
partner=partner, tipo='campana', canal=canal,
cuando=fields.Datetime.now(), mensaje=mensaje, clave_unica=clave,
forzar=True, destino=destino or None,
vals_extra={'campana_id': campana.id, 'es_prueba': True})
for notif in notifs:
servicio._intentar(notif, notif_cfg)
creadas |= notifs
if not creadas:
return {'ok': False, 'message': 'No se pudo encolar la prueba.'}
return {
'ok': True,
'simulado': bool(notif_cfg['modo_prueba']),
'notificaciones': [self._notif_dict(n) for n in creadas],
'message': ('Modo prueba: la campaña quedó registrada como simulada.'
if notif_cfg['modo_prueba'] else 'Prueba enviada.'),
}
# ------------------------------------------------------------------
# Medición
# ------------------------------------------------------------------
@api.model
def _notif_dict(self, n):
return {
'id': n.id,
'partner_id': n.partner_id.id if n.partner_id else None,
'patient': n.partner_id.name if n.partner_id else '',
'canal': n.canal,
'estado': n.estado,
'es_prueba': n.es_prueba,
'destino': n.destino or '',
'enviada_at': n.enviada_at.strftime('%Y-%m-%d %H:%M') if n.enviada_at else None,
'error': n.error or '',
}
@api.model
def _respuestas_wacrm(self, partners, desde, hasta):
"""Mensajes del paciente (WACRM) en la ventana. None si no hay datos.
Se detectan por teléfono del contacto. Si la instancia no sincroniza
WACRM se devuelve None (el reporte lo muestra como "no disponible")."""
if 'skeen.wacrm.message' not in self.env:
return None
M = self.env['skeen.wacrm.message'].sudo()
if not M.search_count([], limit=1):
return None
total = 0
for partner in partners:
digitos = re.sub(r'\D', '', (partner.whatsapp or partner.phone or ''))
if not digitos:
continue
cola = digitos[-10:]
total += M.search_count([
('sender_type', '=', 'customer'),
('created_at', '>=', desde), ('created_at', '<=', hasta),
('contact_id.phone', 'like', cola),
])
return total
def _metricas(self, campana, start=None, end=None):
"""Métricas de la campaña. `start`/`end` acotan las fechas de envío."""
cfg = self._cfg()
Notif = self.env['skeen.notificacion'].sudo()
dominio = [('campana_id', '=', campana.id), ('es_prueba', '=', False)]
if start:
dominio.append(('enviada_at', '>=', '%s 00:00:00' % start))
if end:
dominio.append(('enviada_at', '<=', '%s 23:59:59' % end))
enviadas = Notif.search(dominio + [('estado', 'in', ('enviada', 'simulada'))])
errores = Notif.search_count(dominio + [('estado', '=', 'error')])
canceladas = Notif.search_count(dominio + [('estado', '=', 'cancelada')])
pendientes = Notif.search_count(dominio + [('estado', '=', 'pendiente')])
pacientes = enviadas.mapped('partner_id')
alcanzados = len(pacientes)
ventana = cfg['ventana_atribucion_dias']
citas_count = 0
citas_valor = 0.0
citas_ids = set()
ventas_ids = set()
for partner in pacientes:
envios = enviadas.filtered(lambda n: n.partner_id.id == partner.id
and n.enviada_at)
if not envios:
continue
desde = min(envios.mapped('enviada_at'))
hasta = desde + timedelta(days=ventana)
citas = self.env['skeen.cita'].sudo().search([
('partner_id', '=', partner.id), ('state', '!=', 'cancelled'),
('create_date', '>=', desde), ('create_date', '<=', hasta)])
citas_ids |= set(citas.ids)
ventas = self.env['skeen.venta'].sudo().search([
('partner_id', '=', partner.id), ('state', '!=', 'cancelled'),
('create_date', '>=', desde), ('create_date', '<=', hasta)])
ventas_ids |= set(ventas.ids)
citas_valor += sum(v.total for v in ventas)
citas_count = len(citas_ids)
# Respuestas detectables en WACRM dentro de cada ventana.
respuestas = 0
respuestas_disponible = True
if pacientes:
min_envio = min(enviadas.mapped('enviada_at'))
max_envio = max(enviadas.mapped('enviada_at'))
detectadas = self._respuestas_wacrm(
pacientes, min_envio, max_envio + timedelta(days=ventana))
if detectadas is None:
respuestas_disponible = False
else:
respuestas = detectadas
enviadas_reales = len(enviadas.filtered(lambda n: n.estado == 'enviada'))
simuladas = len(enviadas.filtered(lambda n: n.estado == 'simulada'))
return {
'campana_id': campana.id,
'campana': campana.name,
'estado': campana.estado,
'alcanzados': alcanzados,
'enviados': len(enviadas),
'enviados_reales': enviadas_reales,
'simulados': simuladas,
'errores': errores,
'cancelados': canceladas,
'pendientes': pendientes,
# La entrega real (ACK del proveedor) no está disponible con el
# WACRM actual: se reporta como no disponible en lugar de inventarla.
'entregados': None,
'entregados_disponible': False,
'respuestas': respuestas if respuestas_disponible else None,
'respuestas_disponible': respuestas_disponible,
'citas_atribuidas': citas_count,
'valor_atribuido': round(citas_valor, 2),
'ventana_atribucion_dias': ventana,
'conversion_pct': round((citas_count * 100.0 / alcanzados), 2)
if alcanzados else 0.0,
}