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:
236
frontend/src/components/NotificacionesEnviadasPanel.tsx
Normal file
236
frontend/src/components/NotificacionesEnviadasPanel.tsx
Normal 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;
|
||||||
281
frontend/src/components/NotificacionesPanel.tsx
Normal file
281
frontend/src/components/NotificacionesPanel.tsx
Normal 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;
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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)}>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
21
odoo-addons/skeen_whatsapp/data/notificaciones_cron.xml
Normal file
21
odoo-addons/skeen_whatsapp/data/notificaciones_cron.xml
Normal 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>
|
||||||
@@ -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
|
||||||
|
|||||||
613
odoo-addons/skeen_whatsapp/models/notificacion.py
Normal file
613
odoo-addons/skeen_whatsapp/models/notificacion.py
Normal 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)}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
|
Reference in New Issue
Block a user