# 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 = <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 = <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" "<la URL>"
```

**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 = <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;
```
