Documentación completa del proyecto en el módulo Soporte

21 documentos en cuatro secciones, escritos sobre el comportamiento real del
sistema —incluidos los casos que costaron diagnosticar esta semana.

Manual de usuario (visible para todos): primeros pasos, recepción, toma de
muestras, portal del enfermero y administración. Orientado a tareas concretas,
no a describir pantallas.

Documentación técnica: índice de módulos, turnero, formularios y firma digital,
WhatsApp y bot, domicilios, webhook (migrado de WEBHOOK_ENDPOINTS.md) e
inventario de endpoints.

Arquitectura: visión general, enrutamiento y registro de módulos, roles y
permisos, modelo de datos, integración con WhatsApp, y decisiones tomadas con
su deuda técnica asociada.

Operación: runbook de incidentes ordenado por síntoma, configuraciones críticas
—incluido qué vive en Meta y no en la base— y despliegue.

Se documentan explícitamente las trampas conocidas: role/role_id que hay que
mantener sincronizados, las columnas can_* que el control de acceso no lee, las
URL de plantilla que no se cambian desde el código, y las columnas históricas
que quedaron en NULL sin forma de recuperarlas.

README_DOCS.md apunta al módulo y explica cómo agregar páginas.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-08-04 10:14:32 -05:00
co-authored by Claude Opus 5
parent 018fb13332
commit 32c209710c
15 changed files with 1197 additions and 0 deletions
@@ -0,0 +1,41 @@
# Índice de módulos
Inventario de los módulos del sistema, generado del filesystem en cada carga.
{{modulos}}
## Cómo leer esta tabla
**Vistas** son las pantallas (`modules/<slug>/views/*.php`). **Endpoints** son los archivos que devuelven JSON (`modules/<slug>/api/*.php`).
Un módulo con **1 vista y 0 endpoints** suele ser un puente al sistema anterior: la vista solo incluye el archivo de la raíz, donde está el código real.
```php
// modules/lab_domicilios/views/index.php
require_once APP_ROOT . '/lab_domicilios.php';
```
**En SYSTEM_MODULES** indica si el módulo pasa por el control de permisos. Los que dicen «no» quedan accesibles para cualquier sesión — es el caso de módulos auxiliares que se consumen desde otras pantallas.
## Dónde está el código de verdad
| Módulo | Código real |
|---|---|
| `turnero` | En el módulo. Es el más grande y el más nuevo |
| `registro_exams` | En el módulo |
| `lab_examenes`, `medicos` | En el módulo |
| `lab_domicilios`, `lab_pacientes`, `lab_ordenes`, y demás `lab_*` | Archivo de la raíz; el módulo es un puente |
| `whatsapp` | `index.php` y `services/BotService.php` |
| `enfermero_portal` | `enfermero_portal.php` |
## Servicios y clases compartidas
{{servicios}}
## Detalle por módulo
- [Turnero](?m=soporte&v=documentacion&s=tecnica&d=turnero)
- [WhatsApp y bot](?m=soporte&v=documentacion&s=tecnica&d=whatsapp-bot)
- [Formularios](?m=soporte&v=documentacion&s=tecnica&d=formularios)
- [Domicilios](?m=soporte&v=documentacion&s=tecnica&d=domicilios)
- [Todos los endpoints](?m=soporte&v=documentacion&s=tecnica&d=endpoints)
+104
View File
@@ -0,0 +1,104 @@
# Módulo Turnero
El módulo más grande del sistema: 12 vistas y unos 70 endpoints. Gestiona la atención presencial completa.
## Vistas
| Vista | Para quién | Qué hace |
|---|---|---|
| `dashboard` | Admin, supervisor | Métricas del día, facturación, asistente LIA |
| `historial` | Admin, supervisor | Turnos de varios días, filtros, exportación |
| `bandeja` | Bacteriólogo, admin | Turnos del día con su detalle completo |
| `recepcion` | Recepcionista | Atención en el mostrador |
| `lugar` | Bacteriólogo | Estación de toma de muestras |
| `kiosko` | Público | El paciente saca su turno |
| `display_global` | Público | Pantalla de TV de la sala |
| `verificar_paciente` | Recepción | Consulta rápida de una ficha |
| `chat` | Recepción | Conversación de WhatsApp del turnero |
| `configuracion` | Admin | Lugares, dispositivos, plantillas, pantalla de TV |
`kiosko` y `display_global` son las **únicas rutas públicas** del sistema (`core/Router.php`): no hay quién inicie sesión en un televisor ni en el tótem de la entrada.
## Menú dinámico
`modules/turnero/module.php` no devuelve una lista fija: la arma según **el rol y la IP del equipo**.
```
recepcionista → chat, verificar paciente, TV, y su escritorio
bacteriólogo → bandeja y su estación
admin → todo
```
Si el equipo está en `turnero_dispositivos` (por IP o por cookie `turnero_token`), el usuario ve **solo su puesto**. Si no, los ve todos. Evita que alguien atienda desde el escritorio equivocado.
## Modelo de datos
```
turnero_sesiones un día de operación
└── turnero_turnos código, estado, tiempos, prioridad
├── turnero_solicitudes qué se hace y cuánto se cobró
│ ├── turnero_examen_items
│ └── turnero_muestras
├── turnero_consentimientos
└── turnero_comentarios
```
### Estados
```
espera → en_recepcion → en_espera_lugar → en_servicio → finalizado
ausente / cancelado
```
Solo `finalizado` cuenta como facturación real.
## Consentimientos
Se crean automáticamente desde **dos fuentes** que se acumulan:
| Fuente | Tabla |
|---|---|
| Por examen | `exam_tipo_consentimientos` |
| Por estación destino | `turnero_lugar_consentimientos` |
`get_consentimientos.php` los autocrea si faltan y calcula el estado de cada uno.
> En visitas de *solo entrega de muestras* no aplican los formularios por examen (no hay exámenes), pero **sí los de estación**. Por eso F-LAB-08 se exige igual.
## Tomas prolongadas
El formulario **F-LAB-28** cubre exámenes seriados. La lógica está repartida entre `ver_formulario_enviado.php` (render) y `modules/turnero/api/guardar_toma.php` (guardado).
- El esquema trae secciones para todos los exámenes posibles; se muestran solo las del examen marcado, mediante la `condicion` de cada separador.
- `_tomas_config` en `datos_respuestas` guarda qué ciclos se configuraron para ese paciente.
- Cada firma se guarda en su propio campo (`_tm00_f`, `_tm30_f`, …) junto con la hora y **la identidad de quien firmó**, resuelta en el servidor desde la sesión.
- Al firmar, el endpoint calcula cuándo toca la siguiente toma y actualiza `siguiente_toma_at`.
> La identidad se resuelve **en el servidor**, no se acepta del cliente: cada toma puede firmarla un profesional distinto y esa es la única fuente confiable. Las firmas anteriores al 3 de agosto de 2026 no tienen ese dato y no es recuperable.
## Muestras entre visitas
Una muestra que queda `pendiente` o `rechazada` reaparece cuando el paciente vuelve, con la bandera `es_pendiente_anterior` y los exámenes de la orden original.
Al recibirla, `turnero_muestras.recibida_en_turno_id` registra en qué turno se completó. Historial y bandeja usan ese dato para enlazar ambos turnos en los dos sentidos.
**El turno original no se modifica**: sigue finalizado. Solo se agrega la trazabilidad.
## LIA
`api/ai_chat.php` — asistente sobre Gemini Flash.
| Aspecto | Detalle |
|---|---|
| Contexto | Se arma en cada llamada con hasta 60 turnos del día y sus detalles |
| Historial | Los últimos intercambios se envían para que entienda preguntas de seguimiento |
| Tope de salida | `maxOutputTokens`; si Gemini corta, se avisa con `finishReason` |
| Presupuesto | `lab_config.lia_tokens_usados` contra `LIA_TOKENS_MAX` |
Se contabiliza `totalTokenCount`, que **incluye el contexto de entrada**. Como el contexto va completo en cada pregunta, el gasto por consulta es alto aunque la respuesta sea breve.
## Endpoints propios
Los del turnero usan `api/_helpers.php`, que provee `db()`, `inputJson()`, `jsonOk()`, `jsonError()`, `adminId()`, `requireTurnero()` y `notificarSSE()`.
`notificarSSE()` avisa a las pantallas conectadas para que se refresquen sin recargar.
@@ -0,0 +1,104 @@
# Formularios y firma digital
Cómo se definen los formularios, cómo se envían y cómo se firman. Es transversal: lo usan el turnero, los domicilios y los envíos sueltos.
## Definición
Un formulario es una fila en `lab_formularios`. Su estructura está en la columna `esquema`, un JSON con la lista de campos:
```json
[
{"id": "_sep1", "tipo": "separador", "label": "Datos del paciente"},
{"id": "_nom", "tipo": "linked", "linked_key": "nombre_completo", "label": "Nombre"},
{"id": "_sint", "tipo": "checkbox", "label": "Síntomas", "options": ["Fiebre", "Tos"]},
{"id": "_fir", "tipo": "firma_profesional", "label": "Firma del profesional"}
]
```
### Tipos de campo
| Tipo | Qué es |
|---|---|
| `separador` | Encabezado de sección; admite `condicion` |
| `parrafo` | Texto fijo (consentimientos, notas legales) |
| `texto`, `textarea`, `numero` | Entrada libre |
| `fecha`, `fecha_hoy`, `hora` | Fechas y horas |
| `radio`, `checkbox`, `select` | Opciones |
| `linked` | Se autocompleta con un dato del paciente vía `linked_key` |
| `firma` | Firma del paciente |
| `firma_profesional` | Firma del profesional |
### Secciones condicionales
Un separador puede depender de otro campo:
```json
{"id": "_sep_ins", "tipo": "separador", "label": "Insulina · Minuto 0",
"condicion": {"campo_id": "_examen", "valores": ["Insulina"]}}
```
La sección y **todos sus campos** se ocultan si la condición no se cumple. Los campos heredan el estado mediante el atributo `data-sep-id`.
## Las dos vías de envío
Un mismo formulario se firma por dos caminos, con tablas y tokens distintos:
| Vía | Tabla | Token | Respuestas |
|---|---|---|---|
| Turnero | `turnero_consentimientos` | UUID → `?token=` | `datos_respuestas` |
| Domicilios y envíos | `lab_form_envios` | 64 hex → `?t=` | `datos_cliente` |
Las dos las muestra `ver_formulario_enviado.php`, que distingue **por el formato del token**. De ahí que existan dos parámetros para lo que parece lo mismo.
`form_cliente.php` detecta tokens con formato UUID y redirige a `ver_formulario_enviado.php` — necesario porque la plantilla de WhatsApp aprobada en Meta apunta a la primera página.
## Parámetros del visor
| Parámetro | Efecto |
|---|---|
| `token` | Consentimiento del turnero (UUID) |
| `t` | Envío de formulario (64 hex) |
| `id` | Acceso interno con sesión |
| `embed=1` | Modo embebido; **activa el filtrado de secciones** |
| `compact=1` | Grilla de campos y tarjetas de toma |
| `zoom` | Escala |
| `autoprint=1` | Abre el diálogo de impresión |
> `embed=1` no es cosmético: sin él no se aplica el filtrado de secciones de tomas prolongadas y el documento muestra secciones que no corresponden. Bandeja e historial lo pasan siempre.
## Firma
### Del paciente
Se dibuja en un canvas y se guarda como imagen en `datos_cliente['<campo>_svg']`. También se admite pad biométrico Topaz.
### Del profesional
Se dibuja igual, pero además queda **quién firmó**. Hay tres endpoints según el contexto:
| Endpoint | Contexto |
|---|---|
| `modules/turnero/api/guardar_toma.php` | Tomas prolongadas — una firma por toma |
| `modules/turnero/api/firmar_profesional_consentimiento.php` | Consentimientos del turnero |
| `api/lab/firmar_profesional.php` | Envíos de formularios |
La identidad se guarda como `_pro_nombre` y `_pro_cedula`; en tomas prolongadas, además por campo (`_tm30_f_pro_nombre`), porque cada toma puede firmarla alguien distinto.
De dónde sale la identidad:
```
admin_users.cedula → personal en general
lab_enfermeras.numero_documento → enfermeros (vía admin_users.enfermera_id)
```
> Al mostrar un documento firmado **no se usa un valor por defecto**: si no quedó guardado quién firmó, se muestra vacío. Antes se caía al usuario de la sesión actual, lo que atribuía la firma a quien simplemente estaba mirando el documento.
## Precarga desde una visita anterior
`modules/turnero/api/get_formulario_anterior.php` devuelve las respuestas del último formulario firmado del mismo paciente, para no reescribir la historia clínica en cada visita.
Excluye deliberadamente firmas e identidad del profesional anterior: cada visita se firma de nuevo, con la fecha de hoy y quien atienda.
## Diseñador
`lab_formulario_builder.php` permite armar el esquema desde la interfaz, sin escribir JSON a mano.
@@ -0,0 +1,90 @@
# WhatsApp y bot
El sistema nació como bot de WhatsApp y esa integración sigue siendo central.
## Servicios
{{servicios}}
## `WhatsAppService`
Envuelve la Cloud API de Meta. Se elige la línea al construirlo:
```php
$wa = new WhatsAppService(); // principal
$wa = new WhatsAppService('turnero'); // turnero
```
Ambas líneas comparten cuenta (WABA) y token; **lo único que cambia es el `phone_number_id`**. Si un mensaje sale por la línea equivocada, casi siempre falta el argumento.
Métodos principales:
```php
$wa->sendTextMessage($telefono, $texto, $meta);
$wa->sendTemplateMessage($telefono, $plantilla, $idioma, [], [], $componentes, $meta);
```
`$meta` acompaña el registro del mensaje: `['canal' => 'turnero', 'operator_id' => adminId()]`.
## Plantillas
Fuera de la ventana de 24 horas hay que usar plantilla aprobada. `message_templates` guarda una copia local con sus `components`, pero **la copia no manda**: la versión real vive en Meta.
Consecuencia importante:
> Las URL de los botones están **en la plantilla**, no en el código. Nuestro código solo envía los parámetros (`{{1}}`). Cambiar el código no altera el enlace que recibe el paciente.
Ejemplo — botón de `consentimiento_turno_v2`:
```
https://erp.laboratorioximenacaicedo.com/form_cliente.php?t={{1}}
```
Y así se arman los componentes al enviar:
```php
$rawComps = [
['type' => 'body', 'parameters' => [['type' => 'text', 'text' => $codigo]]],
['type' => 'button', 'sub_type' => 'url', 'index' => '0',
'parameters' => [['type' => 'text', 'text' => $token]]],
];
```
### Respaldo
Si el envío por plantilla falla, se manda un texto plano con el enlace armado desde el dominio del servidor. Ese texto **no** pasa por Meta, así que su URL puede diferir de la del botón.
## `BotService`
Decide qué hacer con cada mensaje entrante:
1. ¿Aceptó los términos? Si no, se los pide y no avanza.
2. ¿Está en horario? (`BusinessHoursService`)
3. ¿La conversación la tomó un operador humano? El bot no interrumpe.
4. Si no, responde según el estado de la conversación (`ConversationStateService`) y el menú (`MenuService`).
## Términos y condiciones
| Tabla | Contenido |
|---|---|
| `terms_versions` | Versión activa, URL del documento, mensajes |
| `terms_acceptance` | Historial de aceptaciones |
| `system_config.terms_message` | Texto de bienvenida, **repite la URL dentro** |
Se vuelve a pedir la aceptación si nunca aceptó, si pasaron más de 6 meses, o si hay versión nueva con `forzar_reenvio`.
> La URL del documento está en **dos** lugares. Cambiar solo uno deja al otro sirviendo un enlace viejo.
## Configuración
Todo en `system_config`: `whatsapp_token`, `whatsapp_api_url`, `whatsapp_business_account_id`, `whatsapp_phone_number_id`, `whatsapp_phone_number_id_turnero`, `webhook_verify_token`.
## Diagnóstico
| Síntoma | Dónde mirar |
|---|---|
| Sale por el número equivocado | Falta `'turnero'` en el constructor |
| Enlace roto en un botón | La plantilla en Meta |
| No llega ninguna plantilla | Estado de aprobación en WhatsApp Manager |
| Llega texto plano en vez de plantilla | El respaldo actuó: la plantilla falló |
| El bot no responde | `webhook_logs`, horario, estado de la conversación |
@@ -0,0 +1,65 @@
# Domicilios
Visitas domiciliarias: agendamiento, asignación de enfermeros y seguimiento.
## Dónde está el código
Es de la generación anterior. El módulo es un puente:
```php
// modules/lab_domicilios/views/index.php
require_once APP_ROOT . '/lab_domicilios.php';
```
| Archivo | Rol |
|---|---|
| `lab_domicilios.php` | Pantalla administrativa |
| `enfermero_portal.php` | Portal del enfermero, pensado para celular |
| `classes/lab/Domicilio.php` | Reglas de negocio |
| `classes/lab/Asignacion.php` | Vínculo enfermerodomicilio |
| `api/lab/*.php` | Endpoints |
## Datos
```
lab_domicilios
├── lab_asignaciones qué enfermero atiende
├── lab_domicilio_notas seguimiento, admite adjuntos
└── lab_domicilio_pagos cobros
```
Estados: `programado``en_curso``completado`, o `cancelado`.
## Permisos
Se combinan dos niveles.
**Nivel módulo**`hasModuleWrite('lab_domicilios')` decide si aparecen los botones de crear y editar. Un rol con `permission = 'read'` ve la pantalla sin poder modificar.
**Nivel registro** — un enfermero solo puede editar domicilios **que creó o que tiene asignados**. Se verifica en el servidor (`api/lab/save_domicilio.php`):
```sql
SELECT d.creado_por,
(SELECT COUNT(*) FROM lab_asignaciones a
WHERE a.domicilio_id = d.id AND a.enfermera_id = ?) AS asignado
FROM lab_domicilios d WHERE d.id = ?
```
> No alcanza con ocultar el botón en la vista: el endpoint verifica por su cuenta. Cualquier endpoint que modifique datos debe hacer lo mismo.
## Portal del enfermero
`enfermero_portal.php` admite `admin`, `superadmin` y `enfermero`. Los administradores pueden ver el portal de un enfermero concreto pasando `?eid=<id>`, útil para dar soporte.
Incluye:
- Agenda propia con creación y edición
- Notas de ficha y notas libres, con fotos y archivos
- Envío de formularios al paciente, o copia del enlace
- Enlace a WhatsApp con el mensaje ya redactado, presentando al profesional como parte del Laboratorio Ximena Caicedo
## Formularios
Los domicilios usan la vía `lab_form_envios` (token de 64 hex, parámetro `?t=`), a diferencia del turnero que usa UUID. Ver [Formularios](?m=soporte&v=documentacion&s=tecnica&d=formularios).
Cuando el enfermero firma, su nombre y cédula salen de `lab_enfermeras` a través de `admin_users.enfermera_id`.
+166
View File
@@ -0,0 +1,166 @@
# Webhook de WhatsApp
_Migrado de `WEBHOOK_ENDPOINTS.md` (raíz del repositorio), donde vivía suelto._
## Endpoint principal
```
URL: /api/webhook.php
```
---
## GET — Verificación de webhook
```
GET /api/webhook.php?hub.mode=subscribe&hub.verify_token=TOKEN&hub.challenge=CHALLENGE
```
### Parámetros que envía Meta
| Parámetro | Valor esperado |
|---|---|
| `hub.mode` | `subscribe` |
| `hub.verify_token` | El token configurado en `system_config.webhook_verify_token` |
| `hub.challenge` | Número aleatorio que debe devolverse tal cual |
### ⚠️ Bug conocido
El código lee `$_GET['hub_verify_token']` (con guión bajo), pero PHP convierte los puntos a guiones bajos automáticamente al parsear `$_GET`, por lo que **funciona correctamente**.
### Respuesta exitosa
```
HTTP 200
Body: {challenge}
```
### Respuesta fallida
```
HTTP 403
Body: {"error":"Token de verificación inválido"}
```
---
## POST — Recepción de eventos
```
POST /api/webhook.php
Content-Type: application/json
```
### Estructura del payload esperado (Meta Cloud API)
```json
{
"object": "whatsapp_business_account",
"entry": [{
"id": "WABA_ID",
"changes": [{
"field": "messages",
"value": {
"messaging_product": "whatsapp",
"metadata": {
"phone_number_id": "PHONE_NUMBER_ID"
},
"contacts": [{
"wa_id": "573001234567",
"profile": { "name": "Nombre Contacto" }
}],
"messages": [{
"from": "573001234567",
"id": "wamid.XXX",
"timestamp": "1234567890",
"type": "text",
"text": { "body": "Hola" }
}]
}
}]
}]
}
```
### Tipos de mensaje soportados
| `type` | Descripción |
|---|---|
| `text` | Texto plano |
| `image` | Imagen (con caption opcional) |
| `audio` | Audio / nota de voz |
| `video` | Video |
| `document` | Documento / PDF |
| `sticker` | Sticker |
| `reaction` | Reacción emoji a otro mensaje |
| `interactive` | Respuesta de lista o botón |
### El campo `field` del change puede ser
- `messages` → mensajes entrantes y estados
- `conversations` → alias aceptado también
### Eventos de estado (statuses)
```json
"statuses": [{
"id": "wamid.XXX",
"status": "sent|delivered|read|failed",
"recipient_id": "573001234567"
}]
```
### Respuesta exitosa
```
HTTP 200
Body: {"status":"success"}
```
---
## Configuración necesaria en `system_config` (BD)
| config_key | Descripción |
|---|---|
| `whatsapp_token` | Access Token de Meta |
| `whatsapp_phone_number_id` | Phone Number ID de la línea |
| `webhook_verify_token` | Token de verificación del webhook |
| `whatsapp_api_url` | `https://graph.facebook.com/v22.0/` |
---
## Variables de entorno equivalentes (`.env`)
```env
WHATSAPP_TOKEN=
WHATSAPP_PHONE_NUMBER_ID=
WEBHOOK_VERIFY_TOKEN=
WHATSAPP_API_URL=https://graph.facebook.com/v22.0/
DB_HOST=
DB_PORT=3306
DB_NAME=
DB_USER=
DB_PASS=
```
---
## Tablas BD que usa el webhook
| Tabla | Uso |
|---|---|
| `users` | Crea o busca usuario por `phone_number` |
| `conversations` | Guarda cada mensaje (deduplicado por `message_id`) |
| `webhook_logs` | Registra el payload crudo de cada POST |
| `notifications` | Crea aviso de nuevo mensaje entrante |
| `media_queue` | Encola media que no pudo descargarse en el momento |
| `system_config` | Lee tokens y configuración |
---
## Seguridad — pendiente de implementar
- No valida la firma `X-Hub-Signature-256` en el POST.
- Se recomienda agregar antes de procesar:
```php
$signature = $_SERVER['HTTP_X_HUB_SIGNATURE_256'] ?? '';
$expected = 'sha256=' . hash_hmac('sha256', $input, APP_SECRET);
if (!hash_equals($expected, $signature)) {
http_response_code(401);
exit;
}
```
@@ -0,0 +1,41 @@
# Endpoints
Inventario de los endpoints de todos los módulos, generado del filesystem en cada carga. La descripción sale del comentario de cabecera de cada archivo.
## Convenciones
Los endpoints son archivos PHP sueltos que devuelven JSON. **No pasan por el enrutador**: se invocan por su ruta real.
```
modules/turnero/api/get_historial.php
api/lab/save_domicilio.php
```
Cada módulo tiene su `api/_helpers.php` con lo común. Los archivos que empiezan con guión bajo son internos y no se listan acá.
### Helpers típicos
| Función | Qué hace |
|---|---|
| `db()` | Conexión PDO |
| `inputJson()` | Cuerpo de la petición como arreglo |
| `jsonOk($datos)` | Respuesta correcta |
| `jsonError($msg, $codigo)` | Error con código HTTP |
| `adminId()` | Id del usuario de la sesión |
| `requireMethod('POST')` | Corta si el método no coincide |
| `requireTurnero()` | Corta si no tiene acceso al turnero |
### Reglas al agregar uno
1. **Verificá permisos en el propio endpoint.** Que la vista haya ocultado el botón no protege nada.
2. **Resolvé la identidad en el servidor.** Para saber quién hace una acción, usá `adminId()`, no un valor que mande el navegador.
3. **Consultas preparadas siempre.**
4. **Dejá un comentario de cabecera** describiendo qué hace y qué recibe: es lo que aparece en la tabla de abajo.
## Inventario
{{endpoints}}
## Endpoints fuera de módulos
`api/lab/` agrupa los del laboratorio de la generación anterior — domicilios, pacientes, formularios, configuración. Siguen las mismas convenciones y usan `api/lab/_helpers.php`.