fase 2.4: recordatorios y notificaciones automaticas (modelo+cola+bitacora, cron cada 5 min, recordatorios por ventana, confirmacion, cumpleanos, post-visita, recuperacion de no-show, modo prueba, plantillas con glosario, historial y reintentos); apagado por defecto

This commit is contained in:
2026-10-11 09:05:19 +00:00
parent 9257586943
commit dabd8c6bd7
16 changed files with 1832 additions and 12 deletions

View File

@@ -11,6 +11,7 @@ cambiar lo que ya se ve en producción cuando el parámetro no está guardado.
"""
import json
import re
from urllib.parse import urlparse
SETTINGS_KEY = 'skeen.clinic.settings'
@@ -262,6 +263,222 @@ def get_reservas_online(env):
"""Bloque de reservas en línea vigente (copia). Fuente única para la API."""
return normalizar_reservas_online(get_settings(env).get('reservas_online'))
# ---------------------------------------------------------------------------
# Recordatorios y notificaciones automáticas de citas.
#
# Bloque anidado del mismo JSON de ajustes, igual que `impuestos` y
# `reservas_online`. La regla de oro: con `activo=False` NADA se envía ni se
# encola (cero cambios de comportamiento y cero filas nuevas); por eso el
# default es apagado. `modo_prueba=True` (default) es la segunda llave: las
# notificaciones se registran como "simuladas" y no se llama a ningún proveedor
# externo, de modo que se puede probar sin credenciales reales.
#
# activo: enciende recordatorios y notificaciones
# modo_prueba: no envía nada real; solo registra "simulada"
# canal: whatsapp | email | ambos
# recordatorio_horas_antes: ventanas (0-168 h) en que se avisa antes de la cita
# confirmacion_al_crear: avisar al paciente al reservar en línea
# cumpleanos: felicitación el día del cumpleaños
# hora_envio_cumpleanos: hora (HH:MM) a partir de la cual se felicita
# post_visita_horas: horas tras la visita para el mensaje de seguimiento
# recuperacion_no_show: mensaje a quien no se presentó
# plantilla_*: textos con variables {nombre} {cita} {servicio}...
# whatsapp_numero: número de la clínica que firma los mensajes
# email_remitente: remitente de los correos
# ---------------------------------------------------------------------------
NOTIFICACIONES_DEFAULT = {
'activo': False,
'modo_prueba': True,
'canal': 'whatsapp',
'recordatorio_horas_antes': [24, 2],
'confirmacion_al_crear': False,
'cumpleanos': False,
'hora_envio_cumpleanos': '09:00',
'post_visita_horas': 0,
'recuperacion_no_show': False,
'plantilla_recordatorio': (
'Hola {nombre}, te recordamos tu {cita} de {servicio} el {fecha} a las '
'{hora} con {doctor} en {sucursal}. Responde CONFIRMAR o CANCELAR.'
),
'plantilla_confirmacion': (
'Hola {nombre}, tu {cita} de {servicio} quedó registrada para el {fecha} '
'a las {hora} con {doctor} en {sucursal}. Te esperamos.'
),
'plantilla_cumpleanos': (
'¡Feliz cumpleaños, {nombre}! Todo el equipo de {sucursal} te desea un '
'gran día. Te esperamos pronto.'
),
'plantilla_post_visita': (
'Hola {nombre}, gracias por tu visita de {servicio} el {fecha}. Si tienes '
'dudas o quieres agendar de nuevo, llámanos al {telefono}.'
),
'whatsapp_numero': '',
'email_remitente': '',
}
NOTIF_CANALES = ('whatsapp', 'email', 'ambos')
NOTIF_VARIABLES = ('nombre', 'cita', 'servicio', 'fecha', 'hora', 'doctor',
'sucursal', 'telefono')
NOTIF_PLANTILLAS = ('plantilla_recordatorio', 'plantilla_confirmacion',
'plantilla_cumpleanos', 'plantilla_post_visita')
NOTIF_HORAS_MAX = 168 # 1 semana
NOTIF_PLANTILLA_MAX = 1000 # caracteres por plantilla
NOTIF_TEXTO_MAX = 120 # whatsapp_numero / email_remitente
# Variables reconocidas dentro de una plantilla: {nombre}, {cita}, ...
_VARIABLE_RE = re.compile(r'\{([a-zA-Z_]+)\}')
def horas_recordatorio_validas(valor, por_defecto=None):
"""Lista de horas de recordatorio (enteros 0-168, sin repetir, ordenada)."""
if valor is None:
return list(por_defecto or NOTIFICACIONES_DEFAULT['recordatorio_horas_antes'])
if not isinstance(valor, (list, tuple)):
return list(por_defecto or NOTIFICACIONES_DEFAULT['recordatorio_horas_antes'])
limpias = []
for v in valor:
try:
n = int(v)
except (TypeError, ValueError):
continue
if isinstance(v, bool) or not (0 <= n <= NOTIF_HORAS_MAX):
continue
if n not in limpias:
limpias.append(n)
return sorted(limpias) if limpias else list(por_defecto or [])
def _hhmm_valida(valor, por_defecto='09:00'):
"""Normaliza 'HH:MM'. Devuelve el default si el formato no es válido."""
partes = str(valor or '').strip().split(':')
if len(partes) != 2:
return por_defecto
try:
h, m = int(partes[0]), int(partes[1])
except (TypeError, ValueError):
return por_defecto
if not (0 <= h <= 23 and 0 <= m <= 59):
return por_defecto
return '%02d:%02d' % (h, m)
def variables_de_plantilla(texto):
"""Variables {x} usadas en una plantilla (sin repetir, en orden)."""
usadas = []
for m in _VARIABLE_RE.finditer(str(texto or '')):
if m.group(1) not in usadas:
usadas.append(m.group(1))
return usadas
def normalizar_notificaciones(raw):
"""Bloque `notificaciones` completo, con defaults. Tolerante: nunca lanza."""
out = dict(NOTIFICACIONES_DEFAULT)
out['recordatorio_horas_antes'] = list(NOTIFICACIONES_DEFAULT['recordatorio_horas_antes'])
if not isinstance(raw, dict):
return out
for k in ('activo', 'modo_prueba', 'confirmacion_al_crear', 'cumpleanos',
'recuperacion_no_show'):
out[k] = _bool(raw.get(k), NOTIFICACIONES_DEFAULT[k])
canal = str(raw.get('canal') or '').strip().lower()
out['canal'] = canal if canal in NOTIF_CANALES else NOTIFICACIONES_DEFAULT['canal']
out['recordatorio_horas_antes'] = horas_recordatorio_validas(
raw.get('recordatorio_horas_antes'))
out['hora_envio_cumpleanos'] = _hhmm_valida(
raw.get('hora_envio_cumpleanos'), NOTIFICACIONES_DEFAULT['hora_envio_cumpleanos'])
out['post_visita_horas'] = min(max(_int(raw.get('post_visita_horas'),
NOTIFICACIONES_DEFAULT['post_visita_horas']),
0), NOTIF_HORAS_MAX)
for k in NOTIF_PLANTILLAS:
texto = raw.get(k)
if texto is None:
continue
out[k] = str(texto)[:NOTIF_PLANTILLA_MAX]
out['whatsapp_numero'] = str(raw.get('whatsapp_numero') or '').strip()[:NOTIF_TEXTO_MAX]
out['email_remitente'] = str(raw.get('email_remitente') or '').strip()[:NOTIF_TEXTO_MAX]
return out
def validar_notificaciones(raw):
"""Valida el bloque `notificaciones` de un PUT. Lanza ValueError si algo no
cuadra (se traduce a 400). Devuelve SOLO las claves presentes y normalizadas
para que el merge anidado no reinicie lo que el PUT no tocó."""
if raw is None:
return None
if not isinstance(raw, dict):
raise ValueError('El bloque de notificaciones debe ser un objeto')
out = {}
for k in ('activo', 'modo_prueba', 'confirmacion_al_crear', 'cumpleanos',
'recuperacion_no_show'):
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]
if 'canal' in raw:
canal = str(raw['canal'] or '').strip().lower()
if canal not in NOTIF_CANALES:
raise ValueError('El canal debe ser whatsapp, email o ambos')
out['canal'] = canal
if 'recordatorio_horas_antes' in raw:
valor = raw['recordatorio_horas_antes']
if not isinstance(valor, list) or not valor:
raise ValueError('Las horas de recordatorio deben ser una lista')
horas = []
for v in valor:
if isinstance(v, bool) or not isinstance(v, int):
raise ValueError('Cada ventana de recordatorio debe ser un número entero de horas')
if not (0 <= v <= NOTIF_HORAS_MAX):
raise ValueError('Las horas de recordatorio deben estar entre 0 y %d' % NOTIF_HORAS_MAX)
if v not in horas:
horas.append(v)
out['recordatorio_horas_antes'] = sorted(horas)
if 'hora_envio_cumpleanos' in raw:
partes = str(raw['hora_envio_cumpleanos'] 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 cumpleaños debe tener el formato HH:MM')
out['hora_envio_cumpleanos'] = _hhmm_valida(raw['hora_envio_cumpleanos'])
if 'post_visita_horas' in raw:
if isinstance(raw['post_visita_horas'], bool) or not isinstance(raw['post_visita_horas'], int):
raise ValueError('Las horas de post-visita deben ser un número entero')
if not (0 <= raw['post_visita_horas'] <= NOTIF_HORAS_MAX):
raise ValueError('Las horas de post-visita deben estar entre 0 y %d' % NOTIF_HORAS_MAX)
out['post_visita_horas'] = raw['post_visita_horas']
for k in NOTIF_PLANTILLAS:
if k not in raw:
continue
if not isinstance(raw[k], str):
raise ValueError('El campo %s debe ser texto' % k)
if len(raw[k]) > NOTIF_PLANTILLA_MAX:
raise ValueError('La plantilla es demasiado larga (máx. %d caracteres)' % NOTIF_PLANTILLA_MAX)
desconocidas = [v for v in variables_de_plantilla(raw[k]) if v not in NOTIF_VARIABLES]
if desconocidas:
raise ValueError('Variable no permitida en la plantilla: {%s}. Permitidas: %s'
% (desconocidas[0], ', '.join('{%s}' % v for v in NOTIF_VARIABLES)))
out[k] = raw[k]
for k in ('whatsapp_numero', 'email_remitente'):
if k in raw:
if not isinstance(raw[k], str):
raise ValueError('El campo %s debe ser texto' % k)
if len(raw[k].strip()) > NOTIF_TEXTO_MAX:
raise ValueError('El campo %s es demasiado largo (máx. %d)' % (k, NOTIF_TEXTO_MAX))
out[k] = raw[k].strip()
remitente = out.get('email_remitente')
if remitente and '@' not in remitente:
raise ValueError('El correo remitente no es válido')
return out
def get_notificaciones(env):
"""Bloque de notificaciones vigente (copia). Fuente única para el motor."""
return normalizar_notificaciones(get_settings(env).get('notificaciones'))
# Horas de agenda de respaldo: el comportamiento de siempre (8:00-19:00).
AGENDA_START_DEFAULT = 8.0
AGENDA_END_DEFAULT = 19.0
@@ -303,6 +520,7 @@ def get_settings(env):
'nota_ticket': '',
'impuestos': dict(IMPUESTOS_DEFAULT),
'reservas_online': dict(RESERVAS_ONLINE_DEFAULT),
'notificaciones': normalizar_notificaciones(None),
}
raw = env['ir.config_parameter'].sudo().get_param(SETTINGS_KEY)
if raw:
@@ -316,6 +534,7 @@ def get_settings(env):
out[k] = str(guardado[k])
out['impuestos'] = normalizar_impuestos(guardado.get('impuestos'))
out['reservas_online'] = normalizar_reservas_online(guardado.get('reservas_online'))
out['notificaciones'] = normalizar_notificaciones(guardado.get('notificaciones'))
return out
@@ -336,6 +555,10 @@ def set_settings(env, vals):
bloque = validar_reservas_online(vals['reservas_online'])
if bloque is not None:
actual['reservas_online'] = {**actual['reservas_online'], **bloque}
if 'notificaciones' in vals:
bloque = validar_notificaciones(vals['notificaciones'])
if bloque is not None:
actual['notificaciones'] = {**actual['notificaciones'], **bloque}
env['ir.config_parameter'].sudo().set_param(
SETTINGS_KEY, json.dumps(actual, ensure_ascii=False))
return get_settings(env)

View File

@@ -15,6 +15,7 @@
'security/ir.model.access.csv',
'data/roles.xml',
'data/frontend_users.xml',
'data/notificaciones_cron.xml',
],
'installable': True,
'application': False,

View File

@@ -8118,5 +8118,194 @@ class SkeenFrontendController(http.Controller):
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
# ============================================================
# Notificaciones y recordatorios de citas
#
# Las plantillas y el resto de la configuración viven en el bloque
# `notificaciones` de clinic-settings (GET/PUT /clinic-settings), no en
# endpoints aparte: así hay una sola fuente de verdad.
# ============================================================
def _notif_to_dict(self, n):
return {
'id': n.id,
'partner_id': n.partner_id.id,
'patient': n.partner_id.name or '',
'phone': n.partner_id.whatsapp or n.partner_id.phone or '',
'email': n.partner_id.email or '',
'cita_id': n.cita_id.id if n.cita_id else None,
'cita_ref': n.cita_id.name if n.cita_id else '',
'tipo': n.tipo,
'tipo_label': dict(n._fields['tipo'].selection).get(n.tipo, n.tipo),
'canal': n.canal,
'canal_label': dict(n._fields['canal'].selection).get(n.canal, n.canal),
'estado': n.estado,
'estado_label': dict(n._fields['estado'].selection).get(n.estado, n.estado),
'asunto': n.asunto or '',
'mensaje': n.mensaje or '',
'programada_para': n.programada_para.strftime('%Y-%m-%d %H:%M') if n.programada_para else None,
'enviada_at': n.enviada_at.strftime('%Y-%m-%d %H:%M') if n.enviada_at else None,
'error': n.error or '',
'intentos': n.intentos,
}
@http.route('/skeen/frontend/v1/notificaciones', type='http', auth='none',
methods=['GET', 'OPTIONS'], csrf=False)
@require_role('lectura')
@require_perm('notificaciones.ver')
def list_notificaciones(self, **kw):
"""Historial de notificaciones con filtros y resumen por estado."""
try:
domain = []
try:
if kw.get('cita_id'):
domain.append(('cita_id', '=', int(kw['cita_id'])))
if kw.get('partner_id'):
domain.append(('partner_id', '=', int(kw['partner_id'])))
except (TypeError, ValueError):
return json_response({'status': 'error',
'message': 'cita_id / partner_id deben ser numéricos'}, 400)
for campo in ('estado', 'tipo', 'canal'):
if kw.get(campo):
domain.append((campo, '=', kw[campo]))
if kw.get('start'):
domain.append(('programada_para', '>=', '%s 00:00:00' % kw['start']))
if kw.get('end'):
domain.append(('programada_para', '<=', '%s 23:59:59' % kw['end']))
Notif = request.env['skeen.notificacion'].sudo()
try:
page = max(int(kw.get('page', 1) or 1), 1)
page_size = int(kw.get('page_size') or kw.get('limit') or 50)
except (TypeError, ValueError):
page, page_size = 1, 50
page_size = min(max(page_size, 1), 200)
total = Notif.search_count(domain)
registros = Notif.search(domain, order='programada_para desc, id desc',
limit=page_size, offset=(page - 1) * page_size)
resumen = {estado: 0 for estado, _l in Notif._fields['estado'].selection}
for grupo in Notif.read_group(domain, ['estado'], ['estado'], lazy=False):
estado = grupo.get('estado')
if estado:
resumen[estado] = grupo.get('__count', grupo.get('estado_count', 0))
return json_response({
'status': 'success',
'notificaciones': [self._notif_to_dict(n) for n in registros],
'total': total,
'page': page,
'page_size': page_size,
'total_pages': (total + page_size - 1) // page_size,
'resumen': resumen,
})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
def _procesar_notificaciones(self, limite):
"""Corre el mismo ciclo que el cron y devuelve los conteos."""
return request.env['skeen.notificacion.service'].sudo().ejecutar(limite=limite)
@http.route('/skeen/frontend/v1/notificaciones/procesar', type='http', auth='none',
methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('notificaciones.enviar')
def procesar_notificaciones(self, **kw):
"""Fuerza el procesamiento: encola lo que toca y envía la cola."""
try:
data = _parse_json_body()
try:
limite = int(data.get('limit') or 100)
except (TypeError, ValueError):
limite = 100
limite = min(max(limite, 1), 500)
resultado = self._procesar_notificaciones(limite)
return json_response({
'status': 'success',
'message': 'Se encolaron %s y se procesaron %s notificaciones.'
% (resultado['encoladas'], resultado['revisadas']),
**resultado,
})
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/notificaciones/enviar', type='http', auth='none',
methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('notificaciones.enviar')
def enviar_notificacion(self, **kw):
"""Encola y envía un mensaje puntual desde el detalle de la cita."""
try:
data = _parse_json_body()
cita_id = data.get('cita_id')
if not cita_id:
return json_response({'status': 'error', 'message': 'Falta cita_id'}, 400)
canal = data.get('canal') or None
if canal not in (None, 'whatsapp', 'email'):
return json_response({'status': 'error',
'message': 'El canal debe ser whatsapp o email'}, 400)
cita = request.env['skeen.cita'].sudo().browse(int(cita_id))
if not cita.exists():
return json_response({'status': 'error', 'message': 'Cita no encontrada'}, 404)
service = request.env['skeen.notificacion.service'].sudo()
cfg = service._cfg()
if not cfg['activo']:
return json_response({'status': 'error',
'message': 'Las notificaciones están desactivadas'}, 400)
creadas = service.encolar_manual(cita, canal=canal)
if not creadas:
return json_response({'status': 'error',
'message': 'El paciente no tiene destino para ese canal '}, 400)
estados = {}
for notif in creadas:
estados[service._intentar(notif, cfg)] = True
return json_response({
'status': 'success',
'notificaciones': [self._notif_to_dict(n) for n in creadas],
'simulado': bool(cfg['modo_prueba']),
'message': ('Modo prueba: el mensaje quedó registrado como simulado.'
if cfg['modo_prueba'] else 'Mensaje enviado.'),
})
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/notificaciones/<int:notif_id>/reintentar',
type='http', auth='none', methods=['POST', 'OPTIONS'], csrf=False)
@require_role('recepcion')
@require_perm('notificaciones.enviar')
def reintentar_notificacion(self, notif_id, **kw):
"""Devuelve a la cola una notificación fallida e intenta de nuevo."""
try:
notif = request.env['skeen.notificacion'].sudo().browse(notif_id)
if not notif.exists():
return json_response({'status': 'error', 'message': 'Notificación no encontrada'}, 404)
service = request.env['skeen.notificacion.service'].sudo()
notif.action_reintentar()
estado = service._intentar(notif, service._cfg())
return json_response({'status': 'success', 'estado': estado,
'notificacion': self._notif_to_dict(notif)})
except Exception as e:
request.env.cr.rollback()
return json_response({'status': 'error', 'message': str(e)}, 500)
@http.route('/skeen/frontend/v1/notificaciones/probar', type='http', auth='none',
methods=['POST', 'OPTIONS'], csrf=False)
@require_role('gerencia')
@require_perm('notificaciones.editar')
def probar_notificacion(self, **kw):
"""Prueba de conexión de un canal. Nunca lanza: responde 200 siempre."""
try:
data = _parse_json_body()
service = request.env['skeen.notificacion.service'].sudo()
resultado = service.probar_canal(data.get('canal'), data.get('destino') or '')
return json_response({'status': 'success', **resultado})
except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500)
# Importar fields para fechas
from odoo import fields

View File

@@ -254,14 +254,25 @@ class SkeenPublicBookingController(http.Controller):
return cita
def _encolar_confirmacion(self, cita):
"""Hook Fase 2.4: avisar al cliente que la clínica confirmará su cita.
"""Encola y dispara la confirmación al paciente que reservó en línea.
A propósito es un no-op: la Fase 2.4 enviará el mensaje de confirmación
por WhatsApp/WACRM (usando `cita.wacrm_conversation_id` o el teléfono del
paciente) cuando esas credenciales estén configuradas. Se deja el punto
de extensión listo, sin acoplar el portal público a WhatsApp todavía.
Solo actúa si `notificaciones.activo` y `confirmacion_al_crear` están
encendidos; el motor decide si envía o simula (modo prueba). Nunca debe
tumbar la reserva: cualquier fallo se registra y se ignora.
"""
return False
try:
from odoo.addons.skeen_citas.clinic_settings import get_notificaciones
cfg = get_notificaciones(request.env)
if not (cfg.get('activo') and cfg.get('confirmacion_al_crear')):
return False
service = request.env['skeen.notificacion.service'].sudo()
creadas = service.encolar(cita, 'confirmacion')
for notif in creadas:
service._intentar(notif, cfg)
return bool(creadas)
except Exception:
_logger.exception('No se pudo encolar la confirmación de la reserva en línea')
return False
# ------------------------------------------------------------------
# 1. Configuración pública del portal

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<data noupdate="1">
<!-- Motor de recordatorios y notificaciones de citas.
Con `notificaciones.activo=False` (default) el método regresa sin
tocar nada: cero filas y cero envíos. Cada 5 minutos encola los
recordatorios que entran en ventana, los cumpleaños del día, las
recuperaciones de no-show y el seguimiento post-visita, y procesa
la cola pendiente. -->
<record id="cron_notificaciones" model="ir.cron">
<field name="name">SKEEN Notificaciones: recordatorios y avisos de citas</field>
<field name="model_id" ref="model_skeen_notificacion_service"/>
<field name="state">code</field>
<field name="code">model._cron_tick()</field>
<field name="interval_number">5</field>
<field name="interval_type">minutes</field>
<field name="numbercall">-1</field>
<field name="active">True</field>
</record>
</data>
</odoo>

View File

@@ -5,3 +5,4 @@ from . import frontend_user
from . import config_audit
from . import wacrm_notes
from . import ticket_config
from . import notificacion

View File

@@ -0,0 +1,613 @@
# -*- coding: utf-8 -*-
"""Recordatorios y notificaciones automáticas de citas.
Motor único, apagado por defecto: con `notificaciones.activo=False` (el default)
no se encola ni se envía absolutamente nada. Con `modo_prueba=True` (también el
default) las notificaciones se registran como "simuladas": son visibles en el
historial, pero no se llama a ningún proveedor externo. Solo cuando la clínica
apaga el modo prueba se intenta el envío real (WhatsApp vía WACRM o correo vía
`mail.mail`).
Cada intento queda registrado en `skeen.notificacion`: nada se envía en
silencio. La idempotencia va por `clave_unica` (tipo + cita/paciente + ventana +
canal), así reprocesar no duplica.
Términos: los nombres de los términos se toman del glosario vigente (p. ej. con
el pack `spa` el mensaje dice "reserva" y no "cita").
"""
import html
import logging
import os
import re
import uuid
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
import requests
from odoo import api, fields, models, _
from odoo.exceptions import UserError
from odoo.addons.skeen_citas.clinic_settings import (
get_notificaciones as clinic_get_notificaciones,
get_settings as clinic_get_settings,
)
from odoo.addons.skeen_citas.glosario import resolve as resolver_glosario
_logger = logging.getLogger(__name__)
TIPOS = [
('recordatorio', 'Recordatorio'),
('confirmacion', 'Confirmación'),
('cumpleanos', 'Cumpleaños'),
('post_visita', 'Post-visita'),
('recuperacion', 'Recuperación'),
('manual', 'Manual'),
]
CANALES = [('whatsapp', 'WhatsApp'), ('email', 'Correo')]
ESTADOS = [
('pendiente', 'Pendiente'),
('enviada', 'Enviada'),
('simulada', 'Simulada'),
('error', 'Error'),
('cancelada', 'Cancelada'),
]
# Plantilla del bloque de ajustes que usa cada tipo.
PLANTILLA_POR_TIPO = {
'recordatorio': 'plantilla_recordatorio',
'confirmacion': 'plantilla_confirmacion',
'cumpleanos': 'plantilla_cumpleanos',
'post_visita': 'plantilla_post_visita',
# La recuperación de no-show reutiliza la plantilla de recordatorio: el
# bloque `notificaciones` solo define cuatro plantillas (recordatorio,
# confirmación, cumpleaños y post-visita).
'recuperacion': 'plantilla_recordatorio',
'manual': 'plantilla_recordatorio',
}
ETIQUETA_TIPO = dict(TIPOS)
MAX_INTENTOS = 3
BACKOFF_MIN = (5, 30, 120) # minutos de espera entre reintentos
VENTANA_BUSQUEDA_DIAS = 8 # margen de citas futuras que revisa el cron
RECUPERACION_HORAS = 48 # antigüedad máxima de un no-show a recuperar
POST_VISITA_DIAS = 7 # antigüedad máxima de una visita para seguimiento
PROCESAR_POR_DEFECTO = 100
WHATSAPP_API_PATH = '/api/v1/messages'
class SkeenNotificacion(models.Model):
_name = 'skeen.notificacion'
_description = 'Notificación de SKEEN'
_order = 'programada_para desc, id desc'
partner_id = fields.Many2one('res.partner', string='Paciente', required=True,
index=True, ondelete='cascade')
cita_id = fields.Many2one('skeen.cita', string='Cita', index=True,
ondelete='cascade')
tipo = fields.Selection(TIPOS, string='Tipo', required=True, index=True)
canal = fields.Selection(CANALES, string='Canal', required=True, index=True)
estado = fields.Selection(ESTADOS, string='Estado', required=True,
default='pendiente', index=True)
asunto = fields.Char(string='Asunto')
mensaje = fields.Text(string='Mensaje')
programada_para = fields.Datetime(string='Programada para', required=True,
default=fields.Datetime.now, index=True)
enviada_at = fields.Datetime(string='Enviada el')
error = fields.Text(string='Error')
intentos = fields.Integer(string='Intentos', default=0)
clave_unica = fields.Char(string='Clave única', required=True, index=True)
_sql_constraints = [
('clave_unica_uniq', 'unique(clave_unica)',
'Ya existe una notificación con esa clave (evita duplicados).'),
]
def action_reintentar(self):
"""Devuelve a la cola una notificación fallida. Nunca hace envío real."""
for rec in self:
if rec.estado in ('enviada', 'simulada', 'cancelada'):
continue
rec.write({'estado': 'pendiente', 'error': False,
'programada_para': fields.Datetime.now()})
return True
class SkeenNotificacionService(models.AbstractModel):
"""Motor de envío: render de plantillas, encolado, procesamiento y envío."""
_name = 'skeen.notificacion.service'
_description = 'Servicio de notificaciones de SKEEN'
# ------------------------------------------------------------------
# Configuración y plantillas
# ------------------------------------------------------------------
@api.model
def _cfg(self):
return clinic_get_notificaciones(self.env)
@api.model
def render(self, plantilla, cita=None, partner=None, cfg=None):
"""Sustituye las variables de una plantilla con datos reales.
Los nombres de los términos (`{cita}`) salen del glosario vigente; las
demás variables son los datos de la cita y del paciente. Una variable sin
dato queda como cadena vacía.
"""
cfg = cfg or self._cfg()
settings = clinic_get_settings(self.env)
try:
_pack, terms = resolver_glosario(self.env)
except Exception:
terms = {}
cita = cita if cita and cita.exists() else None
partner = partner or (cita.partner_id if cita else None)
contexto = {
'nombre': partner.name if partner else '',
'cita': terms.get('appointment_singular', 'Cita'),
'servicio': '',
'fecha': '',
'hora': '',
'doctor': '',
'sucursal': '',
'telefono': settings.get('telefono') or cfg.get('whatsapp_numero') or '',
}
if cita is not None:
contexto['servicio'] = cita.servicio_id.name if cita.servicio_id else ''
contexto['fecha'] = cita.date.strftime('%d/%m/%Y') if cita.date else ''
contexto['hora'] = cita.time_str or ''
contexto['doctor'] = cita.doctor_id.name if cita.doctor_id else ''
contexto['sucursal'] = self._sucursal_nombre(cita)
elif partner is not None:
contexto['fecha'] = fields.Date.today().strftime('%d/%m/%Y')
contexto['sucursal'] = self._sucursal_default()
texto = str(plantilla or '')
for clave, valor in contexto.items():
texto = texto.replace('{%s}' % clave, str(valor or ''))
return texto
@api.model
def _sucursal_nombre(self, cita):
if not cita.branch:
return self._sucursal_default()
sucursal = self.env['skeen.sucursal'].sudo().search(
[('code', '=', cita.branch)], limit=1)
return sucursal.name or cita.branch
@api.model
def _sucursal_default(self):
"""Sucursal de la clínica para mensajes sin cita (p. ej. cumpleaños)."""
Sucursal = self.env['skeen.sucursal'].sudo()
sucursal = Sucursal.search([('is_default', '=', True)], limit=1) \
or Sucursal.search([('active', '=', True)], order='sequence, name', limit=1)
return sucursal.name or ''
@api.model
def _canales(self, cfg):
canal = cfg.get('canal')
if canal == 'ambos':
return ['whatsapp', 'email']
return [canal] if canal in ('whatsapp', 'email') else ['whatsapp']
@api.model
def _destino_disponible(self, partner, canal):
if partner is None:
return False
if canal == 'whatsapp':
return bool((partner.whatsapp or partner.phone or '').strip())
return bool((partner.email or '').strip())
@api.model
def _clave(self, tipo, cita, partner, canal, ventana=None):
if tipo == 'cumpleanos':
base = 'p%s:%s' % (partner.id, fields.Date.today().year)
elif cita is not None:
base = 'c%s' % cita.id
else:
base = 'p%s' % (partner.id if partner else 0)
partes = [tipo, base]
if ventana not in (None, ''):
partes.append(str(ventana))
partes.append(canal)
return ':'.join(partes)
# ------------------------------------------------------------------
# Encolado
# ------------------------------------------------------------------
@api.model
def encolar(self, cita=None, tipo='recordatorio', cuando=None, canal=None,
partner=None, ventana=None, clave_unica=None, asunto=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.
"""
Notif = self.env['skeen.notificacion'].sudo()
cfg = self._cfg()
if not cfg['activo']:
return Notif
partner = partner or (cita.partner_id if cita else None)
if not partner:
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)
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):
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,
'cita_id': cita.id if cita is not None else False,
'tipo': tipo,
'canal': ch,
'estado': 'pendiente',
'asunto': asunto,
'mensaje': mensaje,
'programada_para': cuando or fields.Datetime.now(),
'clave_unica': clave,
})
return creadas
@api.model
def _asunto(self, tipo):
nombre = clinic_get_settings(self.env).get('nombre') or ''
etiqueta = ETIQUETA_TIPO.get(tipo, tipo)
return ('%s - %s' % (etiqueta, nombre)).strip(' -')
@api.model
def encolar_manual(self, cita, canal=None):
"""Encola y envía un mensaje puntual (botón "enviar ahora").
Devuelve el recordset. `clave_unica` lleva un identificador único para
permitir envíos repetidos a propósito.
"""
clave = 'manual:c%s:%s' % (cita.id, uuid.uuid4().hex[:12])
return self.encolar(cita=cita, tipo='manual', cuando=fields.Datetime.now(),
canal=canal, clave_unica=clave)
# ------------------------------------------------------------------
# Motor del cron
# ------------------------------------------------------------------
@api.model
def ejecutar(self, limite=PROCESAR_POR_DEFECTO):
"""Ciclo completo: encola lo que toca y procesa la cola pendiente.
Con `activo=False` regresa sin tocar nada (cero filas, cero envíos).
"""
resultado = {'encoladas': 0, 'revisadas': 0, 'enviadas': 0,
'simuladas': 0, 'fallidas': 0, 'canceladas': 0}
cfg = self._cfg()
if not cfg['activo']:
return resultado
encoladas = self._encolar_ventanas(cfg)
encoladas += self._encolar_post_visita(cfg)
if cfg['cumpleanos'] and self._hora_cumpleanos_llegada(cfg):
encoladas += self._encolar_cumpleanos(cfg)
if cfg['recuperacion_no_show']:
encoladas += self._encolar_recuperaciones(cfg)
resultado['encoladas'] = encoladas
res = self.procesar_pendientes(limite=limite)
resultado.update(res)
if encoladas or res.get('revisadas'):
_logger.info('SKEEN notificaciones: %s', resultado)
return resultado
@api.model
def _cron_tick(self):
"""Entrada del ir.cron (cada 5 minutos)."""
return self.ejecutar()
@api.model
def _encolar_ventanas(self, cfg):
"""Recordatorios de citas que entran en cada ventana configurada.
Una ventana `h` está vencida cuando faltan `h` horas o menos para la
cita. La idempotencia por `clave_unica` evita que el cron (cada 5 min)
repita el mismo aviso.
"""
now = fields.Datetime.now()
limite = now + timedelta(days=VENTANA_BUSQUEDA_DIAS)
citas = self.env['skeen.cita'].sudo().search([
('state', 'in', ('pending', 'confirmed')),
('datetime_start', '>=', now),
('datetime_start', '<=', limite),
])
creadas = 0
for cita in citas:
restante = (cita.datetime_start - now).total_seconds() / 3600.0
for horas in cfg['recordatorio_horas_antes']:
if restante <= horas:
creadas += len(self.encolar(cita, 'recordatorio',
cuando=now, ventana=horas))
return creadas
@api.model
def _hora_cumpleanos_llegada(self, cfg):
"""True si ya pasó la hora local configurada para felicitar."""
try:
tz = ZoneInfo(clinic_get_settings(self.env).get('zona_horaria')
or 'America/Tijuana')
except Exception:
tz = ZoneInfo('America/Tijuana')
local = datetime.now(tz)
return local.strftime('%H:%M') >= (cfg.get('hora_envio_cumpleanos') or '09:00')
@api.model
def _encolar_cumpleanos(self, cfg):
hoy = fields.Date.today()
pacientes = self.env['res.partner'].sudo().search([
('is_patient', '=', True),
('birth_date', '!=', False),
])
now = fields.Datetime.now()
creadas = 0
for p in pacientes:
try:
ocurre = p.birth_date.replace(year=hoy.year)
except ValueError: # 29-feb en año no bisiesto
ocurre = p.birth_date.replace(year=hoy.year, day=28)
if (ocurre.month, ocurre.day) == (hoy.month, hoy.day):
creadas += len(self.encolar(partner=p, tipo='cumpleanos',
cuando=now))
return creadas
@api.model
def _encolar_recuperaciones(self, cfg):
now = fields.Datetime.now()
desde = now - timedelta(hours=RECUPERACION_HORAS)
citas = self.env['skeen.cita'].sudo().search([
('state', '=', 'no_show'),
('datetime_start', '>=', desde),
('datetime_start', '<=', now),
])
return sum(len(self.encolar(c, 'recuperacion', cuando=now)) for c in citas)
@api.model
def _encolar_post_visita(self, cfg):
horas = cfg.get('post_visita_horas') or 0
if horas <= 0:
return 0
now = fields.Datetime.now()
limite = now - timedelta(hours=horas)
desde = now - timedelta(days=POST_VISITA_DIAS)
citas = self.env['skeen.cita'].sudo().search([
('state', '=', 'done'),
('datetime_end', '<=', limite),
('datetime_end', '>=', desde),
])
return sum(len(self.encolar(c, 'post_visita', cuando=now)) for c in citas)
# ------------------------------------------------------------------
# Procesamiento de la cola
# ------------------------------------------------------------------
@api.model
def procesar_pendientes(self, limite=PROCESAR_POR_DEFECTO):
"""Intenta enviar las notificaciones pendientes ya vencidas.
Un fallo incrementa `intentos` (con backoff) y NO detiene la cola: cada
registro se procesa en su propio try/except. Con `modo_prueba=True` se
marca `simulada` sin llamar a ningún proveedor.
"""
resultado = {'revisadas': 0, 'enviadas': 0, 'simuladas': 0,
'fallidas': 0, 'canceladas': 0}
cfg = self._cfg()
if not cfg['activo']:
return resultado
ahora = fields.Datetime.now()
pendientes = self.env['skeen.notificacion'].sudo().search([
('estado', '=', 'pendiente'),
('programada_para', '<=', ahora),
], order='programada_para asc, id asc', limit=limite)
resultado['revisadas'] = len(pendientes)
for notif in pendientes:
try:
estado = self._intentar(notif, cfg)
except Exception as exc: # nunca tumbar la cola
_logger.exception('Notificación %s: error inesperado', notif.id)
notif.write({'estado': 'error', 'error': str(exc)[:500]})
estado = 'fallida'
resultado[{
'enviada': 'enviadas',
'simulada': 'simuladas',
'fallida': 'fallidas',
'cancelada': 'canceladas',
}.get(estado, 'fallidas')] += 1
return resultado
@api.model
def _intentar(self, notif, cfg):
"""Intenta una notificación. Devuelve el desenlace en texto."""
cita = notif.cita_id
if cita and notif.tipo in ('recordatorio', 'confirmacion', 'post_visita'):
if cita.state == 'cancelled' or (
notif.tipo == 'recordatorio'
and cita.state in ('arrived', 'in_progress', 'done', 'no_show')):
notif.write({'estado': 'cancelada',
'error': 'La cita ya no está pendiente'})
return 'cancelada'
if cfg['modo_prueba']:
notif.write({'estado': 'simulada', 'enviada_at': fields.Datetime.now(),
'intentos': notif.intentos + 1, 'error': False})
return 'simulada'
try:
if notif.canal == 'whatsapp':
self.enviar_whatsapp(notif.partner_id, notif.mensaje, cfg=cfg)
else:
self.enviar_email(notif.partner_id, notif.asunto or 'Notificación',
notif.mensaje, cfg=cfg)
except Exception as exc:
intentos = notif.intentos + 1
vals = {'intentos': intentos, 'error': str(exc)[:500]}
if intentos >= MAX_INTENTOS:
vals['estado'] = 'error'
else:
espera = BACKOFF_MIN[min(intentos - 1, len(BACKOFF_MIN) - 1)]
vals['programada_para'] = fields.Datetime.now() + timedelta(minutes=espera)
notif.write(vals)
_logger.warning('Notificación %s falló (intento %s): %s',
notif.id, intentos, exc)
return 'fallida'
notif.write({'estado': 'enviada', 'enviada_at': fields.Datetime.now(),
'intentos': notif.intentos + 1, 'error': False})
return 'enviada'
# ------------------------------------------------------------------
# Adaptadores de envío
# ------------------------------------------------------------------
@api.model
def _wacrm_credenciales(self):
ICP = self.env['ir.config_parameter'].sudo()
url = os.environ.get('WACRM_API_URL') or ICP.get_param('skeen.wacrm.api_url')
key = os.environ.get('WACRM_API_KEY') or ICP.get_param('skeen.wacrm.api_key')
return (url or '').rstrip('/'), (key or '')
@api.model
def enviar_whatsapp(self, partner, mensaje, cfg=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).
"""
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 not phone:
raise UserError(_('El paciente no tiene teléfono ni WhatsApp registrado.'))
digits = re.sub(r'\D', '', phone)
destino = ('+' + digits.lstrip('0')) if digits else ''
if not destino:
raise UserError(_('El teléfono del paciente no es válido.'))
try:
resp = requests.post(
url + WHATSAPP_API_PATH,
json={'to': destino, 'type': 'text', 'text': mensaje},
headers={'Authorization': 'Bearer ' + key,
'Content-Type': 'application/json'},
timeout=30,
)
except requests.RequestException as exc:
raise UserError(_('No se pudo contactar al servicio de WhatsApp: %s') % exc)
if resp.status_code >= 400:
raise UserError(_('WhatsApp (WACRM) respondió %s: %s')
% (resp.status_code, (resp.text or '')[:200]))
return True
@api.model
def _smtp_configurado(self):
"""True si Odoo tiene un servidor de correo saliente configurado."""
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:
raise UserError(_('El paciente no tiene correo registrado.'))
if not self._smtp_configurado():
raise UserError(_('No hay servidor SMTP configurado en Odoo.'))
cfg = cfg or self._cfg()
remitente = (cfg.get('email_remitente')
or clinic_get_settings(self.env).get('email')
or self.env.company.email or '')
mail = self.env['mail.mail'].sudo().create({
'subject': asunto or 'Notificación',
'body_html': '<div style="white-space:pre-wrap">%s</div>'
% html.escape(mensaje or ''),
'email_to': destino,
'email_from': remitente or None,
})
mail.send()
return True
# ------------------------------------------------------------------
# Prueba de conexión
# ------------------------------------------------------------------
@api.model
def probar_canal(self, canal, destino='', cfg=None):
"""Prueba de conexión de un canal.
En `modo_prueba` solo valida que el canal esté configurado y responde
simulado, sin enviar a nadie real. Devuelve un dict, nunca lanza.
"""
cfg = cfg or self._cfg()
canal = (canal or '').strip().lower()
if canal not in ('whatsapp', 'email'):
return {'ok': False, 'simulado': False,
'message': 'Canal no válido: usa whatsapp o email.'}
if canal == 'whatsapp':
url, key = self._wacrm_credenciales()
if not url or not key:
return {'ok': False, 'simulado': False,
'message': 'No hay credenciales de WhatsApp (WACRM) configuradas.'}
if cfg['modo_prueba']:
return {'ok': True, 'simulado': True,
'message': 'Modo prueba: WhatsApp está configurado; no se envió nada real.'}
telefono = (destino or cfg.get('whatsapp_numero') or '').strip()
if not telefono:
return {'ok': False, 'simulado': False,
'message': 'Indica un número de destino para la prueba.'}
try:
digits = re.sub(r'\D', '', telefono)
resp = requests.post(
url + WHATSAPP_API_PATH,
json={'to': '+' + digits.lstrip('0'), 'type': 'text',
'text': 'Prueba de conexión de SKEEN.'},
headers={'Authorization': 'Bearer ' + key,
'Content-Type': 'application/json'},
timeout=30)
if resp.status_code >= 400:
return {'ok': False, 'simulado': False,
'message': 'WACRM respondió %s: %s'
% (resp.status_code, (resp.text or '')[:200])}
return {'ok': True, 'simulado': False, 'message': 'Mensaje de prueba enviado.'}
except requests.RequestException as exc:
return {'ok': False, 'simulado': False,
'message': 'No se pudo contactar al servicio de WhatsApp: %s' % exc}
# canal == 'email'
if not self._smtp_configurado():
return {'ok': False, 'simulado': False,
'message': 'No hay servidor SMTP configurado en Odoo.'}
if cfg['modo_prueba']:
return {'ok': True, 'simulado': True,
'message': 'Modo prueba: el correo está configurado; no se envió nada real.'}
if '@' not in (destino or ''):
return {'ok': False, 'simulado': False,
'message': 'Indica un correo de destino válido para la prueba.'}
try:
destinatario = self.env['res.partner'].sudo().new(
{'name': destino, 'email': destino})
self.enviar_email(destinatario, 'Prueba de conexión SKEEN',
'Prueba de conexión de notificaciones por correo.', cfg=cfg)
return {'ok': True, 'simulado': False, 'message': 'Correo de prueba enviado.'}
except Exception as exc:
return {'ok': False, 'simulado': False, 'message': str(exc)}

View File

@@ -84,6 +84,11 @@ PERMISSION_GROUPS = [
('whatsapp.responder', 'Responder conversaciones'),
('whatsapp.asignar', 'Asignar conversaciones y leads'),
]),
('notificaciones', 'Notificaciones', [
('notificaciones.ver', 'Ver el historial de notificaciones'),
('notificaciones.enviar', 'Enviar y reintentar notificaciones'),
('notificaciones.editar', 'Configurar recordatorios y plantillas'),
]),
('config', 'Usuarios y configuración', [
('config.usuarios', 'Administrar usuarios'),
('config.roles', 'Administrar roles y menús'),
@@ -110,6 +115,7 @@ READ_PERM_KEYS = (
'reportes.ver', 'reportes.exportar', 'caja.ver',
'ventas.reimprimir',
'whatsapp.ver',
'notificaciones.ver',
)
# ---------------------------------------------------------------------------
@@ -186,6 +192,11 @@ DEFAULT_PERM_ROLES = {
'whatsapp.ver': W,
'whatsapp.responder': W,
'whatsapp.asignar': W,
# Notificaciones de citas: ver es de todos; enviar lo hace recepción y
# quien agenda; configurar (plantillas y conexión) es de admin/gerencia.
'notificaciones.ver': ALL,
'notificaciones.enviar': W,
'notificaciones.editar': AG,
# Usuarios y configuración
'config.usuarios': AG,
'config.roles': AG,

View File

@@ -11,3 +11,4 @@ access_skeen_config_audit,skeen.config.audit,model_skeen_config_audit,base.group
access_skeen_wacrm_note,Notas internas WACRM,model_skeen_wacrm_note,base.group_user,1,1,1,1
access_skeen_wacrm_tag,Etiquetas WACRM,model_skeen_wacrm_tag,base.group_user,1,1,1,1
access_skeen_wacrm_conversation_tag,Etiquetas por conversacion,model_skeen_wacrm_conversation_tag,base.group_user,1,1,1,1
access_skeen_notificacion,Notificaciones de citas,model_skeen_notificacion,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
11 access_skeen_wacrm_note Notas internas WACRM model_skeen_wacrm_note base.group_user 1 1 1 1
12 access_skeen_wacrm_tag Etiquetas WACRM model_skeen_wacrm_tag base.group_user 1 1 1 1
13 access_skeen_wacrm_conversation_tag Etiquetas por conversacion model_skeen_wacrm_conversation_tag base.group_user 1 1 1 1
14 access_skeen_notificacion Notificaciones de citas model_skeen_notificacion base.group_user 1 1 1 1