Módulo Soporte: visor de documentación con renderizado Markdown y buscador

Nuevo módulo `soporte` con la documentación del proyecto en cuatro secciones:
manual de usuario (visible para todos), y documentación técnica, arquitectura
y operación (solo administradores).

- Markdown.php: renderizador propio del subconjunto que usa la documentación
  (encabezados, listas anidadas, tablas, código, citas). Escapa todo el texto
  antes de aplicar formato, así que los .md no pueden inyectar HTML. Se
  prefirió un archivo auditable a incorporar una dependencia externa.
- DocIndex.php: descubre los .md, arma el árbol, resuelve acceso por sección
  y construye el índice del buscador.
- Generadores.php: expande marcadores {{modulos}}, {{endpoints}}, {{tablas}},
  {{roles}} y {{servicios}} leyendo el código y la base en cada carga, para
  que los inventarios no puedan quedar desactualizados.

Se registra en SYSTEM_MODULES y se concede a los 12 roles con permission=read.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-04 10:07:06 -05:00
co-authored by Claude Opus 5
parent 8948ea67d1
commit 018fb13332
13 changed files with 1644 additions and 0 deletions
@@ -0,0 +1,167 @@
# 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, actualizá 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, comprobá 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ó. Revisá 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`. Revisá 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. Verificá que el documento se abra con `&embed=1&compact=1` — sin esos parámetros no se aplica el filtrado.
2. Revisá `_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, comprobá 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;
```
@@ -0,0 +1,88 @@
# Configuraciones críticas
Dónde vive cada cosa que se configura. La pregunta que más tiempo hace perder es *«¿esto dónde se cambia?»*, sobre todo porque no todo está en la base de datos.
## Las tres tablas de configuración
| Tabla | Contenido | Se edita desde |
|---|---|---|
| `system_config` | Credenciales de WhatsApp, webhook, mensaje de términos | Base de datos |
| `lab_config` | Datos de la empresa, encabezados de documentos, clave y consumo de LIA | Configuración del laboratorio |
| `turnero_*` | Lugares, prioridades, dispositivos, playlist de TV | Configuración del turnero |
## Lo que NO está en la base de datos
Esto es lo que más confunde:
| Configuración | Dónde vive de verdad |
|---|---|
| URL del botón de consentimiento | **Plantilla en el WhatsApp Manager de Meta** |
| Texto y formato de las plantillas | **Meta** (`message_templates` es solo una copia) |
| Dominio del sistema | Se deduce del `HTTP_HOST` de cada petición |
> Cambiar el código **no** cambia la URL que reciben los pacientes en el botón de una plantilla. Eso se edita en Meta y requiere reaprobación.
## Datos de la empresa
En `lab_config`, salen impresos en el encabezado de todos los documentos:
| Clave | Ejemplo |
|---|---|
| `empresa_nombre` | XIMENA CAICEDO G. E.U |
| `empresa_subtitulo` | Laboratorio Hematológico |
| `empresa_direccion` | Calle 21 #0A-26, Barrio Blanco |
| `empresa_ciudad` | Cúcuta, Norte de Santander |
| `empresa_telefono` | +57 305 337 0116 |
| `empresa_email` | servicioalcliente@laboratorioximenacaicedo.com |
| `doc_logo_base64` | Logo embebido |
| `doc_color` | Color de encabezados |
Se editan desde **Configuración del laboratorio**, sin tocar código.
## Dominio del sistema
`APP_URL` y `BASE_URL` se calculan en cada petición a partir del host (`config/config.php`):
```php
$__host = $_SERVER['HTTP_HOST'] ?? 'localhost';
define('APP_URL', $__proto . '://' . $__host);
```
Detecta HTTPS detrás de proxy reverso mediante `X-Forwarded-Proto`.
> Consecuencia: si alguien entra por una IP o un dominio alternativo, **los enlaces que se generen en esa sesión llevarán esa dirección** — y quedan guardados así en el mensaje que recibe el paciente. Si eso importa, conviene fijar `APP_URL` explícitamente.
## Turnero
| Qué | Dónde |
|---|---|
| Escritorios y estaciones | `turnero_lugares` |
| Formularios obligatorios por estación | `turnero_lugar_consentimientos` |
| Formularios obligatorios por examen | `exam_tipo_consentimientos` |
| Equipos fijos por IP o token | `turnero_dispositivos` |
| Prioridades de la cola | `turnero_prioridades` |
| Playlist de la pantalla de TV | `turnero_tv_media` |
Las estaciones de toma de muestras exigen el formulario **Datos Toma de Muestras (F-LAB-08)**, incluso en visitas marcadas como *solo entrega de muestras*.
## LIA
| Clave | Qué es |
|---|---|
| `lab_config.gemini_api_key` | Clave de la API de Google Gemini |
| `lab_config.lia_tokens_usados` | Consumo acumulado (tope: 1.000.000) |
El tope está en el código como `LIA_TOKENS_MAX`.
## Términos y condiciones
En **dos** lugares que hay que mantener sincronizados:
- `terms_versions` — versión activa, URL del documento, mensajes de aceptación y rechazo
- `system_config.terms_message` — el texto de bienvenida, que **repite la URL** dentro
## Cambios de esquema
Van en `migrations/`, con nombre `AAAAMMDD_descripcion.sql` e idempotentes (`IF NOT EXISTS`, guardas en `UPDATE`/`INSERT`).
Si aplicás un cambio directo en producción, **dejá también la migración**: sin ella, un entorno nuevo no tendrá ese cambio y nadie se va a enterar hasta que falle.