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

@@ -1535,6 +1535,201 @@ def get_propinas(env):
"""Bloque de propinas vigente (copia). Apagado por defecto."""
return normalizar_propinas(get_settings(env).get('propinas'))
# ---------------------------------------------------------------------------
# Campañas por segmentos (marketing sobre la base de pacientes).
#
# Bloque anidado del mismo JSON de ajustes, igual que `impuestos`. La regla de
# oro: con `activo=False` (default) NADA cambia en la instancia — la sección
# Campañas no aparece en el menú, no corre ningún cron, no se encola ni se
# envía nada y las tablas propias quedan en cero.
#
# activo: enciende TODO el módulo de campañas
# envios_por_minuto: tope de mensajes que el motor manda por minuto
# pausar_tras_errores: errores seguidos que pausan la campaña sola
# ventana_atribucion_dias: días tras el envío en que una cita se atribuye
# exigir_consentimiento: nunca escribir a quien no autorizó marketing
# hora_envio: hora (HH:MM) de los envíos recurrentes
# ---------------------------------------------------------------------------
CAMPANAS_DEFAULT = {
'activo': False,
'envios_por_minuto': 20,
'pausar_tras_errores': 5,
'ventana_atribucion_dias': 7,
'exigir_consentimiento': True,
'hora_envio': '10:00',
}
CAMPANAS_ENVIOS_MIN, CAMPANAS_ENVIOS_MAX = 1, 600
CAMPANAS_ERRORES_MIN, CAMPANAS_ERRORES_MAX = 1, 100
CAMPANAS_VENTANA_MIN, CAMPANAS_VENTANA_MAX = 1, 90
def normalizar_campanas(raw):
"""Bloque `campanas` completo, con defaults. Tolerante: nunca lanza."""
out = dict(CAMPANAS_DEFAULT)
if not isinstance(raw, dict):
return out
for k in ('activo', 'exigir_consentimiento'):
out[k] = _bool(raw.get(k), CAMPANAS_DEFAULT[k])
out['envios_por_minuto'] = min(max(_int(
raw.get('envios_por_minuto'), CAMPANAS_DEFAULT['envios_por_minuto']),
CAMPANAS_ENVIOS_MIN), CAMPANAS_ENVIOS_MAX)
out['pausar_tras_errores'] = min(max(_int(
raw.get('pausar_tras_errores'), CAMPANAS_DEFAULT['pausar_tras_errores']),
CAMPANAS_ERRORES_MIN), CAMPANAS_ERRORES_MAX)
out['ventana_atribucion_dias'] = min(max(_int(
raw.get('ventana_atribucion_dias'), CAMPANAS_DEFAULT['ventana_atribucion_dias']),
CAMPANAS_VENTANA_MIN), CAMPANAS_VENTANA_MAX)
out['hora_envio'] = _hhmm_valida(raw.get('hora_envio'), CAMPANAS_DEFAULT['hora_envio'])
return out
def validar_campanas(raw):
"""Valida el bloque `campanas` de un PUT. Lanza ValueError si algo no cuadra.
Devuelve SOLO las claves presentes para que el merge no reinicie el resto."""
if raw is None:
return None
if not isinstance(raw, dict):
raise ValueError('El bloque de campañas debe ser un objeto')
out = {}
for k in ('activo', 'exigir_consentimiento'):
if k in raw:
if not isinstance(raw[k], bool):
raise ValueError('El campo %s debe ser verdadero o falso' % k)
out[k] = raw[k]
for k, lo, hi in (('envios_por_minuto', CAMPANAS_ENVIOS_MIN, CAMPANAS_ENVIOS_MAX),
('pausar_tras_errores', CAMPANAS_ERRORES_MIN, CAMPANAS_ERRORES_MAX),
('ventana_atribucion_dias', CAMPANAS_VENTANA_MIN, CAMPANAS_VENTANA_MAX)):
if k in raw:
if isinstance(raw[k], bool) or not isinstance(raw[k], int):
raise ValueError('El campo %s debe ser un número entero' % k)
if not (lo <= raw[k] <= hi):
raise ValueError('El campo %s debe estar entre %d y %d' % (k, lo, hi))
out[k] = raw[k]
if 'hora_envio' in raw:
partes = str(raw['hora_envio'] or '').strip().split(':')
valido = False
if len(partes) == 2:
try:
h, m = int(partes[0]), int(partes[1])
valido = 0 <= h <= 23 and 0 <= m <= 59
except (TypeError, ValueError):
valido = False
if not valido:
raise ValueError('La hora de envío debe tener el formato HH:MM')
out['hora_envio'] = _hhmm_valida(raw['hora_envio'])
return out
def get_campanas(env):
"""Bloque de campañas vigente (copia). Apagado por defecto."""
return normalizar_campanas(get_settings(env).get('campanas'))
# ---------------------------------------------------------------------------
# Encuestas de salida y satisfacción (NPS).
#
# Bloque anidado del mismo JSON de ajustes, igual que `impuestos`. La regla de
# oro: con `activo=False` (default) NADA cambia en la instancia — no se encola
# ni se envía ninguna encuesta tras completar una cita y no se genera ninguna
# respuesta nueva.
#
# activo: enciende el envío automático de la encuesta al salir
# enviar_tras_horas: horas después de completarse la cita en que se envía
# plantilla_id: plantilla de formulario (tipo encuesta) a enviar
# canal: whatsapp | email | ambos
# recordatorio_horas: horas para reenviar si no responde (0 = sin recordatorio)
# solo_primera_visita: envía solo a quien tiene una única visita
# agradecimiento: mensaje de gracias que ve el paciente al terminar
# ---------------------------------------------------------------------------
ENCUESTAS_DEFAULT = {
'activo': False,
'enviar_tras_horas': 2,
'plantilla_id': None,
'canal': 'whatsapp',
'recordatorio_horas': 48,
'solo_primera_visita': False,
'agradecimiento': '',
}
ENCUESTAS_CANALES = ('whatsapp', 'email', 'ambos')
ENCUESTAS_HORAS_MAX = 720 # 30 días
ENCUESTAS_RECORDATORIO_MAX = 720
ENCUESTAS_AGRADECIMIENTO_MAX = 500
def normalizar_encuestas(raw):
"""Bloque `encuestas` completo, con defaults. Tolerante: nunca lanza."""
out = dict(ENCUESTAS_DEFAULT)
if not isinstance(raw, dict):
return out
for k in ('activo', 'solo_primera_visita'):
out[k] = _bool(raw.get(k), ENCUESTAS_DEFAULT[k])
out['enviar_tras_horas'] = min(max(_int(
raw.get('enviar_tras_horas'), ENCUESTAS_DEFAULT['enviar_tras_horas']),
0), ENCUESTAS_HORAS_MAX)
out['recordatorio_horas'] = min(max(_int(
raw.get('recordatorio_horas'), ENCUESTAS_DEFAULT['recordatorio_horas']),
0), ENCUESTAS_RECORDATORIO_MAX)
canal = str(raw.get('canal') or '').strip().lower()
out['canal'] = canal if canal in ENCUESTAS_CANALES else ENCUESTAS_DEFAULT['canal']
pid = raw.get('plantilla_id')
if pid in (None, '', False, 0, '0'):
out['plantilla_id'] = None
else:
n = _int(pid, 0)
out['plantilla_id'] = n if n > 0 else None
out['agradecimiento'] = str(raw.get('agradecimiento') or '')[:ENCUESTAS_AGRADECIMIENTO_MAX]
return out
def validar_encuestas(raw):
"""Valida el bloque `encuestas` de un PUT. Lanza ValueError si algo no cuadra.
Devuelve SOLO las claves presentes para que el merge no reinicie el resto."""
if raw is None:
return None
if not isinstance(raw, dict):
raise ValueError('El bloque de encuestas debe ser un objeto')
out = {}
for k in ('activo', 'solo_primera_visita'):
if k in raw:
if not isinstance(raw[k], bool):
raise ValueError('El campo %s debe ser verdadero o falso' % k)
out[k] = raw[k]
for k, hi in (('enviar_tras_horas', ENCUESTAS_HORAS_MAX),
('recordatorio_horas', ENCUESTAS_RECORDATORIO_MAX)):
if k in raw:
if isinstance(raw[k], bool) or not isinstance(raw[k], int):
raise ValueError('El campo %s debe ser un número entero' % k)
if not (0 <= raw[k] <= hi):
raise ValueError('El campo %s debe estar entre 0 y %d' % (k, hi))
out[k] = raw[k]
if 'canal' in raw:
canal = str(raw['canal'] or '').strip().lower()
if canal not in ENCUESTAS_CANALES:
raise ValueError('El canal debe ser whatsapp, email o ambos')
out['canal'] = canal
if 'plantilla_id' in raw:
pid = raw['plantilla_id']
if pid in (None, '', False, 0):
out['plantilla_id'] = None
else:
if isinstance(pid, bool) or not isinstance(pid, int) or pid <= 0:
raise ValueError('La plantilla de encuesta debe ser un identificador válido')
out['plantilla_id'] = pid
if 'agradecimiento' in raw:
if not isinstance(raw['agradecimiento'], str):
raise ValueError('El agradecimiento debe ser texto')
if len(raw['agradecimiento']) > ENCUESTAS_AGRADECIMIENTO_MAX:
raise ValueError('El agradecimiento es demasiado largo (máx. %d)'
% ENCUESTAS_AGRADECIMIENTO_MAX)
out['agradecimiento'] = raw['agradecimiento'].strip()
return out
def get_encuestas(env):
"""Bloque de encuestas vigente (copia). Apagado por defecto."""
return normalizar_encuestas(get_settings(env).get('encuestas'))
# ---------------------------------------------------------------------------
# Licencia por plan (producto comercial).
#
@@ -1966,6 +2161,8 @@ def get_settings(env, aplicar_licencia=True):
'comisiones': dict(COMISIONES_DEFAULT),
'lealtad': normalizar_lealtad(None),
'propinas': dict(PROPINAS_DEFAULT),
'campanas': dict(CAMPANAS_DEFAULT),
'encuestas': dict(ENCUESTAS_DEFAULT),
'licencia': dict(LICENCIA_DEFAULT, limites=dict(LICENCIA_DEFAULT['limites'])),
'portal_cliente': normalizar_portal_cliente(None),
}
@@ -1988,6 +2185,8 @@ def get_settings(env, aplicar_licencia=True):
out['comisiones'] = normalizar_comisiones(guardado.get('comisiones'))
out['lealtad'] = normalizar_lealtad(guardado.get('lealtad'))
out['propinas'] = normalizar_propinas(guardado.get('propinas'))
out['campanas'] = normalizar_campanas(guardado.get('campanas'))
out['encuestas'] = normalizar_encuestas(guardado.get('encuestas'))
out['licencia'] = normalizar_licencia(guardado.get('licencia'))
out['portal_cliente'] = normalizar_portal_cliente(
guardado.get('portal_cliente'))
@@ -2059,6 +2258,16 @@ def set_settings(env, vals):
bloque = validar_propinas(vals['propinas'])
if bloque is not None:
actual['propinas'] = {**actual['propinas'], **bloque}
if 'campanas' in vals:
bloque = validar_campanas(vals['campanas'])
if bloque is not None:
# Merge anidado: un PUT parcial (p. ej. solo activo) no borra el resto.
actual['campanas'] = {**actual['campanas'], **bloque}
if 'encuestas' in vals:
bloque = validar_encuestas(vals['encuestas'])
if bloque is not None:
# Merge anidado: un PUT parcial (p. ej. solo activo) no borra el resto.
actual['encuestas'] = {**actual['encuestas'], **bloque}
if 'licencia' in vals:
bloque = validar_licencia(vals['licencia'])
if bloque is not None: