# Runbook de incidentes Qué hacer cuando algo falla. Ordenado por lo que reporta el usuario, no por la causa. --- ## «No veo un módulo que antes veía» O el opuesto: «puedo editar algo que no debería». **Casi siempre es una de dos cosas:** el usuario no volvió a iniciar sesión, o `role` y `role_id` quedaron desincronizados. ```sql -- 1. ¿Las dos columnas coinciden? SELECT u.id, u.username, u.role, u.role_id, r.slug AS rol_real FROM admin_users u LEFT JOIN roles r ON r.id = u.role_id WHERE u.username = 'usuario'; -- 2. ¿Qué le da ese rol? SELECT module_slug, permission FROM role_modules WHERE role_id = ; ``` Si los datos están bien → **que cierre sesión y vuelva a entrar**. Los permisos se cargan al iniciar sesión, no en cada petición. Si `role` y `role_id` no coinciden, actualice las dos: ```sql UPDATE admin_users SET role = 'lab_recepcion', role_id = (SELECT id FROM roles WHERE slug = 'lab_recepcion') WHERE id = ; ``` > Para dejar un módulo en solo lectura, lo que importa es `permission = 'read'`. Las columnas `can_edit`, `can_create` y demás **no** las lee el control de acceso. --- ## «Un enlace que enviamos por WhatsApp está roto» Primero, compruebe si el destino responde: ```bash curl -s -o /dev/null -w "%{http_code}\n" "" ``` **Si devuelve 503 o no resuelve**, el dominio está caído o cambió. Revise si el archivo existe en el dominio actual del sistema. Las URL que enviamos viven en lugares distintos según el caso: | Enlace | Dónde está definido | |---|---| | Botón de consentimiento | **En la plantilla de Meta**, no en el código | | Documento de términos | `terms_versions.documento_url` **y** `system_config.terms_message` | | Respaldo texto plano del consentimiento | Se arma con el dominio del servidor | > Si el enlace roto es el botón de una plantilla, **cambiar el código no lo arregla**. Hay que editar la plantilla en el WhatsApp Manager de Meta y esperar la reaprobación. Para el documento de términos, hay que cambiar **los dos** lugares a la vez: ```sql UPDATE terms_versions SET documento_url = REPLACE(documento_url, 'dominio.viejo', 'dominio.nuevo') WHERE documento_url LIKE '%dominio.viejo%'; UPDATE system_config SET config_value = REPLACE(config_value, 'dominio.viejo', 'dominio.nuevo') WHERE config_value LIKE '%dominio.viejo%'; ``` --- ## «El mensaje salió por el número equivocado» Las dos líneas comparten cuenta y token; lo único que cambia es el `phone_number_id`. Revise que el envío haya especificado el canal: ```php $wa = new WhatsAppService('turnero'); // no new WhatsAppService() ``` --- ## «LIA responde cortado» El asistente del dashboard del turnero tiene tope de salida. Si la respuesta se corta a media frase, ahora avisa con *«respuesta cortada por longitud»*. | Qué revisar | Dónde | |---|---| | Tope de tokens de salida | `modules/turnero/api/ai_chat.php`, `maxOutputTokens` | | Presupuesto consumido | `lab_config.lia_tokens_usados` (tope: 1.000.000) | | Clave configurada | `lab_config.gemini_api_key` | Si el presupuesto se agotó, LIA se bloquea y pide contactar a soporte. Para reiniciar el contador: ```sql UPDATE lab_config SET valor = '0' WHERE clave = 'lia_tokens_usados'; ``` > El contexto del día se manda completo en **cada** pregunta, así que el gasto por consulta es alto aunque la respuesta sea corta. --- ## «El formulario de tomas prolongadas muestra secciones que no corresponden» El formulario F-LAB-28 tiene secciones para todos los exámenes posibles y muestra solo las del examen del paciente. Si aparecen de más: 1. Verifique que el documento se abra con `&embed=1&compact=1` — sin esos parámetros no se aplica el filtrado. 2. Revise `_tomas_config` dentro de `datos_respuestas`: ahí queda qué ciclos se configuraron. --- ## «No aparece quién firmó una toma» Las firmas de tomas prolongadas registran el profesional **desde el 3 de agosto de 2026**. Los documentos firmados antes no tienen ese dato y **no es recuperable** — no quedó traza en ninguna tabla de auditoría. Para los nuevos, el nombre y la cédula se resuelven en el servidor desde la sesión de quien firma. Si aparece vacío en un documento reciente, compruebe que el usuario tenga cédula: ```sql SELECT id, username, full_name, cedula FROM admin_users WHERE id = ; ``` Los enfermeros la toman de `lab_enfermeras.numero_documento`; el resto de `admin_users.cedula`. --- ## «El bot no responde» | Revisar | Cómo | |---|---| | ¿Llegan los mensajes? | Tabla `webhook_logs` | | ¿Está en horario? | `BusinessHoursService` — fuera de horario responde distinto | | ¿La conversación quedó con un operador? | Estado en `conversations`; el bot no interrumpe una atención humana | | ¿Aceptó los términos? | `users.terms_accepted_at`; sin aceptar, el bot no avanza | --- ## «Un paciente quedó con una muestra pendiente» Cuando el paciente vuelve, las muestras pendientes **y rechazadas** de visitas anteriores aparecen automáticamente en la estación de toma de muestras, con la etiqueta *visita anterior* y los exámenes de aquella orden. Al recibirla queda registrado en qué turno se completó (`turnero_muestras.recibida_en_turno_id`), y ambos turnos quedan enlazados en el historial y en la bandeja. **El turno original no se modifica**: sigue finalizado como estaba. --- ## Consultas útiles ```sql -- Facturación real de hoy (solo turnos finalizados) SELECT ROUND(SUM(ts.total_cobrado)) AS facturado, COUNT(*) AS turnos FROM turnero_turnos t JOIN turnero_solicitudes ts ON ts.turno_id = t.id JOIN turnero_sesiones s ON s.id = t.sesion_id WHERE s.fecha = CURDATE() AND t.estado = 'finalizado' AND ts.total_cobrado > 0; -- Consentimientos sin firmar SELECT tc.estado, COUNT(*) FROM turnero_consentimientos tc JOIN turnero_turnos t ON t.id = tc.turno_id JOIN turnero_sesiones s ON s.id = t.sesion_id WHERE s.fecha = CURDATE() GROUP BY tc.estado; -- Muestras pendientes acumuladas por paciente SELECT p.nombre_completo, COUNT(*) AS pendientes FROM turnero_muestras tm JOIN turnero_solicitudes ts ON ts.id = tm.solicitud_id JOIN lab_pacientes p ON p.id = ts.paciente_id WHERE tm.estado IN ('pendiente','rechazada') GROUP BY p.id ORDER BY pendientes DESC LIMIT 20; ```