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

@@ -0,0 +1,236 @@
import type { FC } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { Play, RefreshCw, RotateCcw } from 'lucide-react';
import { Badge, Button, Card, EmptyState, Input, Select, toast } from './ui';
import { Skeleton } from './ui/Skeleton';
import { odooApi, type Notificacion, type NotificacionEstado } from '../services/odoo';
import { useAuth } from '../lib/auth';
import { useGlosario } from '../lib/glosario';
// Historial de notificaciones enviadas (y simuladas/fallidas). Nada se manda en
// silencio: cada intento queda aquí con su estado, canal y mensaje.
const ESTADOS: { value: '' | NotificacionEstado; label: string }[] = [
{ value: '', label: 'Todos los estados' },
{ value: 'pendiente', label: 'Pendiente' },
{ value: 'enviada', label: 'Enviada' },
{ value: 'simulada', label: 'Simulada' },
{ value: 'error', label: 'Error' },
{ value: 'cancelada', label: 'Cancelada' },
];
const TIPOS: { value: string; label: string }[] = [
{ value: '', label: 'Todos los tipos' },
{ value: 'recordatorio', label: 'Recordatorio' },
{ value: 'confirmacion', label: 'Confirmación' },
{ value: 'cumpleanos', label: 'Cumpleaños' },
{ value: 'post_visita', label: 'Post-visita' },
{ value: 'recuperacion', label: 'Recuperación' },
{ value: 'manual', label: 'Manual' },
];
const badgeEstado = (estado: string) => {
switch (estado) {
case 'enviada': return 'success' as const;
case 'simulada': return 'info' as const;
case 'pendiente': return 'pending' as const;
case 'error': return 'danger' as const;
default: return 'done' as const;
}
};
const NotificacionesEnviadasPanel: FC = () => {
const { can } = useAuth();
const { term } = useGlosario();
const puedeEnviar = can('notificaciones.enviar');
const [rows, setRows] = useState<Notificacion[]>([]);
const [resumen, setResumen] = useState<Record<string, number>>({});
const [total, setTotal] = useState(0);
const [page, setPage] = useState(1);
const [pageSize] = useState(25);
const [loading, setLoading] = useState(true);
const [procesando, setProcesando] = useState(false);
const [reintentando, setReintentando] = useState<number | null>(null);
const [filtros, setFiltros] = useState({ estado: '', tipo: '', start: '', end: '' });
const cargar = useCallback(async () => {
setLoading(true);
try {
const res = await odooApi.getNotificaciones({
page, page_size: pageSize,
...(filtros.estado ? { estado: filtros.estado } : {}),
...(filtros.tipo ? { tipo: filtros.tipo } : {}),
...(filtros.start ? { start: filtros.start } : {}),
...(filtros.end ? { end: filtros.end } : {}),
});
setRows(res.notificaciones || []);
setResumen(res.resumen || {});
setTotal(res.total || 0);
} catch {
toast.error('No se pudo cargar el historial de notificaciones');
} finally {
setLoading(false);
}
}, [page, pageSize, filtros]);
useEffect(() => { cargar(); }, [cargar]);
const setFiltro = (k: keyof typeof filtros, v: string) => {
setPage(1);
setFiltros((prev) => ({ ...prev, [k]: v }));
};
const procesar = async () => {
setProcesando(true);
try {
const res = await odooApi.procesarNotificaciones(200);
toast.success(`Encoladas ${res.encoladas} · enviadas ${res.enviadas} · simuladas ${res.simuladas} · fallidas ${res.fallidas}`);
await cargar();
} catch {
toast.error('No se pudo procesar la cola');
} finally {
setProcesando(false);
}
};
const reintentar = async (id: number) => {
setReintentando(id);
try {
const res = await odooApi.reintentarNotificacion(id);
toast.success(`Reintento: ${res.notificacion?.estado_label || res.estado}`);
await cargar();
} catch {
toast.error('No se pudo reintentar');
} finally {
setReintentando(null);
}
};
const totalPaginas = Math.max(1, Math.ceil(total / pageSize));
return (
<div className="space-y-5">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<h3 className="font-heading text-xl text-theme-heading">Notificaciones enviadas</h3>
<p className="text-sm text-theme-muted">
Historial de avisos de {term('appointment_plural').toLowerCase()}. Cada intento queda registrado.
</p>
</div>
{puedeEnviar && (
<Button variant="outline" loading={procesando} onClick={procesar}>
<Play size={15} className="mr-1.5" /> Procesar cola ahora
</Button>
)}
</div>
{/* Resumen por estado */}
<div className="flex flex-wrap gap-2">
{Object.entries(resumen).map(([estado, n]) => (
<button key={estado} type="button"
onClick={() => setFiltro('estado', filtros.estado === estado ? '' : estado)}
className={`rounded-full border px-3 py-1 text-xs font-medium transition ${
filtros.estado === estado ? 'border-theme-accent bg-theme-accent text-theme-inverse' : 'border-theme-border text-theme-muted hover:bg-theme-bg'
}`}>
{estado}: {n}
</button>
))}
</div>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
<Select label="Estado" value={filtros.estado}
onChange={(e) => setFiltro('estado', e.target.value)} options={ESTADOS} />
<Select label="Tipo" value={filtros.tipo}
onChange={(e) => setFiltro('tipo', e.target.value)} options={TIPOS} />
<Input label="Desde" type="date" value={filtros.start}
onChange={(e) => setFiltro('start', e.target.value)} />
<Input label="Hasta" type="date" value={filtros.end}
onChange={(e) => setFiltro('end', e.target.value)} />
</div>
<Card>
<Card.Body>
{loading ? (
<Skeleton className="h-12" count={4} />
) : rows.length === 0 ? (
<EmptyState title="Sin notificaciones" subtitle="No hay avisos que coincidan con los filtros." />
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead className="bg-theme-bg">
<tr>
<th className="p-3 text-left text-xs font-medium uppercase text-theme-muted">Estado</th>
<th className="p-3 text-left text-xs font-medium uppercase text-theme-muted">Tipo</th>
<th className="p-3 text-left text-xs font-medium uppercase text-theme-muted">Canal</th>
<th className="p-3 text-left text-xs font-medium uppercase text-theme-muted">Paciente</th>
<th className="p-3 text-left text-xs font-medium uppercase text-theme-muted">Mensaje</th>
<th className="p-3 text-left text-xs font-medium uppercase text-theme-muted">Fecha</th>
{puedeEnviar && <th className="p-3 text-left text-xs font-medium uppercase text-theme-muted"></th>}
</tr>
</thead>
<tbody className="divide-y">
{rows.map((n) => (
<tr key={n.id} className="align-top hover:bg-theme-bg">
<td className="p-3">
<Badge variant={badgeEstado(n.estado)}>{n.estado_label}</Badge>
{n.intentos > 0 && (
<span className="mt-1 block text-[11px] text-theme-muted">{n.intentos} intento(s)</span>
)}
</td>
<td className="p-3 text-sm text-theme-heading">{n.tipo_label}</td>
<td className="p-3 text-sm text-theme-muted">{n.canal_label}</td>
<td className="p-3 text-sm">
<span className="text-theme-heading">{n.patient}</span>
{n.cita_ref && <span className="block text-[11px] text-theme-muted">{n.cita_ref}</span>}
</td>
<td className="max-w-md p-3 text-sm text-theme-muted">
<span className="line-clamp-2 whitespace-pre-wrap">{n.mensaje}</span>
{n.error && <span className="mt-1 block text-[11px] text-rose-600">{n.error}</span>}
</td>
<td className="whitespace-nowrap p-3 text-xs text-theme-muted">
{n.enviada_at || n.programada_para || '-'}
</td>
{puedeEnviar && (
<td className="p-3">
{['error', 'pendiente'].includes(n.estado) && (
<Button size="sm" variant="ghost" loading={reintentando === n.id}
onClick={() => reintentar(n.id)}>
<RotateCcw size={14} className="mr-1" /> Reintentar
</Button>
)}
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
)}
{totalPaginas > 1 && (
<div className="mt-4 flex items-center justify-between">
<p className="text-xs text-theme-muted">{total} notificaciones</p>
<div className="flex items-center gap-2">
<Button size="sm" variant="outline" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
Anterior
</Button>
<span className="text-xs text-theme-muted">Página {page} de {totalPaginas}</span>
<Button size="sm" variant="outline" disabled={page >= totalPaginas} onClick={() => setPage((p) => p + 1)}>
Siguiente
</Button>
</div>
</div>
)}
<div className="mt-3 flex justify-end">
<Button size="sm" variant="ghost" onClick={cargar}>
<RefreshCw size={14} className="mr-1" /> Actualizar
</Button>
</div>
</Card.Body>
</Card>
</div>
);
};
export default NotificacionesEnviadasPanel;

View File

@@ -0,0 +1,281 @@
import type { FC, ReactNode } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { BellRing, FlaskConical, PlugZap, Save } from 'lucide-react';
import { Button, Input, Select, TextArea, toast } from './ui';
import {
odooApi, NOTIFICACIONES_DEFAULT, NOTIF_VARIABLES, NOTIF_VENTANAS,
type ClinicSettings, type NotificacionCanal, type NotificacionesSettings,
} from '../services/odoo';
import { useGlosario } from '../lib/glosario';
// Editor del bloque `notificaciones` (recordatorios y avisos automáticos de
// citas). Todo apagado por defecto: mientras `activo` esté apagado nada se
// envía ni se encola. Con `modo_prueba` encendido (default) los mensajes se
// registran como simulados y no se llama a ningún proveedor externo.
interface Props {
settings: ClinicSettings;
onSaved: (settings: ClinicSettings) => void;
}
const TIPOS_PLANTILLA: { key: keyof NotificacionesSettings; label: string }[] = [
{ key: 'plantilla_recordatorio', label: 'Recordatorio' },
{ key: 'plantilla_confirmacion', label: 'Confirmación' },
{ key: 'plantilla_cumpleanos', label: 'Cumpleaños' },
{ key: 'plantilla_post_visita', label: 'Post-visita' },
];
const NotificacionesPanel: FC<Props> = ({ settings, onSaved }) => {
const { term } = useGlosario();
const [form, setForm] = useState<NotificacionesSettings>(
settings.notificaciones || NOTIFICACIONES_DEFAULT);
const [saving, setSaving] = useState(false);
const [probando, setProbando] = useState(false);
const [pruebaCanal, setPruebaCanal] = useState<NotificacionCanal>('whatsapp');
const [pruebaDestino, setPruebaDestino] = useState('');
useEffect(() => {
setForm(settings.notificaciones || NOTIFICACIONES_DEFAULT);
}, [settings.notificaciones]);
const set = <K extends keyof NotificacionesSettings>(k: K, v: NotificacionesSettings[K]) =>
setForm((prev) => ({ ...prev, [k]: v }));
// Ejemplo con datos ficticios para la vista previa (respeta el glosario).
const ejemplo = useMemo(() => ({
nombre: 'Ana López',
cita: term('appointment_singular'),
servicio: 'Limpieza facial',
fecha: '15/03/2026',
hora: '10:30',
doctor: 'Dra. García',
sucursal: settings.nombre || 'Sucursal Centro',
telefono: settings.telefono || '+52 661 000 0000',
}), [term, settings.nombre, settings.telefono]);
const previsualizar = (plantilla: string) =>
plantilla.replace(/\{([a-zA-Z_]+)\}/g, (_m, key: string) =>
(ejemplo as Record<string, string>)[key] ?? `{${key}}`);
const toggleVentana = (horas: number) => {
const actuales = form.recordatorio_horas_antes || [];
const siguientes = actuales.includes(horas)
? actuales.filter((h) => h !== horas)
: [...actuales, horas];
set('recordatorio_horas_antes', siguientes.sort((a, b) => a - b));
};
const guardar = async () => {
setSaving(true);
try {
const res = await odooApi.putClinicSettings({
notificaciones: form,
motivo: 'Notificaciones desde Configuración',
});
if (res.status === 'success') {
onSaved(res.settings);
toast.success('Notificaciones guardadas');
}
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudieron guardar las notificaciones');
} finally {
setSaving(false);
}
};
const probar = async () => {
setProbando(true);
try {
const res = await odooApi.probarNotificacion({ canal: pruebaCanal, destino: pruebaDestino });
if (res.ok) {
toast.success(res.message || 'Canal configurado');
} else {
toast.error(res.message || 'El canal no está configurado');
}
} catch {
toast.error('No se pudo probar el canal');
} finally {
setProbando(false);
}
};
return (
<div className="space-y-6">
<div className="flex items-start gap-3">
<BellRing size={20} className="mt-0.5 shrink-0 text-theme-accent" />
<div>
<h3 className="font-heading text-xl text-theme-heading">Recordatorios y notificaciones</h3>
<p className="text-sm text-theme-muted">
Avisos automáticos de {term('appointment_plural').toLowerCase()} por WhatsApp o correo:
recordatorios antes de la {term('appointment_singular').toLowerCase()}, confirmación al
reservar en línea, cumpleaños y recuperación de inasistencias. Apagado = nada cambia.
</p>
</div>
</div>
<Toggle label="Activar notificaciones" checked={form.activo}
onChange={(v) => set('activo', v)}
ayuda="Mientras esté apagado no se envía ni se registra ninguna notificación." />
<div className="rounded-2xl border border-amber-300 bg-amber-50 p-4">
<Toggle label="Modo prueba (recomendado al empezar)" checked={form.modo_prueba}
onChange={(v) => set('modo_prueba', v)}
ayuda={form.modo_prueba
? 'Modo prueba activo: los mensajes se registran como «simulados» y NO se envía nada real a ningún paciente.'
: 'Modo prueba apagado: los mensajes se intentarán enviar de verdad por el canal elegido.'} />
{!form.modo_prueba && (
<p className="mt-2 text-xs font-medium text-amber-700">
Atención: los pacientes recibirán mensajes reales.
</p>
)}
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Select label="Canal" value={form.canal}
onChange={(e) => set('canal', e.target.value as NotificacionesSettings['canal'])}
options={[
{ value: 'whatsapp', label: 'WhatsApp' },
{ value: 'email', label: 'Correo' },
{ value: 'ambos', label: 'Ambos' },
]} />
<Input label="Número de WhatsApp de la clínica" value={form.whatsapp_numero}
onChange={(e) => set('whatsapp_numero', e.target.value)}
placeholder="+52 661 000 0000 (opcional)" />
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Input label="Correo remitente" value={form.email_remitente}
onChange={(e) => set('email_remitente', e.target.value)}
placeholder="citas@clinica.mx (opcional)" />
</div>
{/* Ventanas de recordatorio */}
<div className="rounded-2xl border border-theme-border p-4">
<p className="mb-1 text-sm font-medium text-theme-heading">
Avisar antes de la {term('appointment_singular').toLowerCase()}
</p>
<p className="mb-3 text-xs text-theme-muted">
Puedes elegir varias ventanas (por ejemplo, 24 h y 2 h). Se envía una sola vez por ventana.
</p>
<div className="flex flex-wrap gap-2">
{NOTIF_VENTANAS.map((v) => {
const activa = (form.recordatorio_horas_antes || []).includes(v.horas);
return (
<button key={v.horas} type="button" onClick={() => toggleVentana(v.horas)}
className={`rounded-full px-3 py-1.5 text-sm font-medium transition ${
activa ? 'bg-theme-accent text-theme-inverse' : 'bg-theme-accent-bg text-theme-heading hover:bg-theme-bg'
}`}>
{v.label}
</button>
);
})}
</div>
{(form.recordatorio_horas_antes || []).length === 0 && (
<p className="mt-2 text-xs text-amber-700">
Sin ventanas seleccionadas no se enviarán recordatorios.
</p>
)}
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Toggle label="Confirmar al reservar en línea" checked={form.confirmacion_al_crear}
onChange={(v) => set('confirmacion_al_crear', v)}
ayuda="Avisa al paciente cuando agenda solo, desde el portal de reservas en línea." />
<Toggle label="Felicitar cumpleaños" checked={form.cumpleanos}
onChange={(v) => set('cumpleanos', v)} />
<Toggle label="Recuperar inasistencias (no-show)" checked={form.recuperacion_no_show}
onChange={(v) => set('recuperacion_no_show', v)}
ayuda="Mensaje a quien no se presentó, para reagendar." />
<Input label="Hora de cumpleaños" type="time" value={form.hora_envio_cumpleanos}
onChange={(e) => set('hora_envio_cumpleanos', e.target.value)} />
</div>
<div className="max-w-xs">
<Input label="Seguimiento post-visita (horas después)" type="number" min={0} max={168}
value={String(form.post_visita_horas)}
onChange={(e) => set('post_visita_horas', Math.max(0, Number(e.target.value) || 0))} />
<p className="mt-1 text-xs text-theme-muted">0 = apagado.</p>
</div>
{/* Plantillas con variables y vista previa */}
<div className="space-y-4">
<div>
<p className="text-sm font-medium text-theme-heading">Plantillas</p>
<p className="text-xs text-theme-muted">
Variables disponibles:{' '}
{NOTIF_VARIABLES.map((v) => (
<code key={v} className="mr-1 rounded bg-theme-bg px-1 py-0.5 text-[11px]">{`{${v}}`}</code>
))}
</p>
</div>
{TIPOS_PLANTILLA.map(({ key, label }) => (
<div key={key} className="rounded-2xl border border-theme-border p-4">
<TextArea label={label} rows={3}
value={String(form[key] || '')}
onChange={(e) => set(key, e.target.value as NotificacionesSettings[typeof key])} />
<div className="mt-2 flex flex-wrap gap-1.5">
{NOTIF_VARIABLES.map((v) => (
<button key={v} type="button"
onClick={() => set(key, `${String(form[key] || '')}{${v}}` as NotificacionesSettings[typeof key])}
className="rounded-full bg-theme-accent-bg px-2 py-0.5 text-[11px] font-medium text-theme-heading hover:bg-theme-bg">
{`{${v}}`}
</button>
))}
</div>
<div className="mt-3 rounded-xl bg-theme-bg p-3">
<p className="mb-1 text-[11px] font-medium uppercase tracking-wide text-theme-muted">Vista previa</p>
<p className="whitespace-pre-wrap text-sm text-theme-heading">
{previsualizar(String(form[key] || ''))}
</p>
</div>
</div>
))}
</div>
{/* Prueba de conexión */}
<div className="rounded-2xl border border-theme-border p-4">
<p className="mb-1 flex items-center text-sm font-medium text-theme-heading">
<PlugZap size={16} className="mr-2" /> Probar conexión
</p>
<p className="mb-3 text-xs text-theme-muted">
En modo prueba solo se valida que el canal esté configurado; no se envía a nadie real.
</p>
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
<Select label="Canal" value={pruebaCanal}
onChange={(e) => setPruebaCanal(e.target.value as NotificacionCanal)}
options={[{ value: 'whatsapp', label: 'WhatsApp' }, { value: 'email', label: 'Correo' }]} />
<div className="flex-1">
<Input label="Destino (opcional)"
value={pruebaDestino} onChange={(e) => setPruebaDestino(e.target.value)}
placeholder={pruebaCanal === 'email' ? 'correo@ejemplo.com' : '+52 ...'} />
</div>
<Button variant="outline" loading={probando} onClick={probar}>
<FlaskConical size={15} className="mr-1.5" /> Probar
</Button>
</div>
</div>
<div className="flex justify-end">
<Button loading={saving} onClick={guardar}>
<Save size={16} className="mr-2" /> Guardar notificaciones
</Button>
</div>
</div>
);
};
const Toggle: FC<{ label: string; checked: boolean; onChange: (v: boolean) => void; ayuda?: ReactNode }> = ({
label, checked, onChange, ayuda,
}) => (
<label className="flex cursor-pointer items-start gap-3 rounded-2xl border border-theme-border px-4 py-3">
<input type="checkbox" checked={checked} onChange={(e) => onChange(e.target.checked)}
className="mt-0.5 h-4 w-4 accent-theme-accent" />
<span>
<span className="block text-sm font-medium text-theme-heading">{label}</span>
{ayuda && <span className="mt-0.5 block text-xs text-theme-muted">{ayuda}</span>}
</span>
</label>
);
export default NotificacionesPanel;

View File

@@ -1,5 +1,5 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { odooApi, type ClinicSettings, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT } from '../services/odoo'; import { odooApi, type ClinicSettings, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, NOTIFICACIONES_DEFAULT } from '../services/odoo';
// Ajustes de la clínica compartidos por la sesión (nombre, moneda e impuestos). // Ajustes de la clínica compartidos por la sesión (nombre, moneda e impuestos).
// Se leen una sola vez del servidor y se avisa a quien los use cuando cambian. // Se leen una sola vez del servidor y se avisa a quien los use cuando cambian.
@@ -9,6 +9,7 @@ const normalizar = (settings?: Partial<ClinicSettings> | null): ClinicSettings =
...CLINIC_SETTINGS_DEFAULT, ...CLINIC_SETTINGS_DEFAULT,
...(settings || {}), ...(settings || {}),
impuestos: { ...IMPUESTOS_DEFAULT, ...(settings?.impuestos || {}) }, impuestos: { ...IMPUESTOS_DEFAULT, ...(settings?.impuestos || {}) },
notificaciones: { ...NOTIFICACIONES_DEFAULT, ...(settings?.notificaciones || {}) },
}); });
let actual: ClinicSettings = normalizar(null); let actual: ClinicSettings = normalizar(null);

View File

@@ -15,6 +15,7 @@ export const PERM_KEYS: string[] = [
'servicios.ver', 'servicios.editar', 'servicios.ver', 'servicios.editar',
'reportes.ver', 'reportes.exportar', 'caja.ver', 'caja.cerrar', 'reportes.ver', 'reportes.exportar', 'caja.ver', 'caja.cerrar',
'whatsapp.ver', 'whatsapp.responder', 'whatsapp.asignar', 'whatsapp.ver', 'whatsapp.responder', 'whatsapp.asignar',
'notificaciones.ver', 'notificaciones.enviar', 'notificaciones.editar',
'config.usuarios', 'config.roles', 'config.editar', 'config.auditoria', 'config.usuarios', 'config.roles', 'config.editar', 'config.auditoria',
]; ];
@@ -35,6 +36,7 @@ const READ_PERM_KEYS = new Set([
'reportes.ver', 'reportes.exportar', 'caja.ver', 'reportes.ver', 'reportes.exportar', 'caja.ver',
'ventas.reimprimir', 'ventas.reimprimir',
'whatsapp.ver', 'whatsapp.ver',
'notificaciones.ver',
]); ]);
const DEFAULT_PERM_ROLES: Record<string, string[]> = { const DEFAULT_PERM_ROLES: Record<string, string[]> = {
@@ -53,6 +55,7 @@ const DEFAULT_PERM_ROLES: Record<string, string[]> = {
'servicios.ver': ALL, 'servicios.editar': AD, 'servicios.ver': ALL, 'servicios.editar': AD,
'reportes.ver': ALL, 'reportes.exportar': ALL, 'caja.ver': ALL, 'caja.cerrar': MONEY, 'reportes.ver': ALL, 'reportes.exportar': ALL, 'caja.ver': ALL, 'caja.cerrar': MONEY,
'whatsapp.ver': W, 'whatsapp.responder': W, 'whatsapp.asignar': W, 'whatsapp.ver': W, 'whatsapp.responder': W, 'whatsapp.asignar': W,
'notificaciones.ver': ALL, 'notificaciones.enviar': W, 'notificaciones.editar': AG,
'config.usuarios': AG, 'config.roles': AG, 'config.editar': AG, 'config.auditoria': AD, 'config.usuarios': AG, 'config.roles': AG, 'config.editar': AG, 'config.auditoria': AD,
}; };

View File

@@ -1,6 +1,6 @@
import type { FC } from 'react'; import type { FC } from 'react';
import { useEffect, useState, useCallback, useRef, useMemo } from 'react'; import { useEffect, useState, useCallback, useRef, useMemo } from 'react';
import { Clock, CheckCircle, XCircle, CheckCheck, UserCheck, UserX, Plus, PackageCheck, Sparkles, RotateCcw, LayoutGrid, List, Rows3, CalendarRange, CalendarClock, ChevronLeft, ChevronRight, Ban, Phone, Cake, CreditCard, Pencil, AlertTriangle, Printer, PhoneCall, Wallet, ChevronDown, CalendarOff, Users, Play, Check, Globe } from 'lucide-react'; import { Clock, CheckCircle, XCircle, CheckCheck, UserCheck, UserX, Plus, PackageCheck, Sparkles, RotateCcw, LayoutGrid, List, Rows3, CalendarRange, CalendarClock, ChevronLeft, ChevronRight, Ban, Phone, Cake, CreditCard, Pencil, AlertTriangle, Printer, PhoneCall, Wallet, ChevronDown, CalendarOff, Users, Play, Check, Globe, Bell } from 'lucide-react';
import Layout from '../components/Layout'; import Layout from '../components/Layout';
import { usePatientDrawer } from '../components/PatientDrawer'; import { usePatientDrawer } from '../components/PatientDrawer';
import AgendaPrint from '../components/AgendaPrint'; import AgendaPrint from '../components/AgendaPrint';
@@ -26,9 +26,10 @@ import {
badgeForAppointmentState, badgeForAppointmentState,
} from '../components/ui'; } from '../components/ui';
import { useSearchParams, useNavigate } from 'react-router-dom'; import { useSearchParams, useNavigate } from 'react-router-dom';
import { odooApi, type Appointment, type AppointmentStateLog, type Patient, type Service, type Doctor, type Bloqueo, type ListaEspera, type PatientWallet, type ReceiptData, type AgendaPerm, type GlossaryKey, AGENDA_PERM_ITEMS } from '../services/odoo'; import { odooApi, type Appointment, type AppointmentStateLog, type Patient, type Service, type Doctor, type Bloqueo, type ListaEspera, type PatientWallet, type ReceiptData, type AgendaPerm, type GlossaryKey, type Notificacion, AGENDA_PERM_ITEMS } from '../services/odoo';
import { useAuth } from '../lib/auth'; import { useAuth } from '../lib/auth';
import { useGlosario } from '../lib/glosario'; import { useGlosario } from '../lib/glosario';
import { useClinicSettings } from '../lib/clinic-settings';
import { useSucursales } from '../lib/sucursales'; import { useSucursales } from '../lib/sucursales';
import { todayLocal } from '../lib/utils'; import { todayLocal } from '../lib/utils';
import { useAutoRefresh, AutoRefreshBadge } from '../lib/autoRefresh'; import { useAutoRefresh, AutoRefreshBadge } from '../lib/autoRefresh';
@@ -36,6 +37,17 @@ import { useAutoRefresh, AutoRefreshBadge } from '../lib/autoRefresh';
// Duración por defecto cuando ni la cita ni el servicio la definen // Duración por defecto cuando ni la cita ni el servicio la definen
const DEFAULT_DURATION = 30; const DEFAULT_DURATION = 30;
// Color del badge según el estado de una notificación.
const notifBadge = (estado: string) => {
switch (estado) {
case 'enviada': return 'success' as const;
case 'simulada': return 'info' as const;
case 'pendiente': return 'pending' as const;
case 'error': return 'danger' as const;
default: return 'done' as const;
}
};
// Opciones de duración de una cita (el servicio propone la suya) // Opciones de duración de una cita (el servicio propone la suya)
const DURATION_OPTIONS = [ const DURATION_OPTIONS = [
{ value: '15', label: '15 min' }, { value: '15', label: '15 min' },
@@ -145,9 +157,10 @@ const QUARTER_TIME_OPTIONS = (() => {
const Agenda: FC = () => { const Agenda: FC = () => {
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const navigate = useNavigate(); const navigate = useNavigate();
const { user, canAgenda } = useAuth(); const { user, canAgenda, can } = useAuth();
const { term } = useGlosario(); const { term } = useGlosario();
const { sucursales, defaultCode } = useSucursales(); const { sucursales, defaultCode } = useSucursales();
const clinicSettings = useClinicSettings();
const { openPatient } = usePatientDrawer(); const { openPatient } = usePatientDrawer();
// Rol médico: agenda restringida a su propio hr.employee (filtro también en servidor). // Rol médico: agenda restringida a su propio hr.employee (filtro también en servidor).
// Cosmetología ve la agenda completa (es personal clínico, sin la privacidad // Cosmetología ve la agenda completa (es personal clínico, sin la privacidad
@@ -1218,12 +1231,41 @@ const Agenda: FC = () => {
const [saldoPaciente, setSaldoPaciente] = useState<PatientWallet | null>(null); const [saldoPaciente, setSaldoPaciente] = useState<PatientWallet | null>(null);
const [recargaAptOpen, setRecargaAptOpen] = useState(false); const [recargaAptOpen, setRecargaAptOpen] = useState(false);
const [reciboApt, setReciboApt] = useState<ReceiptData | null>(null); const [reciboApt, setReciboApt] = useState<ReceiptData | null>(null);
// Notificaciones ya registradas para la cita abierta (recordatorio/confirmación)
const notifActivo = !!clinicSettings.notificaciones?.activo;
const puedeEnviarNotif = can('notificaciones.enviar');
const [notifCita, setNotifCita] = useState<Notificacion[]>([]);
const [enviandoNotif, setEnviandoNotif] = useState(false);
const cargarNotifCita = (citaId: number) => {
if (!notifActivo) { setNotifCita([]); return; }
odooApi.getNotificaciones({ cita_id: citaId, limit: 50 })
.then((res) => setNotifCita(res.notificaciones || []))
.catch(() => setNotifCita([]));
};
const enviarRecordatorioAhora = async () => {
if (!detailApt) return;
setEnviandoNotif(true);
try {
const res = await odooApi.enviarNotificacion({ cita_id: detailApt.id });
toast.success(res.message || 'Recordatorio enviado');
cargarNotifCita(detailApt.id);
} catch (err) {
const msg = (err as { response?: { data?: { message?: string } } })?.response?.data?.message;
toast.error(msg || 'No se pudo enviar el recordatorio');
} finally {
setEnviandoNotif(false);
}
};
const openAptDetail = (apt: Appointment) => { const openAptDetail = (apt: Appointment) => {
setDetailApt(apt); setDetailApt(apt);
setSaldoPaciente(null); setSaldoPaciente(null);
setRecargaAptOpen(false); setRecargaAptOpen(false);
setStateHistory([]); setStateHistory([]);
setNotifCita([]);
cargarNotifCita(apt.id);
if (apt.patient_id) { if (apt.patient_id) {
odooApi.getPatientWallet(apt.patient_id) odooApi.getPatientWallet(apt.patient_id)
.then((w) => { if (w.status === 'success') setSaldoPaciente(w); }) .then((w) => { if (w.status === 'success') setSaldoPaciente(w); })
@@ -3116,6 +3158,36 @@ const Agenda: FC = () => {
)} )}
</div> </div>
</div> </div>
{notifActivo && (
<div className="p-3 bg-theme-bg rounded-xl">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="min-w-0 text-sm">
<p className="font-medium text-theme-heading">Recordatorios automáticos</p>
{notifCita.length === 0 ? (
<p className="text-xs text-theme-muted">Sin notificaciones registradas para esta cita.</p>
) : (
<>
<p className="text-xs text-theme-muted">
{notifCita.length} notificación(es) · última: {notifCita[0]?.estado_label}
</p>
<div className="mt-1 flex flex-wrap gap-1">
{notifCita.slice(0, 4).map((n) => (
<Badge key={n.id} variant={notifBadge(n.estado)}>
{n.tipo_label}: {n.estado_label}
</Badge>
))}
</div>
</>
)}
</div>
{puedeEnviarNotif && !['cancelled', 'no_show', 'done'].includes(detailApt.state) && (
<Button size="sm" variant="outline" loading={enviandoNotif} onClick={enviarRecordatorioAhora}>
<Bell size={16} className="mr-1.5" /> Enviar recordatorio ahora
</Button>
)}
</div>
</div>
)}
<div className="flex flex-wrap gap-2 pt-2 border-t border-theme-border"> <div className="flex flex-wrap gap-2 pt-2 border-t border-theme-border">
{puedeEditar && ( {puedeEditar && (
<Button size="sm" variant="outline" onClick={() => setEditMode(true)}> <Button size="sm" variant="outline" onClick={() => setEditMode(true)}>

View File

@@ -14,14 +14,18 @@ import SucursalesPanel from '../components/SucursalesPanel';
import CategoriasPanel from '../components/CategoriasPanel'; import CategoriasPanel from '../components/CategoriasPanel';
import TerminologiaPanel from '../components/TerminologiaPanel'; import TerminologiaPanel from '../components/TerminologiaPanel';
import ReservasOnlinePanel from '../components/ReservasOnlinePanel'; import ReservasOnlinePanel from '../components/ReservasOnlinePanel';
import NotificacionesPanel from '../components/NotificacionesPanel';
import NotificacionesEnviadasPanel from '../components/NotificacionesEnviadasPanel';
import { Card, Button, Input, PageHeader, toast } from '../components/ui'; import { Card, Button, Input, PageHeader, toast } from '../components/ui';
import { odooApi, type Doctor, type ClinicSettings, type ImpuestosSettings, BRAND_DEFAULT, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, RESERVAS_ONLINE_DEFAULT } from '../services/odoo'; import { odooApi, type Doctor, type ClinicSettings, type ImpuestosSettings, BRAND_DEFAULT, CLINIC_SETTINGS_DEFAULT, IMPUESTOS_DEFAULT, RESERVAS_ONLINE_DEFAULT, NOTIFICACIONES_DEFAULT } from '../services/odoo';
import { useAuth } from '../lib/auth'; import { useAuth } from '../lib/auth';
import { publicarClinicSettings } from '../lib/clinic-settings'; import { publicarClinicSettings } from '../lib/clinic-settings';
const TABS = [ const TABS = [
{ key: 'clinica', label: 'Clínica' }, { key: 'clinica', label: 'Clínica' },
{ key: 'reservas', label: 'Reservas en línea' }, { key: 'reservas', label: 'Reservas en línea' },
{ key: 'notificaciones', label: 'Notificaciones' },
{ key: 'notif_enviadas', label: 'Notificaciones enviadas' },
{ key: 'sucursales', label: 'Sucursales' }, { key: 'sucursales', label: 'Sucursales' },
{ key: 'categorias', label: 'Categorías' }, { key: 'categorias', label: 'Categorías' },
{ key: 'terminologia', label: 'Terminología' }, { key: 'terminologia', label: 'Terminología' },
@@ -34,7 +38,7 @@ const TABS = [
] as const; ] as const;
// Secciones que ve gerencia (admin del sistema ve todas). // Secciones que ve gerencia (admin del sistema ve todas).
const TABS_GERENCIA = ['reservas', 'sucursales', 'categorias', 'terminologia', 'usuarios', 'roles', 'tickets']; const TABS_GERENCIA = ['reservas', 'notificaciones', 'notif_enviadas', 'sucursales', 'categorias', 'terminologia', 'usuarios', 'roles', 'tickets'];
const SUBCATALOGOS = [ const SUBCATALOGOS = [
{ key: 'diagnosticos', label: 'Diagnósticos' }, { key: 'diagnosticos', label: 'Diagnósticos' },
@@ -55,6 +59,8 @@ const Configuracion: FC = () => {
usuarios: 'config.usuarios', usuarios: 'config.usuarios',
roles: 'config.roles', roles: 'config.roles',
auditoria: 'config.auditoria', auditoria: 'config.auditoria',
notificaciones: 'notificaciones.editar',
notif_enviadas: 'notificaciones.ver',
}; };
const tabsVisibles = useMemo( const tabsVisibles = useMemo(
() => (esAdmin ? TABS : TABS.filter((t) => TABS_GERENCIA.includes(t.key))) () => (esAdmin ? TABS : TABS.filter((t) => TABS_GERENCIA.includes(t.key)))
@@ -109,6 +115,7 @@ const Configuracion: FC = () => {
...next, ...next,
impuestos: { ...IMPUESTOS_DEFAULT, ...(next.impuestos || {}) }, impuestos: { ...IMPUESTOS_DEFAULT, ...(next.impuestos || {}) },
reservas_online: { ...RESERVAS_ONLINE_DEFAULT, ...(next.reservas_online || {}) }, reservas_online: { ...RESERVAS_ONLINE_DEFAULT, ...(next.reservas_online || {}) },
notificaciones: { ...NOTIFICACIONES_DEFAULT, ...(next.notificaciones || {}) },
}); });
}) })
.catch(() => { .catch(() => {
@@ -381,6 +388,18 @@ const Configuracion: FC = () => {
<ReservasOnlinePanel settings={settings} onSaved={setSettings} /> <ReservasOnlinePanel settings={settings} onSaved={setSettings} />
</Card.Body> </Card.Body>
</Card> </Card>
) : tab === 'notificaciones' ? (
<Card>
<Card.Body>
<NotificacionesPanel settings={settings} onSaved={setSettings} />
</Card.Body>
</Card>
) : tab === 'notif_enviadas' ? (
<Card>
<Card.Body>
<NotificacionesEnviadasPanel />
</Card.Body>
</Card>
) : ( ) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-4 sm:gap-6">
<Card> <Card>

View File

@@ -1013,6 +1013,7 @@ export interface ClinicSettings {
nota_ticket: string; nota_ticket: string;
impuestos: ImpuestosSettings; impuestos: ImpuestosSettings;
reservas_online: ReservasOnlineSettings; reservas_online: ReservasOnlineSettings;
notificaciones: NotificacionesSettings;
} }
// ---- Reservas en línea (portal público de auto-agendado) ---- // ---- Reservas en línea (portal público de auto-agendado) ----
@@ -1046,6 +1047,115 @@ export const RESERVAS_ONLINE_DEFAULT: ReservasOnlineSettings = {
telefono_contacto: '', telefono_contacto: '',
}; };
// ---- Notificaciones y recordatorios de citas ----
export type NotificacionCanal = 'whatsapp' | 'email';
export type NotificacionCanalConfig = NotificacionCanal | 'ambos';
export type NotificacionTipo = 'recordatorio' | 'confirmacion' | 'cumpleanos' | 'post_visita' | 'recuperacion' | 'manual';
export type NotificacionEstado = 'pendiente' | 'enviada' | 'simulada' | 'error' | 'cancelada';
export interface NotificacionesSettings {
/** Enciende recordatorios y notificaciones. Apagado = no se envía ni se encola nada. */
activo: boolean;
/** Modo prueba: registra "simulada" sin llamar a ningún proveedor externo. */
modo_prueba: boolean;
canal: NotificacionCanalConfig;
/** Ventanas (horas antes de la cita) en que se avisa. */
recordatorio_horas_antes: number[];
confirmacion_al_crear: boolean;
cumpleanos: boolean;
hora_envio_cumpleanos: string;
/** Horas tras la visita para el mensaje de seguimiento (0 = apagado). */
post_visita_horas: number;
recuperacion_no_show: boolean;
plantilla_recordatorio: string;
plantilla_confirmacion: string;
plantilla_cumpleanos: string;
plantilla_post_visita: string;
whatsapp_numero: string;
email_remitente: string;
}
export const NOTIFICACIONES_DEFAULT: NotificacionesSettings = {
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: '',
};
/** Variables permitidas en las plantillas (espejo del backend). */
export const NOTIF_VARIABLES = ['nombre', 'cita', 'servicio', 'fecha', 'hora', 'doctor', 'sucursal', 'telefono'] as const;
/** Ventanas sugeridas para los chips de recordatorio. */
export const NOTIF_VENTANAS: { horas: number; label: string }[] = [
{ horas: 1, label: '1 h' },
{ horas: 2, label: '2 h' },
{ horas: 24, label: '24 h' },
{ horas: 48, label: '48 h' },
{ horas: 168, label: '1 semana' },
];
export interface Notificacion {
id: number;
partner_id: number;
patient: string;
phone: string;
email: string;
cita_id: number | null;
cita_ref: string;
tipo: NotificacionTipo;
tipo_label: string;
canal: NotificacionCanal;
canal_label: string;
estado: NotificacionEstado;
estado_label: string;
asunto: string;
mensaje: string;
programada_para: string | null;
enviada_at: string | null;
error: string;
intentos: number;
}
export interface NotificacionesResponse {
status: string;
notificaciones: Notificacion[];
total: number;
page: number;
page_size: number;
total_pages: number;
resumen: Record<string, number>;
message?: string;
}
export interface ProcesarNotificacionesResult {
status: string;
message: string;
encoladas: number;
revisadas: number;
enviadas: number;
simuladas: number;
fallidas: number;
canceladas: number;
}
export interface ProbarNotificacionResult {
status: string;
ok: boolean;
simulado: boolean;
message: string;
}
// Contrato del portal público (endpoints /public/booking/*) // Contrato del portal público (endpoints /public/booking/*)
export interface BookingConfig { export interface BookingConfig {
activo: boolean; activo: boolean;
@@ -1129,6 +1239,7 @@ export const CLINIC_SETTINGS_DEFAULT: ClinicSettings = {
nota_ticket: '', nota_ticket: '',
impuestos: IMPUESTOS_DEFAULT, impuestos: IMPUESTOS_DEFAULT,
reservas_online: RESERVAS_ONLINE_DEFAULT, reservas_online: RESERVAS_ONLINE_DEFAULT,
notificaciones: NOTIFICACIONES_DEFAULT,
}; };
export interface FrontendUser { export interface FrontendUser {
@@ -1815,6 +1926,32 @@ export const odooApi = {
return data; return data;
}, },
// ---- Notificaciones y recordatorios de citas ----
async getNotificaciones(params?: Record<string, string | number>): Promise<NotificacionesResponse> {
const { data } = await axios.get(`${ODOO_BASE}/skeen/frontend/v1/notificaciones`, { params });
return data;
},
async procesarNotificaciones(limit?: number): Promise<ProcesarNotificacionesResult> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/notificaciones/procesar`, { limit });
return data;
},
async enviarNotificacion(payload: { cita_id: number; tipo?: string; canal?: NotificacionCanal }): Promise<{ status: string; notificaciones: Notificacion[]; simulado: boolean; message: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/notificaciones/enviar`, payload);
return data;
},
async reintentarNotificacion(id: number): Promise<{ status: string; estado: string; notificacion: Notificacion; message?: string }> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/notificaciones/${id}/reintentar`);
return data;
},
async probarNotificacion(payload: { canal: NotificacionCanal; destino?: string }): Promise<ProbarNotificacionResult> {
const { data } = await axios.post(`${ODOO_BASE}/skeen/frontend/v1/notificaciones/probar`, payload);
return data;
},
async updateRolePermissions(matrix: Record<string, FrontendRole[]>): Promise<{ status: string; matrix: Record<string, FrontendRole[]>; message?: string }> { async updateRolePermissions(matrix: Record<string, FrontendRole[]>): Promise<{ status: string; matrix: Record<string, FrontendRole[]>; message?: string }> {
const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/roles/permissions`, { matrix }); const { data } = await axios.put(`${ODOO_BASE}/skeen/frontend/v1/roles/permissions`, { matrix });
return data; return data;

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 json
import re
from urllib.parse import urlparse from urllib.parse import urlparse
SETTINGS_KEY = 'skeen.clinic.settings' 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.""" """Bloque de reservas en línea vigente (copia). Fuente única para la API."""
return normalizar_reservas_online(get_settings(env).get('reservas_online')) 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). # Horas de agenda de respaldo: el comportamiento de siempre (8:00-19:00).
AGENDA_START_DEFAULT = 8.0 AGENDA_START_DEFAULT = 8.0
AGENDA_END_DEFAULT = 19.0 AGENDA_END_DEFAULT = 19.0
@@ -303,6 +520,7 @@ def get_settings(env):
'nota_ticket': '', 'nota_ticket': '',
'impuestos': dict(IMPUESTOS_DEFAULT), 'impuestos': dict(IMPUESTOS_DEFAULT),
'reservas_online': dict(RESERVAS_ONLINE_DEFAULT), 'reservas_online': dict(RESERVAS_ONLINE_DEFAULT),
'notificaciones': normalizar_notificaciones(None),
} }
raw = env['ir.config_parameter'].sudo().get_param(SETTINGS_KEY) raw = env['ir.config_parameter'].sudo().get_param(SETTINGS_KEY)
if raw: if raw:
@@ -316,6 +534,7 @@ def get_settings(env):
out[k] = str(guardado[k]) out[k] = str(guardado[k])
out['impuestos'] = normalizar_impuestos(guardado.get('impuestos')) out['impuestos'] = normalizar_impuestos(guardado.get('impuestos'))
out['reservas_online'] = normalizar_reservas_online(guardado.get('reservas_online')) out['reservas_online'] = normalizar_reservas_online(guardado.get('reservas_online'))
out['notificaciones'] = normalizar_notificaciones(guardado.get('notificaciones'))
return out return out
@@ -336,6 +555,10 @@ def set_settings(env, vals):
bloque = validar_reservas_online(vals['reservas_online']) bloque = validar_reservas_online(vals['reservas_online'])
if bloque is not None: if bloque is not None:
actual['reservas_online'] = {**actual['reservas_online'], **bloque} 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( env['ir.config_parameter'].sudo().set_param(
SETTINGS_KEY, json.dumps(actual, ensure_ascii=False)) SETTINGS_KEY, json.dumps(actual, ensure_ascii=False))
return get_settings(env) return get_settings(env)

View File

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

View File

@@ -8118,5 +8118,194 @@ class SkeenFrontendController(http.Controller):
except Exception as e: except Exception as e:
return json_response({'status': 'error', 'message': str(e)}, 500) 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 # Importar fields para fechas
from odoo import fields from odoo import fields

View File

@@ -254,14 +254,25 @@ class SkeenPublicBookingController(http.Controller):
return cita return cita
def _encolar_confirmacion(self, 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 Solo actúa si `notificaciones.activo` y `confirmacion_al_crear` están
por WhatsApp/WACRM (usando `cita.wacrm_conversation_id` o el teléfono del encendidos; el motor decide si envía o simula (modo prueba). Nunca debe
paciente) cuando esas credenciales estén configuradas. Se deja el punto tumbar la reserva: cualquier fallo se registra y se ignora.
de extensión listo, sin acoplar el portal público a WhatsApp todavía.
""" """
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 # 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 config_audit
from . import wacrm_notes from . import wacrm_notes
from . import ticket_config 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.responder', 'Responder conversaciones'),
('whatsapp.asignar', 'Asignar conversaciones y leads'), ('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 y configuración', [
('config.usuarios', 'Administrar usuarios'), ('config.usuarios', 'Administrar usuarios'),
('config.roles', 'Administrar roles y menús'), ('config.roles', 'Administrar roles y menús'),
@@ -110,6 +115,7 @@ READ_PERM_KEYS = (
'reportes.ver', 'reportes.exportar', 'caja.ver', 'reportes.ver', 'reportes.exportar', 'caja.ver',
'ventas.reimprimir', 'ventas.reimprimir',
'whatsapp.ver', 'whatsapp.ver',
'notificaciones.ver',
) )
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -186,6 +192,11 @@ DEFAULT_PERM_ROLES = {
'whatsapp.ver': W, 'whatsapp.ver': W,
'whatsapp.responder': W, 'whatsapp.responder': W,
'whatsapp.asignar': 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 # Usuarios y configuración
'config.usuarios': AG, 'config.usuarios': AG,
'config.roles': 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_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_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_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