Compare commits
39
Commits
+5
-518
@@ -1,522 +1,9 @@
|
||||
# Documentación del Sistema de Laboratorio
|
||||
# Obsoleto
|
||||
|
||||
> **Sistema**: Laboratorio Clínico — Módulos de Agendamiento y Formularios
|
||||
> **Última actualización**: 27/03/2026
|
||||
Este documento quedó desactualizado y se conserva solo por historial de git.
|
||||
|
||||
---
|
||||
La documentación vigente está **dentro del sistema**, en el módulo Soporte:
|
||||
|
||||
## Tabla de contenidos
|
||||
/erp.php?m=soporte&v=documentacion
|
||||
|
||||
1. [Módulo de Agendamiento (Domicilios)](#1-módulo-de-agendamiento-domicilios)
|
||||
- [¿Qué es?](#qué-es)
|
||||
- [Archivos involucrados](#archivos-involucrados)
|
||||
- [Base de datos](#base-de-datos)
|
||||
- [Roles y permisos](#roles-y-permisos)
|
||||
- [Flujo de estados](#flujo-de-estados)
|
||||
- [Funcionalidades](#funcionalidades)
|
||||
- [API Endpoints](#api-endpoints)
|
||||
2. [Módulo de Formularios](#2-módulo-de-formularios)
|
||||
- [¿Qué es?](#qué-es-1)
|
||||
- [Archivos involucrados](#archivos-involucrados-1)
|
||||
- [Base de datos](#base-de-datos-1)
|
||||
- [Roles y permisos](#roles-y-permisos-1)
|
||||
- [Flujo completo](#flujo-completo)
|
||||
- [Tipos de campos del Builder](#tipos-de-campos-del-builder)
|
||||
- [Firma digital](#firma-digital)
|
||||
- [Enlace público y vigencia](#enlace-público-y-vigencia)
|
||||
- [PDF y visualización del documento](#pdf-y-visualización-del-documento)
|
||||
- [Sello de integridad SHA-256](#sello-de-integridad-sha-256)
|
||||
- [Firma del profesional](#firma-del-profesional)
|
||||
- [API Endpoints](#api-endpoints-1)
|
||||
|
||||
---
|
||||
|
||||
## 1. Módulo de Agendamiento (Domicilios)
|
||||
|
||||
### ¿Qué es?
|
||||
|
||||
El módulo de agendamiento gestiona los **servicios de toma de muestras a domicilio**. Permite crear, asignar, seguir y completar visitas médicas domiciliarias. Los administradores gestionan la agenda desde `lab_domicilios.php`; los enfermeros gestionan su propia agenda desde `enfermero_portal.php`.
|
||||
|
||||
---
|
||||
|
||||
### Archivos involucrados
|
||||
|
||||
| Archivo | Descripción |
|
||||
|---|---|
|
||||
| `lab_domicilios.php` | Vista principal del admin — tabla con filtros, detalle del domicilio, formularios recibidos, exportar CSV |
|
||||
| `enfermero_portal.php` | Portal exclusivo del enfermero — su agenda personal del día, ordenada por hora, con tarjetas colapsables |
|
||||
| `lab_enfermeras.php` | CRUD del personal de enfermería |
|
||||
| `classes/lab/Domicilio.php` | Clase ORM — crear, editar, cambiar estado, estadísticas |
|
||||
| `classes/lab/Enfermera.php` | Clase ORM — CRUD, agenda por enfermero, carga de trabajo |
|
||||
| `classes/lab/Asignacion.php` | Clase ORM — asignar / reasignar / liberar enfermero a domicilio |
|
||||
| `api/lab/get_domicilios.php` | GET — lista de domicilios con filtros y estadísticas del día |
|
||||
| `api/lab/save_domicilio.php` | POST — crear, actualizar o cambiar estado |
|
||||
| `api/lab/update_domicilio_enfermero.php` | POST — el enfermero avanza el estado desde su portal |
|
||||
| `api/lab/my_agenda.php` | GET — agenda del enfermero actualmente autenticado |
|
||||
| `api/lab/save_asignacion.php` | POST — asignar o reasignar enfermero |
|
||||
| `api/lab/get_asignaciones.php` | GET — asignaciones por fecha |
|
||||
| `api/lab/registrar_pago.php` | POST — registrar pago de un domicilio |
|
||||
| `api/lab/save_servicio_extra.php` | POST — agregar servicio realizado durante la visita |
|
||||
| `api/lab/get_notas_domicilio.php` | GET — notas clínicas y libres del domicilio |
|
||||
| `api/lab/upload_nota_imagen.php` | POST — subir imagen adjunta a una nota |
|
||||
| `api/lab/crear_desde_whatsapp.php` | GET/POST — crear paciente u orden desde una conversación de WhatsApp activa |
|
||||
|
||||
---
|
||||
|
||||
### Base de datos
|
||||
|
||||
#### Tabla `lab_domicilios`
|
||||
|
||||
Tabla principal. Cada fila es un servicio a domicilio.
|
||||
|
||||
| Campo | Tipo | Descripción |
|
||||
|---|---|---|
|
||||
| `id` | INT PK | Identificador único |
|
||||
| `paciente_id` | INT FK | Paciente al que se le realiza el servicio |
|
||||
| `orden_id` | INT FK NULL | Orden médica adjunta (opcional) |
|
||||
| `direccion` | TEXT | Dirección completa de la visita |
|
||||
| `ciudad` | VARCHAR(100) | Ciudad |
|
||||
| `barrio` | VARCHAR(100) | Barrio |
|
||||
| `indicaciones_dir` | TEXT | Referencias o indicaciones adicionales ("apto 302, tocar campanilla") |
|
||||
| `fecha_programada` | DATE | Fecha de la visita |
|
||||
| `hora_programada` | TIME | Hora de la visita |
|
||||
| `tipo_servicio` | VARCHAR(100) | Tipo de servicio (toma de muestra, etc.) |
|
||||
| `tipo_cliente` | ENUM | `particular` / `seguro` / `eps` |
|
||||
| `examenes_solicitados` | TEXT | Lista de exámenes (cuando no hay orden médica) |
|
||||
| `seguro_nombre` | VARCHAR | Nombre del seguro o EPS |
|
||||
| `autorizacion` | VARCHAR | Número de autorización |
|
||||
| `valor_domicilio` | DECIMAL | Valor del servicio |
|
||||
| `valor_copago` | DECIMAL | Copago a cargo del cliente |
|
||||
| `copago_laboratorio` | DECIMAL | Copago al laboratorio |
|
||||
| `pago_estado` | ENUM | `pending` / `pagado` / `exento` |
|
||||
| `pago_modo` | ENUM | `efectivo` / `transferencia` / `otro` |
|
||||
| `pago_monto` | DECIMAL | Monto pagado |
|
||||
| `pago_fecha` | DATETIME | Fecha del pago |
|
||||
| `pago_notas` | TEXT | Notas sobre el pago |
|
||||
| `estado` | ENUM | Ver [Flujo de estados](#flujo-de-estados) |
|
||||
| `motivo_cancelacion` | TEXT | Motivo si fue cancelado (obligatorio) |
|
||||
| `fecha_reprogramada` | DATE | Nueva fecha si fue reprogramado |
|
||||
| `hora_llegada` | TIME | Registrada automáticamente al iniciar la visita |
|
||||
| `hora_salida` | TIME | Registrada automáticamente al completar |
|
||||
| `observaciones` | TEXT | Observaciones del resultado de la visita |
|
||||
| `muestras_tomadas` | TEXT | Lista de muestras obtenidas |
|
||||
| `notas_admin` | TEXT | Notas internas del equipo administrativo |
|
||||
| `creado_por` | INT FK | Usuario que creó el registro |
|
||||
|
||||
#### Tabla `lab_enfermeras`
|
||||
|
||||
Personal de enfermería disponible para asignación.
|
||||
|
||||
Campos: `id`, `numero_documento`, `tipo_documento` (CC/CE/TI/PA), `nombre_completo`, `telefono`, `telefono_alt`, `email`, `zona`, `notas`, `is_active`.
|
||||
|
||||
#### Tabla `lab_asignaciones`
|
||||
|
||||
Asignación de enfermero a domicilio. Máximo un enfermero activo por domicilio (`UNIQUE KEY` en `domicilio_id`).
|
||||
|
||||
Campos: `id`, `domicilio_id`, `enfermera_id`, `asignada_por`, `estado` (`asignada` / `confirmada` / `liberada` / `completada`), `notas`.
|
||||
|
||||
#### Tabla `lab_servicios_extra`
|
||||
|
||||
Servicios realizados por el enfermero durante la visita, adicionales a la orden original.
|
||||
|
||||
Tipos disponibles: `inyeccion`, `cura`, `nebulizacion`, `toma_muestra`, `tension_arterial`, `glucometria`, `otro`.
|
||||
|
||||
Campos: `id`, `domicilio_id`, `descripcion`, `tipo`, `notas`, `requiere_pago`, `valor`, `realizado_por`.
|
||||
|
||||
#### Tabla `lab_domicilio_notas`
|
||||
|
||||
Notas registradas por el enfermero durante la visita.
|
||||
|
||||
Dos tipos:
|
||||
- **`clinica`**: datos de la ficha clínica — antecedentes, medicamentos, acudiente (si el paciente es menor de edad).
|
||||
- **`libre`**: nota libre con título, cuerpo de texto enriquecido e imagen adjunta.
|
||||
|
||||
Campos: `id`, `domicilio_id`, `enfermera_id`, `tipo`, `antecedentes`, `medicamentos`, `acudiente_nombre`, `acudiente_documento`, `titulo`, `cuerpo`, `imagen_path`.
|
||||
|
||||
---
|
||||
|
||||
### Roles y permisos
|
||||
|
||||
| Rol | Acceso |
|
||||
|---|---|
|
||||
| **Admin** | Crear, editar y ver todos los domicilios. Asignar/reasignar enfermeros. Registrar pagos. Exportar Excel. Ver informe completo con notas. Ver agenda de cualquier enfermero usando `?eid=X`. |
|
||||
| **Enfermero** | Solo ve su propia agenda (`enfermero_portal.php`). Avanza el estado de sus domicilios asignados. Agrega servicios extra. Registra notas clínicas y libres. Visualiza órdenes médicas adjuntas. |
|
||||
|
||||
> **Redirección automática**: si el usuario autenticado tiene rol `enfermero`, `lab_domicilios.php` lo redirige inmediatamente a `enfermero_portal.php`.
|
||||
|
||||
Los roles se definen en `admin_users.role` (ENUM `admin` / `enfermero`) y `admin_users.enfermera_id` (FK a `lab_enfermeras`).
|
||||
|
||||
---
|
||||
|
||||
### Flujo de estados
|
||||
|
||||
```
|
||||
[programado]
|
||||
│
|
||||
│ El enfermero confirma que realizará la visita
|
||||
▼
|
||||
[confirmado]
|
||||
│
|
||||
│ El enfermero sale hacia el domicilio
|
||||
▼
|
||||
[en_camino]
|
||||
│
|
||||
│ El enfermero llega → hora_llegada se registra automáticamente
|
||||
▼
|
||||
[en_domicilio]
|
||||
│
|
||||
│ El enfermero finaliza → hora_salida se registra automáticamente
|
||||
▼
|
||||
[completado]
|
||||
|
||||
Desde cualquier estado:
|
||||
→ [cancelado] (requiere motivo_cancelacion como campo obligatorio)
|
||||
→ [reprogramado] (requiere fecha_reprogramada)
|
||||
```
|
||||
|
||||
**Transiciones permitidas al enfermero** (validadas en `update_domicilio_enfermero.php`):
|
||||
|
||||
| Estado actual | Estados posibles |
|
||||
|---|---|
|
||||
| `programado` | `confirmado` |
|
||||
| `confirmado` | `en_camino`, `cancelado` |
|
||||
| `en_camino` | `en_domicilio`, `cancelado` |
|
||||
| `en_domicilio` | `completado`, `cancelado` |
|
||||
|
||||
El **admin** puede cambiar a cualquier estado directamente, incluyendo cancelar desde cualquier punto.
|
||||
|
||||
---
|
||||
|
||||
### Funcionalidades
|
||||
|
||||
- **Filtros**: por fecha, estado, enfermero asignado. Botón "Hoy" para filtro rápido.
|
||||
- **Resumen del día**: conteo de domicilios por estado en la parte superior.
|
||||
- **Panel de detalle**: al hacer clic en un domicilio se abre el panel lateral con toda la información, notas clínicas, notas libres e informe imprimible.
|
||||
- **Asignar / Reasignar enfermero**: modal con lista del personal disponible.
|
||||
- **Sin asignar**: badge con el conteo de domicilios que aún no tienen enfermero.
|
||||
- **Registrar pago**: modal para marcar el cobro con modalidad y monto.
|
||||
- **Servicios extra**: el enfermero los agrega desde su portal durante la visita.
|
||||
- **Notas del enfermero**: ficha clínica con antecedentes, medicamentos, acudiente (si menor) y notas libres con imagen adjunta.
|
||||
- **Informe de domicilio**: vista imprimible del domicilio con datos del paciente, ficha clínica y notas del enfermero.
|
||||
- **Exportar CSV (Excel)**: exporta todos los domicilios filtrados, incluyendo las columnas de notas del enfermero (antecedentes, medicamentos, acudiente, notas libres).
|
||||
- **Formularios recibidos**: pestaña dentro de `lab_domicilios.php` que muestra formularios enviados con filtro por plantilla y estado.
|
||||
- **Portal del enfermero**: tarjetas colapsables ordenadas por hora, separadas en "Activos" y "Finalizados". Permite avanzar estados, agregar notas y ver órdenes.
|
||||
|
||||
---
|
||||
|
||||
### API Endpoints
|
||||
|
||||
| Endpoint | Método | Descripción |
|
||||
|---|---|---|
|
||||
| `api/lab/get_domicilios.php` | GET | Lista con filtros. `?id=X` para uno solo con detalle completo. |
|
||||
| `api/lab/save_domicilio.php` | POST | Crear, editar o cambiar estado. `?solo_estado=true` para solo cambiar estado. |
|
||||
| `api/lab/update_domicilio_enfermero.php` | POST | El enfermero avanza el estado de su domicilio. |
|
||||
| `api/lab/my_agenda.php` | GET | Agenda del enfermero autenticado con servicios extra. |
|
||||
| `api/lab/save_asignacion.php` | POST | Asignar o reasignar enfermero a domicilio. |
|
||||
| `api/lab/get_asignaciones.php` | GET | Asignaciones por fecha. |
|
||||
| `api/lab/registrar_pago.php` | POST | Registrar pago con monto y modalidad. |
|
||||
| `api/lab/save_servicio_extra.php` | POST | Agregar servicio realizado durante la visita. |
|
||||
| `api/lab/get_notas_domicilio.php` | GET | Notas del domicilio (clínicas y libres). |
|
||||
| `api/lab/upload_nota_imagen.php` | POST | Subir imagen adjunta a una nota libre. |
|
||||
| `api/lab/crear_desde_whatsapp.php` | GET/POST | Crear paciente u orden desde una conversación de WhatsApp activa. |
|
||||
|
||||
---
|
||||
|
||||
## 2. Módulo de Formularios
|
||||
|
||||
### ¿Qué es?
|
||||
|
||||
El módulo de formularios permite crear **plantillas de documentos** (consentimientos, historias clínicas, autorizaciones, encuestas) mediante un builder visual, enviarlas a los pacientes por WhatsApp y recopilar sus respuestas con firma digital. El documento firmado genera un **sello de integridad SHA-256** que puede verificarse públicamente.
|
||||
|
||||
---
|
||||
|
||||
### Archivos involucrados
|
||||
|
||||
| Archivo | Descripción |
|
||||
|---|---|
|
||||
| `lab_formularios.php` | Vista principal — lista de plantillas y registro de envíos |
|
||||
| `lab_formulario_builder.php` | Editor visual drag & drop (ventana separada, solo admin) |
|
||||
| `form_cliente.php` | Página pública — el paciente llena y firma sin iniciar sesión |
|
||||
| `ver_formulario_enviado.php` | Vista del documento firmado — acceso por ID (admin/enfermero) o token (cliente) |
|
||||
| `verificar_formulario.php` | Verificación pública de autenticidad por hash SHA-256 |
|
||||
| `classes/lab/Formulario.php` | Clase ORM — CRUD de plantillas, crear envíos, guardar respuestas, generar hash |
|
||||
| `api/lab/get_formularios.php` | GET — lista plantillas o envíos |
|
||||
| `api/lab/save_formulario.php` | POST — crear, editar y eliminar plantillas (solo admin) |
|
||||
| `api/lab/send_formulario.php` | POST — crear instancia de envío, devolver URL pública y mensaje WhatsApp |
|
||||
| `api/lab/submit_formulario.php` | GET/POST — cargar el formulario por token / guardar la respuesta del cliente |
|
||||
| `api/lab/firmar_profesional.php` | POST — guardar firma del profesional (requiere sesión activa) |
|
||||
|
||||
---
|
||||
|
||||
### Base de datos
|
||||
|
||||
#### Tabla `lab_formularios`
|
||||
|
||||
Plantillas de documentos creadas desde el builder.
|
||||
|
||||
| Campo | Tipo | Descripción |
|
||||
|---|---|---|
|
||||
| `id` | INT PK | Identificador único |
|
||||
| `nombre` | VARCHAR(150) | Nombre de la plantilla |
|
||||
| `descripcion` | TEXT | Descripción visible al cliente |
|
||||
| `categoria` | ENUM | `consentimiento` / `historia_clinica` / `autorizacion` / `encuesta` / `otro` |
|
||||
| `esquema` | LONGTEXT | JSON con el array de campos del formulario |
|
||||
| `permite_firma` | TINYINT(1) | El formulario tiene sección de firma global |
|
||||
| `requiere_firma` | TINYINT(1) | La firma global es obligatoria |
|
||||
| `firma_modos` | VARCHAR(50) | `canvas`, `foto` o `canvas,foto` (separados por coma) |
|
||||
| `version` | SMALLINT | Se incrementa automáticamente al editar el esquema |
|
||||
| `is_active` | TINYINT(1) | Soft-delete |
|
||||
| `creado_por` | INT FK | Usuario que creó la plantilla |
|
||||
| `doc_encabezado` | VARCHAR | Override del nombre de empresa en el documento |
|
||||
| `doc_subtitulo` | VARCHAR | Override del subtítulo en el documento |
|
||||
| `doc_logo_base64` | LONGTEXT | Override del logo en el documento |
|
||||
| `doc_color` | VARCHAR(20) | Override del color del encabezado del documento |
|
||||
| `doc_pie_pagina` | TEXT | Override del pie de página |
|
||||
|
||||
#### Tabla `lab_form_envios`
|
||||
|
||||
Cada fila es una instancia enviada a un paciente.
|
||||
|
||||
| Campo | Tipo | Descripción |
|
||||
|---|---|---|
|
||||
| `id` | INT PK | Identificador único |
|
||||
| `formulario_id` | INT FK | Plantilla enviada |
|
||||
| `paciente_id` | INT FK NULL | Paciente asociado |
|
||||
| `domicilio_id` | INT FK NULL | Domicilio asociado (opcional) |
|
||||
| `token` | CHAR(64) UNIQUE | Token público de 64 caracteres hex (acceso sin sesión) |
|
||||
| `datos_prefilled` | LONGTEXT | JSON con datos pre-llenados al enviar (incluye `__paciente.*`) |
|
||||
| `datos_cliente` | LONGTEXT | JSON con las respuestas completadas por el cliente |
|
||||
| `firma_svg` | LONGTEXT | Firma del paciente (PNG base64 — canvas o foto) |
|
||||
| `ip_cliente` | VARCHAR(45) | IP del cliente al enviar el formulario |
|
||||
| `user_agent` | VARCHAR(512) | Navegador del cliente |
|
||||
| `estado` | ENUM | `pendiente` / `completado` / `firmado` / `expirado` |
|
||||
| `enviado_por` | INT FK | Usuario que generó el enlace |
|
||||
| `enviado_via` | ENUM | `whatsapp` / `email` / `link` |
|
||||
| `expira_en` | DATETIME NULL | Siempre `NULL` — el enlace no expira |
|
||||
| `completado_en` | DATETIME | Fecha y hora en que el cliente completó el formulario |
|
||||
| `hash_verificacion` | CHAR(64) | Sello de integridad SHA-256 del documento |
|
||||
|
||||
---
|
||||
|
||||
### Roles y permisos
|
||||
|
||||
| Rol | Acceso |
|
||||
|---|---|
|
||||
| **Admin** | Crear, editar y eliminar plantillas desde el builder. Enviar formularios a cualquier paciente. Ver todos los envíos. Ver y descargar el PDF de cualquier formulario. |
|
||||
| **Enfermero** | Enviar formularios existentes a sus pacientes. Ver solo sus propios envíos (`enviado_por = su user_id`). No puede crear ni editar plantillas. Puede firmar como profesional en los formularios que él mismo envió. |
|
||||
| **Cliente (público)** | Accede a `form_cliente.php?t=TOKEN` sin ninguna autenticación. Llena y firma el formulario. Puede volver al mismo enlace en cualquier momento para ver el documento firmado y descargarlo como PDF. |
|
||||
|
||||
---
|
||||
|
||||
### Flujo completo
|
||||
|
||||
```
|
||||
1. ADMIN crea la plantilla
|
||||
├─ Abre lab_formulario_builder.php (se abre en ventana nueva)
|
||||
├─ Arrastra campos al canvas y los configura
|
||||
├─ Configura el diseño del documento (logo, color, encabezado, pie de página)
|
||||
└─ Guarda → POST api/lab/save_formulario.php → lab_formularios
|
||||
|
||||
2. ADMIN o ENFERMERO envía el formulario
|
||||
├─ lab_formularios.php → botón "Enviar" → modal
|
||||
├─ Busca y selecciona el paciente
|
||||
├─ Previsualiza los campos que llegarán pre-llenados
|
||||
├─ Selecciona el canal: WhatsApp o "solo link"
|
||||
└─ POST api/lab/send_formulario.php
|
||||
├─ Genera token de 64 hex chars: bin2hex(random_bytes(32))
|
||||
├─ Crea fila en lab_form_envios (estado=pendiente, expira_en=NULL)
|
||||
└─ Devuelve URL pública y mensaje preformateado para WhatsApp
|
||||
|
||||
3. CLIENTE recibe el enlace (por WhatsApp u otro medio)
|
||||
├─ Abre form_cliente.php?t=TOKEN
|
||||
├─ GET api/lab/submit_formulario.php?t=TOKEN → carga datos del formulario
|
||||
└─ Si ya fue firmado antes → muestra pantalla de solo lectura con link al PDF
|
||||
|
||||
4. CLIENTE llena el formulario
|
||||
├─ Campos "linked" llegan pre-llenados con datos del paciente (readonly si tienen valor)
|
||||
├─ Campos vacíos linked son editables para que el cliente los complete
|
||||
├─ Campos firma_profesional muestran aviso "uso exclusivo del profesional"
|
||||
└─ Dibuja su firma (canvas) o adjunta una foto de firma
|
||||
|
||||
5. CLIENTE envía
|
||||
├─ POST api/lab/submit_formulario.php
|
||||
├─ Se genera hash SHA-256 (contenido + firma + ID + token + timestamp)
|
||||
├─ Estado → "firmado" (si hay firma) o "completado" (sin firma)
|
||||
└─ Pantalla de éxito con hash visible y botón para descargar el PDF
|
||||
|
||||
6. PROFESIONAL firma (si el formulario lo requiere)
|
||||
├─ Admin/Enfermero abre ver_formulario_enviado.php?id=X con sesión activa
|
||||
├─ Aparece canvas de firma en la posición del campo firma_profesional
|
||||
├─ Dibuja su firma y hace clic en "Guardar firma"
|
||||
└─ POST api/lab/firmar_profesional.php → guarda campo_id_svg en datos_cliente
|
||||
|
||||
7. ADMIN/ENFERMERO revisa el resultado
|
||||
├─ lab_formularios.php → pestaña "Envíos" → icono "Ver respuesta"
|
||||
└─ ver_formulario_enviado.php?id=X → documento HTML imprimible
|
||||
|
||||
8. VERIFICACIÓN pública de integridad
|
||||
└─ verificar_formulario.php?h=HASH_SHA256
|
||||
├─ Busca en lab_form_envios.hash_verificacion
|
||||
└─ Muestra: nombre del formulario, paciente, fecha, estado y si el sello es válido
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Tipos de campos del Builder
|
||||
|
||||
#### Campos de entrada
|
||||
|
||||
| Tipo | Descripción |
|
||||
|---|---|
|
||||
| `texto` | Campo de texto corto de una sola línea |
|
||||
| `textarea` | Área de texto largo (varias líneas) |
|
||||
| `numero` | Campo numérico |
|
||||
| `fecha` | Selector de fecha |
|
||||
| `hora` | Selector de hora |
|
||||
| `select` | Lista desplegable con opciones configurables |
|
||||
| `radio` | Selección única con opciones configurables |
|
||||
| `checkbox` | Selección múltiple con opciones configurables |
|
||||
| `lista_marcable` | Lista de ítems numerados con checkboxes |
|
||||
|
||||
#### Campos de firma
|
||||
|
||||
| Tipo | Descripción |
|
||||
|---|---|
|
||||
| `firma` | Firma del **paciente** — visible y editable en `form_cliente.php` |
|
||||
| `firma_profesional` | Firma del **profesional** — bloqueada para el cliente; solo editable desde el panel admin/enfermero |
|
||||
|
||||
#### Campos de contenido
|
||||
|
||||
| Tipo | Descripción |
|
||||
|---|---|
|
||||
| `separador` | Separador visual o título de sección |
|
||||
| `parrafo` | Bloque de texto estático (pre-formatado o flujo libre) |
|
||||
| `parrafo_inline` | Párrafo con marcadores `{nombre_completo}`, `{telefono}`, etc. que se convierten en espacios editables si el valor está vacío |
|
||||
|
||||
#### Campos vinculados al paciente (`tipo: linked`)
|
||||
|
||||
Se auto-rellenan con los datos del paciente al momento de enviar. Si el valor existe → campo de solo lectura. Si está vacío → el cliente puede completarlo.
|
||||
|
||||
| `linked_key` | Dato que extrae |
|
||||
|---|---|
|
||||
| `nombre_completo` | Nombre completo del paciente |
|
||||
| `numero_documento` | Número de documento |
|
||||
| `tipo_documento` | Tipo de documento |
|
||||
| `fecha_nacimiento` | Fecha de nacimiento |
|
||||
| `telefono` | Teléfono |
|
||||
| `email` | Correo electrónico |
|
||||
| `eps` | EPS o aseguradora |
|
||||
| `direccion` | Dirección |
|
||||
|
||||
---
|
||||
|
||||
### Firma digital
|
||||
|
||||
**Modos disponibles** (configurados en la plantilla mediante `firma_modos`):
|
||||
|
||||
| Modo | Funcionamiento |
|
||||
|---|---|
|
||||
| `canvas` | El cliente dibuja su firma con el dedo o el mouse. Se captura con `canvas.toDataURL('image/png')`. |
|
||||
| `foto` | El cliente sube una imagen desde su cámara o galería (`<input accept="image/*" capture="environment">`). Se convierte a base64 con `FileReader`. |
|
||||
|
||||
Ambos modos pueden estar activos simultáneamente en la misma plantilla.
|
||||
|
||||
**Firma global vs. firma por campo:**
|
||||
- Si el esquema **no incluye** campos tipo `firma`, se muestra una sección de firma global al pie del formulario.
|
||||
- Si el esquema **incluye** campos `firma`, cada uno tiene su propio widget canvas independiente en la posición configurada dentro del formulario.
|
||||
|
||||
---
|
||||
|
||||
### Enlace público y vigencia
|
||||
|
||||
- **URL pública**: `form_cliente.php?t=TOKEN`
|
||||
- **TOKEN**: 64 caracteres hexadecimales generados con `bin2hex(random_bytes(32))`.
|
||||
- **Sin sesión**: el cliente no necesita crear cuenta ni iniciar sesión.
|
||||
- **Sin vencimiento**: la columna `expira_en` existe en la tabla pero siempre es `NULL`. El enlace es permanente.
|
||||
- **Bloqueo por estado**: si el formulario ya fue completado o firmado, el enlace muestra la pantalla de solo lectura. No permite modificar la respuesta.
|
||||
- **Idempotencia**: si el cliente reintenta enviar (por error de red, por ejemplo), el sistema devuelve éxito con los datos ya guardados en lugar de crear un duplicado.
|
||||
|
||||
---
|
||||
|
||||
### PDF y visualización del documento
|
||||
|
||||
No se usa ninguna librería de generación de PDF en el backend. El documento es la página `ver_formulario_enviado.php` con estilos `@media print`. El usuario puede imprimirla o guardarla como PDF directamente desde el navegador.
|
||||
|
||||
**Contenido del documento impreso:**
|
||||
- Encabezado con logo, nombre, subtítulo, datos de contacto y color corporativo
|
||||
- Datos del paciente (nombre, documento, fecha de nacimiento, teléfono, EPS)
|
||||
- Respuestas del formulario campo por campo, en el orden del esquema
|
||||
- Imagen de la firma del paciente
|
||||
- Firma del profesional (si fue completada)
|
||||
- Sello SHA-256 con link para verificar autenticidad
|
||||
- Pie de página con fecha de generación e ID del documento
|
||||
|
||||
**Formas de acceder al documento:**
|
||||
|
||||
| URL | Quién puede acceder |
|
||||
|---|---|
|
||||
| `ver_formulario_enviado.php?id=X` | Admin (cualquier formulario) o Enfermero (solo los que él envió). Requiere sesión. |
|
||||
| `ver_formulario_enviado.php?t=TOKEN` | Cliente u cualquier persona con el enlace. Sin sesión. Solo si el estado es `firmado` o `completado`. |
|
||||
|
||||
---
|
||||
|
||||
### Sello de integridad SHA-256
|
||||
|
||||
Al guardar la respuesta del cliente, el sistema genera un hash SHA-256 que vincula de forma única el contenido del formulario con la firma y el momento en que se completó.
|
||||
|
||||
**Construcción del hash** (en `Formulario::guardarRespuesta()`):
|
||||
|
||||
```
|
||||
SHA-256 de:
|
||||
JSON de los datos del cliente
|
||||
+ firma SVG/PNG del paciente
|
||||
+ ID interno del envío
|
||||
+ token del enlace
|
||||
+ timestamp del momento de registro
|
||||
```
|
||||
|
||||
**¿Para qué sirve?** Cualquier persona con el hash puede ir a `verificar_formulario.php?h=HASH` para confirmar que:
|
||||
- El documento existe en la base de datos.
|
||||
- El nombre del formulario y del paciente.
|
||||
- La fecha en que fue completado.
|
||||
- El estado actual (firmado / completado).
|
||||
|
||||
Si el documento fue alterado, el hash no coincidirá y la verificación fallará.
|
||||
|
||||
---
|
||||
|
||||
### Firma del profesional
|
||||
|
||||
Algunos formularios requieren que un profesional de salud también firme el documento, además del paciente.
|
||||
|
||||
**Flujo:**
|
||||
1. Al diseñar la plantilla en el builder se agrega un campo `tipo: firma_profesional` en la posición deseada.
|
||||
2. Cuando el cliente llena el formulario en `form_cliente.php`, ese campo muestra solo un aviso: *"Uso exclusivo del profesional de salud"*. El cliente no puede interactuar con él.
|
||||
3. Una vez que el cliente ha completado y enviado el formulario, el admin o enfermero abre `ver_formulario_enviado.php?id=X` con sesión activa y verá el canvas de firma en esa posición.
|
||||
4. El profesional dibuja su firma y hace clic en "Guardar firma".
|
||||
5. La firma se guarda mediante POST a `api/lab/firmar_profesional.php`.
|
||||
|
||||
**Validaciones en el servidor:**
|
||||
- Requiere sesión activa (`isUserLoggedIn()`).
|
||||
- Si el usuario es enfermero, solo puede firmar en formularios que él mismo envió.
|
||||
- Valida que el `campo_id` corresponde a un campo `tipo: firma_profesional` en el esquema del formulario.
|
||||
- Valida que la imagen enviada sea un data URI de imagen válido.
|
||||
|
||||
> Si el formulario se accede via `?t=TOKEN` (cliente público), el canvas **no aparece**. En su lugar se muestra un aviso *"Pendiente de firma del profesional"* (solo visible en pantalla, no en el PDF impreso).
|
||||
|
||||
---
|
||||
|
||||
### API Endpoints
|
||||
|
||||
| Endpoint | Método | Autenticación | Descripción |
|
||||
|---|---|---|---|
|
||||
| `api/lab/get_formularios.php` | GET | Sesión | Lista plantillas. `?id=X` para una sola. `?envios=1` para lista de envíos. |
|
||||
| `api/lab/save_formulario.php` | POST | Admin | Crear, editar o eliminar una plantilla. |
|
||||
| `api/lab/send_formulario.php` | POST | Admin / Enfermero | Crear instancia de envío. Devuelve URL pública y mensaje para WhatsApp. |
|
||||
| `api/lab/submit_formulario.php` | GET | Público | Cargar el formulario por token (sin sesión). |
|
||||
| `api/lab/submit_formulario.php` | POST | Público | Guardar la respuesta y firma del cliente. |
|
||||
| `api/lab/firmar_profesional.php` | POST | Sesión | Guardar la firma del profesional en un campo `firma_profesional`. |
|
||||
|
||||
---
|
||||
|
||||
*Documentación generada para uso interno del equipo.*
|
||||
Ver `README_DOCS.md` para saber cómo se organiza y cómo agregar páginas.
|
||||
|
||||
+6
-1
@@ -36,7 +36,12 @@ RUN apk add --no-cache curl-dev \
|
||||
opcache
|
||||
|
||||
# Instalar Redis extension (versión fija para cache reproducible)
|
||||
RUN pecl install redis-6.0.2 && docker-php-ext-enable redis
|
||||
# Se descarga por HTTPS de forma explícita: el filtro de red perimetral
|
||||
# responde 403 a los .tgz servidos por HTTP, lo que rompía "pecl install".
|
||||
RUN apk add --no-cache curl \
|
||||
&& curl -fsSL https://pecl.php.net/get/redis-6.0.2.tgz -o /tmp/redis-6.0.2.tgz \
|
||||
&& pecl install /tmp/redis-6.0.2.tgz \
|
||||
&& docker-php-ext-enable redis
|
||||
|
||||
# Instalar Composer
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# Documentación del proyecto
|
||||
|
||||
La documentación vive **dentro del sistema**, en el módulo Soporte:
|
||||
|
||||
/erp.php?m=soporte&v=documentacion
|
||||
|
||||
Se escribe en Markdown, en `modules/soporte/docs/`, y se versiona con el código.
|
||||
|
||||
| Sección | Carpeta | Quién la ve |
|
||||
|---|---|---|
|
||||
| Manual de usuario | `docs/manual/` | Cualquier usuario autenticado |
|
||||
| Documentación técnica | `docs/tecnica/` | Administradores |
|
||||
| Arquitectura | `docs/arquitectura/` | Administradores |
|
||||
| Operación y soporte | `docs/operacion/` | Administradores |
|
||||
|
||||
## Agregar o editar una página
|
||||
|
||||
Creá un `.md` en la carpeta de la sección. El nombre lleva un prefijo numérico
|
||||
que solo sirve para ordenar:
|
||||
|
||||
modules/soporte/docs/tecnica/70-mi-tema.md
|
||||
|
||||
El título sale del primer encabezado `#` del archivo. No hay que registrar nada
|
||||
en ningún índice: se descubre solo.
|
||||
|
||||
## Limitar un documento a ciertos roles
|
||||
|
||||
Por defecto un documento hereda el permiso de su sección. Para restringirlo más,
|
||||
se declara al inicio del archivo:
|
||||
|
||||
---
|
||||
roles: enfermero, supervisor
|
||||
---
|
||||
|
||||
# Enfermeros — domicilios
|
||||
|
||||
Solo esos roles lo ven; para el resto no aparece en el índice ni es accesible
|
||||
por URL. Los administradores ven todo, siempre. Una sección que queda sin
|
||||
documentos visibles no se muestra.
|
||||
|
||||
## Contenido que se genera solo
|
||||
|
||||
Estos marcadores, en una línea propia, se reemplazan al cargar la página con
|
||||
datos leídos del código y de la base:
|
||||
|
||||
| Marcador | Qué inserta |
|
||||
|----------|-------------|
|
||||
| `{{modulos}}` | Módulos, con sus vistas y endpoints |
|
||||
| `{{endpoints}}` | Todos los endpoints por módulo |
|
||||
| `{{tablas}}` | Tablas de la base, agrupadas por prefijo |
|
||||
| `{{roles}}` | Roles, usuarios activos y sus permisos |
|
||||
| `{{servicios}}` | Clases de `core/`, `services/` y `classes/` |
|
||||
|
||||
Así los inventarios no pueden quedar desactualizados. La descripción de cada
|
||||
endpoint sale de su comentario de cabecera: si lo escribís bien, aparece bien.
|
||||
|
||||
## Documentos anteriores
|
||||
|
||||
`DOCUMENTACION_LAB.md` y `README_LAB.md` quedaron de una etapa previa y están
|
||||
desactualizados. `WEBHOOK_ENDPOINTS.md` se migró a la sección técnica.
|
||||
+5
-125
@@ -1,129 +1,9 @@
|
||||
# Módulo Administrativo — Laboratorio Clínico
|
||||
# Obsoleto
|
||||
|
||||
Módulo add-on para el sistema de chatbot WhatsApp que permite gestionar órdenes médicas recibidas como imágenes, domicilios, enfermeras y pacientes.
|
||||
Este documento quedó desactualizado y se conserva solo por historial de git.
|
||||
|
||||
---
|
||||
La documentación vigente está **dentro del sistema**, en el módulo Soporte:
|
||||
|
||||
## Instalación
|
||||
/erp.php?m=soporte&v=documentacion
|
||||
|
||||
### 1. Ejecutar migraciones de base de datos
|
||||
|
||||
```bash
|
||||
php migrations/20260302_lab_run_migrations.php
|
||||
```
|
||||
|
||||
Crea 7 tablas nuevas sin modificar las existentes:
|
||||
- `lab_pacientes`
|
||||
- `lab_enfermeras`
|
||||
- `lab_ordenes_medicas`
|
||||
- `lab_domicilios`
|
||||
- `lab_asignaciones`
|
||||
- `lab_autorizaciones`
|
||||
- `lab_actividad_admin`
|
||||
|
||||
Para deshacer:
|
||||
```bash
|
||||
php migrations/20260302_lab_run_migrations.php --rollback
|
||||
```
|
||||
|
||||
### 2. Verificar instalación
|
||||
|
||||
Accede desde el navegador (con sesión admin activa):
|
||||
```
|
||||
https://tu-servidor/lab_status.php
|
||||
```
|
||||
|
||||
O desde CLI:
|
||||
```bash
|
||||
php lab_status.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Archivos del módulo
|
||||
|
||||
### Vistas PHP
|
||||
| Archivo | Descripción |
|
||||
|---|---|
|
||||
| `lab_dashboard.php` | Panel principal con estadísticas en tiempo real |
|
||||
| `lab_ordenes.php` | Gestión de órdenes médicas (estados, imágenes, historial) |
|
||||
| `lab_pacientes.php` | CRUD de pacientes, vinculación con usuarios WhatsApp |
|
||||
| `lab_domicilios.php` | Agenda de domicilios y asignación de enfermeras |
|
||||
| `lab_enfermeras.php` | CRUD de enfermeras y visualización de agenda diaria |
|
||||
| `lab_reportes.php` | Trazabilidad, log de actividad, exportación CSV |
|
||||
| `lab_status.php` | Verificador de estado del módulo |
|
||||
|
||||
### Clases (models)
|
||||
Ubicadas en `classes/lab/`:
|
||||
- `ActividadAdmin.php` — Base de trazabilidad
|
||||
- `Paciente.php` — Modelo de pacientes
|
||||
- `Enfermera.php` — Modelo de enfermeras
|
||||
- `OrdenMedica.php` — Modelo de órdenes médicas con flujo de estados
|
||||
- `Domicilio.php` — Modelo de domicilios con flujo de estados
|
||||
- `Asignacion.php` — Modelo de asignaciones enfermera ↔ domicilio
|
||||
|
||||
### API REST
|
||||
Ubicados en `api/lab/`:
|
||||
| Endpoint | Método | Descripción |
|
||||
|---|---|---|
|
||||
| `get_pacientes.php` | GET | Lista paginada de pacientes |
|
||||
| `save_paciente.php` | POST | Crear/actualizar paciente |
|
||||
| `get_ordenes.php` | GET | Lista/detalle de órdenes |
|
||||
| `save_orden.php` | POST | Crear/actualizar orden |
|
||||
| `autorizar_orden.php` | POST | Cambiar estado de una orden |
|
||||
| `get_domicilios.php` | GET | Lista/detalle de domicilios |
|
||||
| `save_domicilio.php` | POST | Crear/actualizar domicilio |
|
||||
| `get_enfermeras.php` | GET | Lista de enfermeras + agenda |
|
||||
| `save_enfermera.php` | POST | Crear/actualizar enfermera |
|
||||
| `get_asignaciones.php` | GET | Asignaciones por fecha |
|
||||
| `save_asignacion.php` | POST | Asignar/liberar/completar enfermera |
|
||||
| `get_actividad.php` | GET | Log de actividad con filtros |
|
||||
| `get_stats.php` | GET | Estadísticas para dashboard |
|
||||
| `crear_desde_whatsapp.php` | GET/POST | Crear orden desde conversación activa |
|
||||
|
||||
---
|
||||
|
||||
## Flujos de estado
|
||||
|
||||
### Órdenes médicas
|
||||
```
|
||||
pendiente → en_revision → autorizada → en_domicilio → completada
|
||||
↘ rechazada
|
||||
```
|
||||
|
||||
### Domicilios
|
||||
```
|
||||
programado → confirmado → en_camino → en_domicilio → completado
|
||||
↘ cancelado
|
||||
↘ reprogramado
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integración con el chatbot
|
||||
|
||||
En `conversations.php`, los mensajes de imagen entrantes tienen un botón **<i class="fas fa-flask"></i>** (verde) en las acciones del mensaje. Al hacer click:
|
||||
|
||||
1. Se abre un modal con la imagen adjunta
|
||||
2. El operador busca o selecciona un paciente (o usa el contacto de la conversación)
|
||||
3. Completa datos opcionales (médico, exámenes, ayuno)
|
||||
4. Se crea la orden en estado `pendiente`
|
||||
|
||||
---
|
||||
|
||||
## Exportaciones CSV
|
||||
|
||||
Disponibles desde `lab_reportes.php`:
|
||||
- **Órdenes médicas** del período — incluye estado, médico, exámenes
|
||||
- **Domicilios** del período — incluye enfermera asignada, dirección, estado
|
||||
- **Pacientes** — catálogo completo con total de órdenes
|
||||
|
||||
---
|
||||
|
||||
## Requisitos
|
||||
|
||||
- PHP 8.2+
|
||||
- MariaDB 10.11+ (o MySQL 8+)
|
||||
- Bootstrap 5.3 (ya incluido en el sistema)
|
||||
- Font Awesome 6.4 (ya incluido en el sistema)
|
||||
- `uploads/media/` con permisos de escritura (755/775)
|
||||
Ver `README_DOCS.md` para saber cómo se organiza y cómo agregar páginas.
|
||||
|
||||
@@ -21,6 +21,53 @@ requireAuthentication();
|
||||
$adminId = (int)($_SESSION['admin_user']['id'] ?? 0);
|
||||
$db = Database::getInstance();
|
||||
|
||||
/**
|
||||
* Le pide el teléfono a quien lo tiene oculto en WhatsApp, una sola vez.
|
||||
*
|
||||
* Solo aplica a quien se identifica con un BSUID: de esa persona no tenemos
|
||||
* número, y sin él el laboratorio no puede llamarla. Se le manda el botón que
|
||||
* Meta dispone para esto y ella decide si lo comparte; si acepta, el webhook
|
||||
* recibe el teléfono y lo vincula solo.
|
||||
*
|
||||
* No se insiste: pedir los datos una vez es razonable, repetirlo en cada
|
||||
* trámite es acoso. Tampoco se interrumpe la creación de la ficha si el envío
|
||||
* falla, porque la ficha es lo importante.
|
||||
*
|
||||
* @return string qué pasó, para que la interfaz lo pueda mostrar
|
||||
*/
|
||||
function pedirContactoSiHaceFalta(Database $db, int $userId): string {
|
||||
$u = $db->fetch(
|
||||
'SELECT phone_number, contacto_pedido_at FROM users WHERE id = ?',
|
||||
[$userId]
|
||||
);
|
||||
if (!$u) return 'usuario_no_encontrado';
|
||||
if (!esBsuid($u['phone_number'])) return 'no_hace_falta'; // ya tenemos su número
|
||||
if (!empty($u['contacto_pedido_at'])) return 'ya_se_pidio';
|
||||
|
||||
try {
|
||||
require_once __DIR__ . '/../../services/WhatsAppService.php';
|
||||
$wa = new WhatsAppService();
|
||||
$texto = getConfigFromDB(
|
||||
'whatsapp_texto_pedir_contacto',
|
||||
'Para poder registrar su atención necesitamos un número de contacto. ¿Nos comparte el suyo?'
|
||||
);
|
||||
|
||||
$r = $wa->pedirContacto($u['phone_number'], $texto);
|
||||
if (!$r) {
|
||||
error_log('[crear_desde_whatsapp] WhatsApp rechazó la solicitud de contacto del usuario ' . $userId);
|
||||
return 'fallo_envio';
|
||||
}
|
||||
|
||||
// Se marca solo si Meta aceptó: si falló, hay que poder reintentarlo
|
||||
$db->update('users', ['contacto_pedido_at' => date('Y-m-d H:i:s')], 'id = ?', [$userId]);
|
||||
return 'pedido';
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log('[crear_desde_whatsapp] Error pidiendo el contacto: ' . $e->getMessage());
|
||||
return 'fallo_envio';
|
||||
}
|
||||
}
|
||||
|
||||
// ── GET: solo_paciente ─────────────────────────────────────────────────────
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['solo_paciente'])) {
|
||||
$convId = (int)($_GET['conversation_id'] ?? 0);
|
||||
@@ -57,7 +104,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['solo_paciente'])) {
|
||||
$pacienteRepo = new Paciente();
|
||||
$pacienteId = $pacienteRepo->obtenerOCrearDesdeWhatsapp($conv['user_id']);
|
||||
$paciente = $pacienteRepo->obtener($pacienteId);
|
||||
echo json_encode(['success' => true, 'paciente' => $paciente]);
|
||||
|
||||
// Si la persona oculta su teléfono, la ficha queda sin número. Es el momento
|
||||
// de pedírselo: se le manda el botón de WhatsApp una sola vez.
|
||||
$contactoPedido = pedirContactoSiHaceFalta($db, (int) $conv['user_id']);
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'paciente' => $paciente,
|
||||
'contacto_pedido' => $contactoPedido,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
|
||||
+112
-6
@@ -113,24 +113,41 @@ class WhatsAppWebhook {
|
||||
return;
|
||||
}
|
||||
|
||||
// Extraer nombres de contactos del payload (contacts[].profile.name)
|
||||
// Extraer nombres de contactos del payload (contacts[].profile.name).
|
||||
// Se indexa por wa_id y también por user_id (BSUID): desde que Meta desplegó
|
||||
// los nombres de usuario, quien oculta su teléfono llega sin wa_id.
|
||||
$contactNames = [];
|
||||
if (isset($value['contacts']) && is_array($value['contacts'])) {
|
||||
foreach ($value['contacts'] as $contact) {
|
||||
$waId = $contact['wa_id'] ?? null;
|
||||
$name = $contact['profile']['name'] ?? null;
|
||||
if ($waId && $name) {
|
||||
$contactNames[$waId] = $name;
|
||||
if (!$name) continue;
|
||||
foreach ([$contact['wa_id'] ?? null, $contact['user_id'] ?? null] as $clave) {
|
||||
if ($clave) $contactNames[$clave] = $name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($items as $message) {
|
||||
// En algunos payloads la estructura key es 'from' y 'id' (mensajes), en otros puede venir distinta; normalizamos
|
||||
// En algunos payloads la estructura key es 'from' y 'id' (mensajes), en otros puede venir distinta; normalizamos.
|
||||
$phoneNumber = $message['from'] ?? ($message['wa_id'] ?? null);
|
||||
$messageId = $message['id'] ?? ($message['message_id'] ?? null);
|
||||
$timestamp = $message['timestamp'] ?? null;
|
||||
|
||||
// Meta manda el BSUID en todos los mensajes, traigan teléfono o no.
|
||||
$bsuid = $message['from_user_id'] ?? null;
|
||||
|
||||
// Si la persona oculta su teléfono, `from` y `wa_id` no llegan. En ese caso
|
||||
// se busca por BSUID: si ya escribió antes mostrando su número, se le
|
||||
// reconoce y se le sigue respondiendo a ese teléfono.
|
||||
if (empty($phoneNumber) && $bsuid) {
|
||||
$conocido = $this->db->fetch(
|
||||
"SELECT phone_number FROM users WHERE bsuid = :b", ['b' => $bsuid]
|
||||
);
|
||||
// Si no se le conoce, el BSUID hace de identificador: sirve para responderle,
|
||||
// aunque no permita cruzarlo con el paciente ni con el turnero.
|
||||
$phoneNumber = $conocido['phone_number'] ?? $bsuid;
|
||||
}
|
||||
|
||||
// Si falta lo crítico, saltar
|
||||
if (empty($phoneNumber) || empty($messageId)) {
|
||||
continue;
|
||||
@@ -148,7 +165,7 @@ class WhatsAppWebhook {
|
||||
|
||||
// Obtener o crear usuario
|
||||
$user = $this->getUserByPhone($phoneNumber);
|
||||
$contactName = $contactNames[$phoneNumber] ?? null;
|
||||
$contactName = $contactNames[$phoneNumber] ?? ($bsuid ? ($contactNames[$bsuid] ?? null) : null);
|
||||
if (!$user) {
|
||||
$userId = $this->createUser($phoneNumber);
|
||||
$user = $this->getUserById($userId);
|
||||
@@ -173,6 +190,20 @@ class WhatsAppWebhook {
|
||||
}
|
||||
}
|
||||
|
||||
// Guardar la equivalencia BSUID↔usuario mientras la persona todavía muestra
|
||||
// su teléfono. El día que lo oculte, ese registro es lo único que permitirá
|
||||
// reconocerla, así que se anota en cada mensaje y no solo la primera vez.
|
||||
if ($bsuid && !empty($user['id']) && ($user['bsuid'] ?? null) !== $bsuid) {
|
||||
try {
|
||||
$this->db->update('users', ['bsuid' => $bsuid], 'id = ?', [$user['id']]);
|
||||
$user['bsuid'] = $bsuid;
|
||||
} catch (Exception $e) {
|
||||
// Choca si ese BSUID ya está en otro usuario (la persona cambió de
|
||||
// número). No es motivo para perder el mensaje: se sigue adelante.
|
||||
error_log('[webhook] No se pudo guardar el BSUID ' . $bsuid . ': ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Procesar diferentes tipos de mensaje
|
||||
$messageText = '';
|
||||
$messageType = 'text';
|
||||
@@ -191,6 +222,28 @@ class WhatsAppWebhook {
|
||||
'reaction_emoji' => $emoji
|
||||
];
|
||||
|
||||
} elseif (($message['type'] ?? '') === 'contacts') {
|
||||
// La persona compartió su contacto, sea por el botón que se le pidió
|
||||
// o a mano. Es la única forma de obtener el teléfono de quien lo oculta.
|
||||
$messageType = 'contacts';
|
||||
$messageText = json_encode($message['contacts'] ?? []);
|
||||
|
||||
$telefonoCompartido = null;
|
||||
foreach ($message['contacts'] ?? [] as $c) {
|
||||
foreach ($c['phones'] ?? [] as $t) {
|
||||
// wa_id ya viene normalizado; `phone` puede traer espacios y signos
|
||||
$candidato = $t['wa_id'] ?? ($t['phone'] ?? null);
|
||||
if ($candidato) {
|
||||
$telefonoCompartido = preg_replace('/[^0-9]/', '', $candidato);
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($telefonoCompartido && !empty($user['id'])) {
|
||||
$this->vincularTelefonoCompartido($user, $telefonoCompartido, $bsuid);
|
||||
}
|
||||
|
||||
} elseif (isset($message['interactive'])) {
|
||||
// Interactive replies (list or button) - normalize to text so bot can process
|
||||
$messageType = 'text';
|
||||
@@ -450,6 +503,59 @@ class WhatsAppWebhook {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vincula el teléfono que la persona acaba de compartir con el usuario que
|
||||
* hasta ahora solo se conocía por su BSUID.
|
||||
*
|
||||
* Puede haber dos registros de la misma persona: el viejo, de cuando escribía
|
||||
* mostrando el número, y el nuevo creado con el BSUID de identificador. No se
|
||||
* fusionan aquí (implicaría mover conversaciones, estados y aceptación de
|
||||
* términos, y una fusión mal hecha mezcla historias clínicas de dos personas):
|
||||
* se deja el registro con el teléfono como el bueno y se marca el otro, para
|
||||
* que alguien lo revise.
|
||||
*
|
||||
* @return bool si el teléfono quedó vinculado
|
||||
*/
|
||||
private function vincularTelefonoCompartido(&$user, $telefono, $bsuid) {
|
||||
// Ya lo teníamos: nada que hacer
|
||||
if (($user['phone_number'] ?? null) === $telefono) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$existente = $this->db->fetch(
|
||||
"SELECT id FROM users WHERE phone_number = :t AND id <> :id",
|
||||
['t' => $telefono, 'id' => $user['id']]
|
||||
);
|
||||
|
||||
try {
|
||||
if ($existente) {
|
||||
// El registro bueno es el que tiene el teléfono. Se le pasa el BSUID
|
||||
// para que a partir de ahora se le reconozca por ahí.
|
||||
if ($bsuid) {
|
||||
$this->db->update('users', ['bsuid' => null], 'id = ?', [$user['id']]);
|
||||
$this->db->update('users', ['bsuid' => $bsuid], 'id = ?', [$existente['id']]);
|
||||
}
|
||||
error_log(sprintf(
|
||||
'[webhook] BSUID %s compartió el teléfono %s, que ya era del usuario %d. ' .
|
||||
'El usuario %d queda duplicado y hay que revisarlo a mano.',
|
||||
$bsuid, $telefono, $existente['id'], $user['id']
|
||||
));
|
||||
$user = $this->getUserById($existente['id']) ?: $user;
|
||||
return true;
|
||||
}
|
||||
|
||||
// No había otro registro: el placeholder pasa a tener el teléfono real
|
||||
$this->db->update('users', ['phone_number' => $telefono], 'id = ?', [$user['id']]);
|
||||
$user['phone_number'] = $telefono;
|
||||
error_log(sprintf('[webhook] BSUID %s quedó vinculado al teléfono %s', $bsuid, $telefono));
|
||||
return true;
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log('[webhook] Error vinculando el teléfono compartido: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function getUserByPhone($phoneNumber) {
|
||||
return $this->db->fetch(
|
||||
"SELECT * FROM users WHERE phone_number = :phone",
|
||||
|
||||
@@ -7,6 +7,15 @@
|
||||
require_once __DIR__ . '/../../classes/Database.php';
|
||||
require_once __DIR__ . '/ActividadAdmin.php';
|
||||
|
||||
// esBsuid() vive en config.php; se garantiza aquí por si esta clase se incluye
|
||||
// directamente, sin pasar por el arranque del ERP.
|
||||
if (!function_exists('esBsuid')) {
|
||||
$configPaciente = __DIR__ . '/../../config/config.php';
|
||||
if (file_exists($configPaciente)) {
|
||||
require_once $configPaciente;
|
||||
}
|
||||
}
|
||||
|
||||
class Paciente {
|
||||
|
||||
private Database $db;
|
||||
@@ -173,8 +182,22 @@ class Paciente {
|
||||
}
|
||||
}
|
||||
|
||||
// Registrar antes y después de lo que realmente cambia. Guardar solo el
|
||||
// valor nuevo impide reconstruir el dato anterior si la corrección
|
||||
// resultó equivocada, que es justo cuando hace falta consultarlo.
|
||||
$antes = $this->db->fetch('SELECT * FROM lab_pacientes WHERE id = ?', [$id]) ?: [];
|
||||
$cambios = [];
|
||||
foreach ($campos as $campo => $nuevo) {
|
||||
$previo = $antes[$campo] ?? null;
|
||||
if ((string)$previo !== (string)$nuevo) {
|
||||
$cambios[$campo] = ['antes' => $previo, 'despues' => $nuevo];
|
||||
}
|
||||
}
|
||||
|
||||
$ok = $this->db->update('lab_pacientes', $campos, 'id = ?', [$id]);
|
||||
$this->log->registrar($adminId, 'pacientes', 'editar', $id, $campos);
|
||||
if ($cambios) {
|
||||
$this->log->registrar($adminId, 'pacientes', 'editar', $id, $cambios);
|
||||
}
|
||||
return $ok > 0;
|
||||
}
|
||||
|
||||
@@ -244,10 +267,19 @@ class Paciente {
|
||||
[$userId]
|
||||
);
|
||||
|
||||
// Quien oculta su teléfono en WhatsApp se identifica con un BSUID, que ocupa
|
||||
// el lugar del número en `users`. No es un teléfono: guardarlo aquí dejaría
|
||||
// en la historia clínica un dato falso con apariencia de número real, porque
|
||||
// normalizarTelefono() le quita el punto y las letras y lo deja en 16 dígitos.
|
||||
// Mejor la ficha sin teléfono, que es la verdad: no lo tenemos.
|
||||
$identificador = $user['phone_number'] ?? null;
|
||||
$esIdentificadorSinTelefono = esBsuid($identificador);
|
||||
|
||||
return $this->crear([
|
||||
'user_id' => $userId,
|
||||
'nombre_completo'=> $user['name'] ?? ('Paciente ' . $user['phone_number']),
|
||||
'telefono' => $user['phone_number'] ?? null,
|
||||
'nombre_completo'=> $user['name']
|
||||
?? ($esIdentificadorSinTelefono ? 'Paciente sin identificar' : 'Paciente ' . $identificador),
|
||||
'telefono' => $esIdentificadorSinTelefono ? null : $identificador,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ define('SYSTEM_MODULES', [
|
||||
// ── Sistema ──────────────────────────────────────────────────────────────
|
||||
'usuarios' => 'Gestión de Usuarios',
|
||||
'enfermero_portal' => 'Portal Enfermero',
|
||||
'soporte' => 'Soporte y Documentación',
|
||||
// ── Oleada 1 — Turnero ───────────────────────────────────────────────────
|
||||
'turnero' => 'Turnero',
|
||||
// ── Oleada 2 — pendiente ─────────────────────────────────────────────────
|
||||
@@ -338,6 +339,24 @@ function deleteConfigFromDB($key) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('esBsuid')) {
|
||||
/**
|
||||
* ¿Este identificador es un BSUID de Meta y no un número de teléfono?
|
||||
*
|
||||
* Desde que WhatsApp permite ocultar el número, quien lo oculta llega
|
||||
* identificado solo por su BSUID, con la forma "CO.1761088155094242".
|
||||
* Ese valor ocupa el lugar del teléfono dentro del bot, así que hay que
|
||||
* distinguirlo antes de tratarlo como si fuera un número real: guardarlo
|
||||
* en un campo de teléfono deja un dato falso con toda la pinta de verdadero.
|
||||
*
|
||||
* @param mixed $valor
|
||||
* @return bool
|
||||
*/
|
||||
function esBsuid($valor) {
|
||||
return (bool) preg_match('/^[A-Z]{2}\.\d+$/', (string) $valor);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Limpia el cache estático de configuraciones
|
||||
*/
|
||||
|
||||
@@ -23,6 +23,14 @@ class Router
|
||||
private const PUBLIC_ROUTES = [
|
||||
'turnero/display',
|
||||
'turnero/kiosko',
|
||||
// Tablet de firma del paciente: la manipula el público y nadie va a
|
||||
// iniciar sesión en ella cada mañana. No queda abierta: se identifica
|
||||
// por la cookie del dispositivo y sin ella no muestra dato alguno.
|
||||
'turnero/firma',
|
||||
// Prueba de voces: hay que abrirla EN el televisor para saber qué voces
|
||||
// tiene ese equipo, y allí no hay sesión iniciada. No expone nada: solo
|
||||
// lista las voces del navegador y lee una frase de ejemplo inventada.
|
||||
'turnero/voces',
|
||||
];
|
||||
|
||||
/** Patrón permitido para módulo y vista: solo letras, números y guión bajo */
|
||||
|
||||
+35
-3
@@ -472,6 +472,28 @@ function fmtFecha(str) {
|
||||
return str.slice(0, 10).split('-').reverse().join('/');
|
||||
}
|
||||
|
||||
// Quien oculta su número en WhatsApp llega identificado con un BSUID
|
||||
// ("CO.1761088155094242"), que se guarda en phone_number pero no es un teléfono.
|
||||
const esBsuid = v => /^[A-Z]{2}\.\d+$/.test(String(v || ''));
|
||||
|
||||
/**
|
||||
* Celda de contacto. Si de esta persona no tenemos teléfono porque lo tiene
|
||||
* oculto, se dice así en vez de mostrar el identificador crudo: recepción
|
||||
* necesita entender por qué no puede llamarla, no ver un código.
|
||||
*/
|
||||
function celdaTelefono(p) {
|
||||
if (esBsuid(p.phone_number)) {
|
||||
return (p.telefono ? esc(p.telefono) : '<span class="text-muted">Sin teléfono</span>')
|
||||
+ '<br><i class="fab fa-whatsapp text-success"></i> '
|
||||
+ '<small class="text-muted" title="Tiene el número oculto en WhatsApp. '
|
||||
+ 'Se le puede escribir por el chat, pero no llamar.">Solo por WhatsApp</small>';
|
||||
}
|
||||
return esc(p.telefono || '—')
|
||||
+ (p.phone_number
|
||||
? `<br><i class="fab fa-whatsapp text-success"></i> <small class="text-muted">${esc(p.phone_number)}</small>`
|
||||
: '');
|
||||
}
|
||||
|
||||
async function cargarLista(pag = 1) {
|
||||
paginaActual = pag;
|
||||
const busq = document.getElementById('buscador').value.trim();
|
||||
@@ -495,7 +517,7 @@ async function cargarLista(pag = 1) {
|
||||
${p.genero ? `<small class="text-muted">${p.genero==='M'?'Masculino':p.genero==='F'?'Femenino':'Otro'}</small>` : ''}
|
||||
</td>
|
||||
<td class="small">${tipoDocLabel(p.tipo_documento)}<br><span class="fw-semibold">${esc(p.numero_documento||'—')}</span></td>
|
||||
<td class="small">${esc(p.telefono||'—')}${p.phone_number ? `<br><i class="fab fa-whatsapp text-success"></i> <small class="text-muted">${esc(p.phone_number)}</small>` : ''}</td>
|
||||
<td class="small">${celdaTelefono(p)}</td>
|
||||
<td class="small text-muted">${esc(p.email||'—')}</td>
|
||||
<td class="small text-muted">${esc(p.ciudad||'—')}</td>
|
||||
<td class="small text-muted">${esc(p.eps||'—')}</td>
|
||||
@@ -546,8 +568,18 @@ async function verDetalle(id) {
|
||||
document.getElementById('detail-body').innerHTML = `
|
||||
<dl class="row small mb-3">
|
||||
<dt class="col-5 text-muted">Documento</dt><dd class="col-7">${tipoDocLabel(p.tipo_documento)} ${esc(p.numero_documento||'—')}</dd>
|
||||
<dt class="col-5 text-muted">Teléfono</dt><dd class="col-7">${esc(p.telefono||'—')}</dd>
|
||||
<dt class="col-5 text-muted">WhatsApp</dt><dd class="col-7">${p.phone_number ? `<i class="fab fa-whatsapp text-success"></i> ${esc(p.phone_number)}` : '—'}</dd>
|
||||
<dt class="col-5 text-muted">Teléfono</dt><dd class="col-7">${
|
||||
p.telefono ? esc(p.telefono)
|
||||
: (esBsuid(p.phone_number)
|
||||
? '<span class="text-muted">Sin teléfono — lo tiene oculto en WhatsApp</span>'
|
||||
: '—')
|
||||
}</dd>
|
||||
<dt class="col-5 text-muted">WhatsApp</dt><dd class="col-7">${
|
||||
!p.phone_number ? '—'
|
||||
: (esBsuid(p.phone_number)
|
||||
? '<i class="fab fa-whatsapp text-success"></i> Se le puede escribir por el chat, pero no llamar'
|
||||
: `<i class="fab fa-whatsapp text-success"></i> ${esc(p.phone_number)}`)
|
||||
}</dd>
|
||||
<dt class="col-5 text-muted">Origen</dt><dd class="col-7">${origenBadge(p.origen)}</dd>
|
||||
<dt class="col-5 text-muted">Registro</dt><dd class="col-7 text-muted small">${fmtFecha(p.created_at)}</dd>
|
||||
${p.email ? `<dt class="col-5 text-muted">Email</dt><dd class="col-7">${esc(p.email)}</dd>` : ''}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
-- 20260811_bsuid_identidad_whatsapp.sql
|
||||
--
|
||||
-- Meta desplegó los nombres de usuario de WhatsApp: quien oculta su teléfono
|
||||
-- llega al webhook sin `from` ni `wa_id`, identificado solo por su BSUID
|
||||
-- (identificador de usuario por empresa), con la forma "CO.1761088155094242".
|
||||
--
|
||||
-- Ese identificador ocupa el lugar del teléfono en el flujo del bot, pero no
|
||||
-- cabe en varchar(20): los BSUID de Meta llegan a 23 caracteres, así que se
|
||||
-- truncarían en silencio y la persona quedaría imposible de responder.
|
||||
--
|
||||
-- Se amplían a varchar(32) las cuatro tablas por donde circula.
|
||||
-- Las vistas (scheduled_messages_view, v_paciente_resumen) heredan el tipo.
|
||||
-- lab_pacientes.telefono se deja igual: ahí va el teléfono real del paciente,
|
||||
-- y de estas personas justamente no lo tenemos.
|
||||
|
||||
ALTER TABLE users MODIFY phone_number VARCHAR(32) NOT NULL;
|
||||
ALTER TABLE user_states MODIFY phone_number VARCHAR(32) NOT NULL;
|
||||
ALTER TABLE terms_acceptance MODIFY phone_number VARCHAR(32) NOT NULL;
|
||||
ALTER TABLE file_requests MODIFY phone_number VARCHAR(32) NOT NULL;
|
||||
@@ -0,0 +1,21 @@
|
||||
-- 20260811_bsuid_mapa_identidad.sql
|
||||
--
|
||||
-- Segunda parte del cambio de identidad de WhatsApp (ver 20260811_bsuid_identidad_whatsapp.sql).
|
||||
--
|
||||
-- Meta manda el BSUID en TODOS los webhooks de mensaje, también en los que aún
|
||||
-- traen teléfono. Eso permite guardar la equivalencia BSUID↔teléfono mientras
|
||||
-- la persona todavía muestra su número, de modo que el día que lo oculte
|
||||
-- siga siendo reconocible: ya sabemos quién es.
|
||||
--
|
||||
-- El histórico de webhook_logs tiene 6.423 equivalencias que se cargan con
|
||||
-- scripts/backfill_bsuid.php.
|
||||
--
|
||||
-- Se guarda en una columna aparte y no en phone_number porque son dos cosas
|
||||
-- distintas: el BSUID identifica, el teléfono además sirve para cruzar con el
|
||||
-- paciente y el turnero. Mezclarlos rompería esos cruces.
|
||||
|
||||
ALTER TABLE users ADD COLUMN bsuid VARCHAR(32) NULL DEFAULT NULL COMMENT 'Identificador de usuario por empresa (Meta). Presente aunque la persona oculte su teléfono.' AFTER phone_number;
|
||||
|
||||
-- Único: un BSUID identifica a una sola persona dentro del portafolio.
|
||||
-- Admite varios NULL, que es el caso de todos los usuarios ya existentes.
|
||||
ALTER TABLE users ADD UNIQUE KEY uk_users_bsuid (bsuid);
|
||||
@@ -0,0 +1,24 @@
|
||||
-- 20260811_bsuid_pedir_contacto.sql
|
||||
--
|
||||
-- Tercera parte del cambio de identidad de WhatsApp.
|
||||
--
|
||||
-- A quien oculta su teléfono se le puede pedir con el botón request_contact_info.
|
||||
-- Se registra cuándo se le pidió para no volver a insistirle: pedirle los datos
|
||||
-- una vez es razonable, repetírselo en cada trámite es acoso.
|
||||
--
|
||||
-- Queda NULL para todo el mundo; solo se llena cuando efectivamente se pide.
|
||||
|
||||
ALTER TABLE users
|
||||
ADD COLUMN contacto_pedido_at DATETIME NULL DEFAULT NULL
|
||||
COMMENT 'Cuándo se le pidió el teléfono por el botón de WhatsApp. NULL = nunca.'
|
||||
AFTER bsuid;
|
||||
|
||||
-- Texto editable desde configuración, para que el laboratorio ajuste el mensaje
|
||||
-- sin tocar código. Si la fila ya existe, se respeta lo que haya.
|
||||
INSERT INTO system_config (config_key, config_value, description)
|
||||
VALUES (
|
||||
'whatsapp_texto_pedir_contacto',
|
||||
'Para poder registrar su atención necesitamos un número de contacto. ¿Nos comparte el suyo?',
|
||||
'Mensaje del botón que pide el teléfono a quien lo tiene oculto en WhatsApp'
|
||||
)
|
||||
ON DUPLICATE KEY UPDATE config_key = config_key;
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Estaciones accesibles desde equipos asignados a otra estación.
|
||||
-- Pediatría y Ginecología se atienden desde cualquier puesto, pero el bloqueo
|
||||
-- por IP o token devolvía al equipo a su estación sin avisar.
|
||||
-- Idempotente: puede correrse más de una vez.
|
||||
|
||||
ALTER TABLE turnero_lugares
|
||||
ADD COLUMN IF NOT EXISTS acceso_libre TINYINT(1) NOT NULL DEFAULT 0
|
||||
COMMENT 'Accesible desde equipos asignados a otra estación';
|
||||
|
||||
UPDATE turnero_lugares SET acceso_libre = 1
|
||||
WHERE nombre IN ('Pediatria', 'Ginecologia') AND acceso_libre = 0;
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Rol de Calidad: consulta de turnos y tiempos de atención.
|
||||
-- Solo lectura, y dentro del turnero solo dashboard e historial.
|
||||
-- Incluye formularios: la coordinación del SIG administra los documentos
|
||||
-- además de revisar los tiempos del proceso.
|
||||
-- Idempotente: puede correrse más de una vez.
|
||||
|
||||
INSERT INTO roles (name, slug, description, color, home_page, is_system)
|
||||
SELECT 'Calidad', 'calidad',
|
||||
'Consulta de turnos y tiempos de atención. Solo lectura: dashboard e historial.',
|
||||
'#7c3aed', '/erp.php?m=turnero&v=dashboard', 0
|
||||
WHERE NOT EXISTS (SELECT 1 FROM roles WHERE slug = 'calidad');
|
||||
|
||||
INSERT INTO role_modules (role_id, module_slug, permission, can_view, can_create, can_edit, can_delete, can_export)
|
||||
SELECT r.id, m.slug, 'read', 1, 0, 0, 0, IF(m.slug = 'turnero', 1, 0)
|
||||
FROM roles r
|
||||
JOIN (SELECT 'turnero' AS slug UNION ALL SELECT 'soporte'
|
||||
UNION ALL SELECT 'lab_formularios') m
|
||||
WHERE r.slug = 'calidad'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM role_modules rm WHERE rm.role_id = r.id AND rm.module_slug = m.slug
|
||||
);
|
||||
@@ -1,4 +1,7 @@
|
||||
<?php
|
||||
/**
|
||||
* GET ?id=N — Detalle de un examen del catálogo con sus ítems.
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireLogin();
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<?php
|
||||
/**
|
||||
* GET ?exam_id=N — Tarifas de un examen por empresa o convenio.
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireLogin();
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<?php
|
||||
/**
|
||||
* GET ?q=&categoria=&page=&limit= — Catálogo de exámenes, paginado y filtrable.
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireLogin();
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<?php
|
||||
/**
|
||||
* POST — Crea o actualiza un examen del catálogo. Requiere administrador.
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireAdmin();
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') jsonError('Método no permitido', 405);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<?php
|
||||
/**
|
||||
* POST — Crea o actualiza un ítem (analito) de un examen. Requiere administrador.
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireAdmin();
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') jsonError('Método no permitido', 405);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<?php
|
||||
/**
|
||||
* POST — Crea o actualiza la tarifa de un examen. Requiere administrador.
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireAdmin();
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') jsonError('Método no permitido', 405);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<?php
|
||||
/**
|
||||
* POST { id } — Elimina un médico del catálogo.
|
||||
*/
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
if (!isUserLoggedIn()) { http_response_code(401); echo json_encode(['ok'=>false,'error'=>'No autorizado']); exit; }
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<?php
|
||||
/**
|
||||
* GET — Lista de médicos solicitantes.
|
||||
*/
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
if (!isUserLoggedIn()) { http_response_code(401); echo json_encode(['ok'=>false,'error'=>'No autorizado']); exit; }
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<?php
|
||||
/**
|
||||
* POST — Crea o actualiza un médico del catálogo.
|
||||
*/
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
if (!isUserLoggedIn()) { http_response_code(401); echo json_encode(['ok'=>false,'error'=>'No autorizado']); exit; }
|
||||
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
<?php
|
||||
/**
|
||||
* modules/soporte/DocIndex.php
|
||||
* Descubre los documentos en docs/, arma el árbol de navegación y resuelve el
|
||||
* control de acceso por sección.
|
||||
*
|
||||
* Convenciones:
|
||||
* docs/<seccion>/<NN>-<slug>.md → el prefijo NN solo ordena, no se muestra
|
||||
* El título sale del primer encabezado `# ` del archivo.
|
||||
*
|
||||
* Un documento puede restringir su visibilidad con una cabecera al inicio:
|
||||
*
|
||||
* ---
|
||||
* roles: enfermero, recepcionista
|
||||
* ---
|
||||
*
|
||||
* Sin cabecera, hereda el permiso de su sección. Los administradores ven todo.
|
||||
*/
|
||||
|
||||
final class DocIndex
|
||||
{
|
||||
/** Secciones, en orden de aparición. `admin` = solo administradores. */
|
||||
public const SECCIONES = [
|
||||
'manual' => ['titulo' => 'Manual de usuario', 'icono' => 'fas fa-book-reader', 'admin' => false,
|
||||
'resumen' => 'Cómo usar el sistema, paso a paso, según su rol.'],
|
||||
'tecnica' => ['titulo' => 'Documentación técnica','icono' => 'fas fa-code', 'admin' => true,
|
||||
'resumen' => 'Cada módulo por dentro: tablas, endpoints y dependencias.'],
|
||||
'arquitectura'=> ['titulo' => 'Arquitectura', 'icono' => 'fas fa-sitemap', 'admin' => true,
|
||||
'resumen' => 'Cómo está armado el sistema y por qué.'],
|
||||
'operacion' => ['titulo' => 'Operación y soporte', 'icono' => 'fas fa-life-ring', 'admin' => true,
|
||||
'resumen' => 'Qué hacer cuando algo falla. Configuraciones críticas.'],
|
||||
];
|
||||
|
||||
/** Roles que ven todo, sin importar lo que declare cada documento. */
|
||||
public const ROLES_TOTALES = ['admin', 'superadmin'];
|
||||
|
||||
public static function dir(): string
|
||||
{
|
||||
return __DIR__ . '/docs';
|
||||
}
|
||||
|
||||
private static function rolActual(): string
|
||||
{
|
||||
return $_SESSION['admin_user']['role'] ?? '';
|
||||
}
|
||||
|
||||
private static function esAdmin(): bool
|
||||
{
|
||||
return in_array(self::rolActual(), self::ROLES_TOTALES, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Separa la cabecera del cuerpo de un documento.
|
||||
* @return array{0: array<string,string>, 1: string} [metadatos, cuerpo]
|
||||
*/
|
||||
public static function leer(string $archivo): array
|
||||
{
|
||||
$texto = (string)@file_get_contents($archivo);
|
||||
if (!preg_match('/^---\R(.*?)\R---\R?(.*)$/s', $texto, $m)) {
|
||||
return [[], $texto];
|
||||
}
|
||||
$meta = [];
|
||||
foreach (preg_split('/\R/', $m[1]) as $linea) {
|
||||
if (preg_match('/^\s*([\w-]+)\s*:\s*(.*)$/', $linea, $kv)) {
|
||||
$meta[strtolower($kv[1])] = trim($kv[2]);
|
||||
}
|
||||
}
|
||||
return [$meta, ltrim($m[2])];
|
||||
}
|
||||
|
||||
/** ¿El usuario actual puede ver este documento concreto? */
|
||||
public static function puedeVerDoc(string $seccion, string $archivo): bool
|
||||
{
|
||||
if (self::esAdmin()) return true;
|
||||
if (!self::puedeVer($seccion)) return false;
|
||||
|
||||
[$meta] = self::leer($archivo);
|
||||
if (empty($meta['roles'])) return true; // sin cabecera: hereda la sección
|
||||
|
||||
$permitidos = array_filter(array_map('trim', explode(',', strtolower($meta['roles']))));
|
||||
return in_array(strtolower(self::rolActual()), $permitidos, true);
|
||||
}
|
||||
|
||||
/** ¿El usuario actual puede ver esta sección? */
|
||||
public static function puedeVer(string $seccion): bool
|
||||
{
|
||||
$cfg = self::SECCIONES[$seccion] ?? null;
|
||||
if (!$cfg) return false;
|
||||
if (!$cfg['admin']) return true;
|
||||
return self::esAdmin();
|
||||
}
|
||||
|
||||
/** Secciones visibles para el usuario actual. */
|
||||
public static function seccionesVisibles(): array
|
||||
{
|
||||
return array_filter(
|
||||
self::SECCIONES,
|
||||
fn($s) => self::puedeVer($s),
|
||||
ARRAY_FILTER_USE_KEY
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Árbol completo: [seccion => ['titulo'=>..,'docs'=>[['slug','titulo','archivo'],...]]]
|
||||
* Solo incluye lo que el usuario puede ver.
|
||||
*/
|
||||
public static function arbol(): array
|
||||
{
|
||||
$arbol = [];
|
||||
foreach (self::seccionesVisibles() as $sec => $cfg) {
|
||||
$ruta = self::dir() . '/' . $sec;
|
||||
if (!is_dir($ruta)) continue;
|
||||
|
||||
$docs = [];
|
||||
foreach (glob($ruta . '/*.md') ?: [] as $archivo) {
|
||||
if (!self::puedeVerDoc($sec, $archivo)) continue;
|
||||
$base = basename($archivo, '.md');
|
||||
$slug = preg_replace('/^\d+-/', '', $base);
|
||||
$docs[] = [
|
||||
'slug' => $slug,
|
||||
'titulo' => self::titulo($archivo, $slug),
|
||||
'archivo' => $archivo,
|
||||
'orden' => $base,
|
||||
];
|
||||
}
|
||||
usort($docs, fn($a, $b) => strcmp($a['orden'], $b['orden']));
|
||||
|
||||
// Una sección sin documentos visibles no se muestra
|
||||
if (!$docs) continue;
|
||||
|
||||
$arbol[$sec] = $cfg + ['docs' => $docs];
|
||||
}
|
||||
return $arbol;
|
||||
}
|
||||
|
||||
/** Ruta del archivo pedido, o null si no existe o no hay acceso. */
|
||||
public static function resolver(string $seccion, string $slug): ?string
|
||||
{
|
||||
if (!self::puedeVer($seccion)) return null;
|
||||
// Evitar traversal: los slugs son [a-z0-9-]
|
||||
if (!preg_match('/^[a-z0-9-]+$/', $seccion) || !preg_match('/^[a-z0-9-]+$/', $slug)) return null;
|
||||
|
||||
foreach (glob(self::dir() . '/' . $seccion . '/*.md') ?: [] as $archivo) {
|
||||
if (preg_replace('/^\d+-/', '', basename($archivo, '.md')) !== $slug) continue;
|
||||
return self::puedeVerDoc($seccion, $archivo) ? $archivo : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Primer encabezado `# ` del archivo; si no hay, el slug capitalizado. */
|
||||
private static function titulo(string $archivo, string $slugFallback): string
|
||||
{
|
||||
$fh = @fopen($archivo, 'r');
|
||||
if ($fh) {
|
||||
$lineas = 0;
|
||||
while (($l = fgets($fh)) !== false && $lineas++ < 30) {
|
||||
if (preg_match('/^#\s+(.+)$/', trim($l), $m)) { fclose($fh); return trim($m[1]); }
|
||||
}
|
||||
fclose($fh);
|
||||
}
|
||||
return ucfirst(str_replace('-', ' ', $slugFallback));
|
||||
}
|
||||
|
||||
/** Palabras que no aportan al puntaje de relevancia. */
|
||||
private const VACIAS = [
|
||||
'como','cual','cuales','donde','cuando','porque','para','pero','esta','este','esto',
|
||||
'con','sin','por','que','del','las','los','una','uno','del','sus','sobre','desde',
|
||||
'hacer','tengo','puedo','quiero','necesito','ayuda','favor','the','and','not','del',
|
||||
];
|
||||
|
||||
/** Secciones que puede consultar el asistente. Ver contextoIA(). */
|
||||
public const SECCIONES_IA = ['manual'];
|
||||
|
||||
/**
|
||||
* Selecciona los documentos más relevantes para una pregunta y devuelve su
|
||||
* texto, listo para dárselo a un modelo de lenguaje.
|
||||
*
|
||||
* Dos restricciones se aplican a la vez:
|
||||
*
|
||||
* 1. Por sección: el asistente solo consulta el manual de usuario. Está
|
||||
* para ayudar a usar el sistema, no a mantenerlo — la documentación
|
||||
* técnica, de arquitectura y de operación queda fuera incluso para
|
||||
* administradores, que pueden leerla directamente en el módulo.
|
||||
* 2. Por rol: dentro del manual solo entra lo que ese usuario podría leer
|
||||
* por su cuenta, así el asistente no puede revelar contenido ajeno.
|
||||
*
|
||||
* @return array{0: string, 1: array<int,string>} [contexto, títulos usados]
|
||||
*/
|
||||
public static function contextoIA(string $pregunta, int $maxDocs = 4, int $maxChars = 14000): array
|
||||
{
|
||||
$palabras = array_values(array_filter(
|
||||
preg_split('/[^a-záéíóúñü0-9]+/u', mb_strtolower($pregunta)),
|
||||
fn($p) => mb_strlen($p) > 2 && !in_array($p, self::VACIAS, true)
|
||||
));
|
||||
if (!$palabras) return ['', []];
|
||||
|
||||
// Textos de los documentos consultables
|
||||
$docs = [];
|
||||
foreach (self::arbol() as $sec => $cfg) {
|
||||
if (!in_array($sec, self::SECCIONES_IA, true)) continue;
|
||||
foreach ($cfg['docs'] as $doc) {
|
||||
[, $cuerpo] = self::leer($doc['archivo']);
|
||||
$docs[] = [
|
||||
'titulo' => $doc['titulo'],
|
||||
'seccion' => $cfg['titulo'],
|
||||
'cuerpo' => $cuerpo,
|
||||
'heno' => mb_strtolower($doc['titulo'] . ' ' . $cuerpo),
|
||||
];
|
||||
}
|
||||
}
|
||||
if (!$docs) return ['', []];
|
||||
|
||||
// Peso de cada palabra según en cuántos documentos aparece: una que está
|
||||
// en todos ("paciente") no distingue nada; una que está en pocos sí.
|
||||
$total = count($docs);
|
||||
$peso = [];
|
||||
foreach ($palabras as $p) {
|
||||
$enCuantos = 0;
|
||||
foreach ($docs as $d) if (str_contains($d['heno'], $p)) $enCuantos++;
|
||||
$peso[$p] = $enCuantos ? log(1 + $total / $enCuantos) : 0;
|
||||
}
|
||||
|
||||
$candidatos = [];
|
||||
foreach ($docs as $d) {
|
||||
$puntaje = 0.0;
|
||||
foreach ($palabras as $p) {
|
||||
if (!$peso[$p]) continue;
|
||||
// El título pesa mucho más que una mención en el cuerpo
|
||||
$puntaje += substr_count(mb_strtolower($d['titulo']), $p) * 12 * $peso[$p];
|
||||
$puntaje += min(substr_count($d['heno'], $p), 6) * $peso[$p];
|
||||
}
|
||||
if ($puntaje > 0) $candidatos[] = ['puntaje' => $puntaje] + $d;
|
||||
}
|
||||
if (!$candidatos) return ['', []];
|
||||
|
||||
usort($candidatos, fn($a, $b) => $b['puntaje'] <=> $a['puntaje']);
|
||||
|
||||
// Umbral absoluto: por debajo son coincidencias sueltas de palabras
|
||||
// sin relación real con la pregunta, y darle eso al modelo lo lleva a
|
||||
// responder con lo que tenga a mano en vez de admitir que no sabe.
|
||||
if ($candidatos[0]['puntaje'] < 6) return ['', []];
|
||||
|
||||
// Y relativo: descartar lo que quede muy por debajo del mejor resultado
|
||||
$corte = $candidatos[0]['puntaje'] * 0.45;
|
||||
$candidatos = array_values(array_filter($candidatos, fn($c) => $c['puntaje'] >= $corte));
|
||||
$candidatos = array_slice($candidatos, 0, $maxDocs);
|
||||
|
||||
$porDoc = (int)floor($maxChars / count($candidatos));
|
||||
$ctx = '';
|
||||
$titulos = [];
|
||||
foreach ($candidatos as $c) {
|
||||
$texto = preg_replace('/\{\{\w+\}\}/', '', $c['cuerpo']); // los marcadores no aportan
|
||||
$texto = mb_substr(trim($texto), 0, $porDoc);
|
||||
$ctx .= "\n\n===== [{$c['seccion']}] {$c['titulo']} =====\n" . $texto;
|
||||
$titulos[] = $c['titulo'];
|
||||
}
|
||||
return [trim($ctx), $titulos];
|
||||
}
|
||||
|
||||
/**
|
||||
* Índice para el buscador: un registro por documento con su texto plano.
|
||||
* Solo incluye secciones visibles para el usuario actual.
|
||||
*/
|
||||
public static function indiceBusqueda(): array
|
||||
{
|
||||
$out = [];
|
||||
foreach (self::arbol() as $sec => $cfg) {
|
||||
foreach ($cfg['docs'] as $doc) {
|
||||
[, $texto] = self::leer($doc['archivo']);
|
||||
// Aplanar: sin marcas, sin saltos, compacto
|
||||
$texto = preg_replace('/```.*?```/s', ' ', $texto);
|
||||
$texto = preg_replace('/[#>*_`|-]+/', ' ', $texto);
|
||||
$texto = preg_replace('/\s+/', ' ', $texto);
|
||||
$out[] = [
|
||||
's' => $sec,
|
||||
'u' => $doc['slug'],
|
||||
't' => $doc['titulo'],
|
||||
'c' => $cfg['titulo'],
|
||||
'x' => mb_substr(trim($texto), 0, 4000),
|
||||
];
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
<?php
|
||||
/**
|
||||
* modules/soporte/Generadores.php
|
||||
* Secciones de la documentación que se leen del código y de la base de datos
|
||||
* en cada carga, en vez de escribirse a mano.
|
||||
*
|
||||
* El objetivo es que los inventarios (módulos, endpoints, tablas, roles) no
|
||||
* puedan quedar desactualizados: si alguien agrega un módulo o una tabla,
|
||||
* aparece aquí sin que nadie tenga que acordarse de editar un .md.
|
||||
*
|
||||
* Se invocan desde los documentos con marcadores en una línea propia:
|
||||
* {{modulos}} {{endpoints}} {{tablas}} {{roles}} {{servicios}}
|
||||
*/
|
||||
|
||||
final class Generadores
|
||||
{
|
||||
/** Reemplaza los marcadores {{...}} de un documento por su tabla generada. */
|
||||
public static function expandir(string $md): string
|
||||
{
|
||||
return preg_replace_callback('/^\{\{(\w+)\}\}\s*$/m', function ($m) {
|
||||
$metodo = 'gen' . ucfirst($m[1]);
|
||||
if (!method_exists(self::class, $metodo)) return $m[0];
|
||||
try {
|
||||
return self::$metodo();
|
||||
} catch (\Throwable $e) {
|
||||
return '> No se pudo generar esta sección: ' . $e->getMessage();
|
||||
}
|
||||
}, $md);
|
||||
}
|
||||
|
||||
private static function pdo(): PDO
|
||||
{
|
||||
return Database::getInstance()->getConnection();
|
||||
}
|
||||
|
||||
private static function raiz(): string
|
||||
{
|
||||
return dirname(__DIR__, 2);
|
||||
}
|
||||
|
||||
// ── Módulos registrados ───────────────────────────────────────────
|
||||
private static function genModulos(): string
|
||||
{
|
||||
$dirs = glob(self::raiz() . '/modules/*', GLOB_ONLYDIR) ?: [];
|
||||
$filas = [];
|
||||
foreach ($dirs as $dir) {
|
||||
$slug = basename($dir);
|
||||
$meta = ['name' => $slug, 'description' => '', 'oleada' => ''];
|
||||
$mf = $dir . '/module.php';
|
||||
if (is_file($mf)) {
|
||||
try {
|
||||
$m = @include $mf;
|
||||
if (is_array($m)) $meta = $m + $meta;
|
||||
} catch (\Throwable $e) { /* un module.php con contexto no cargable no debe romper la doc */ }
|
||||
}
|
||||
$vistas = count(glob($dir . '/views/*.php') ?: []);
|
||||
$apis = count(glob($dir . '/api/*.php') ?: []);
|
||||
$enSys = defined('SYSTEM_MODULES') && array_key_exists($slug, SYSTEM_MODULES);
|
||||
$filas[] = [
|
||||
'`' . $slug . '`',
|
||||
$meta['name'] ?? $slug,
|
||||
$vistas ?: '—',
|
||||
$apis ?: '—',
|
||||
$enSys ? 'sí' : 'no',
|
||||
$meta['description'] ?? '',
|
||||
];
|
||||
}
|
||||
usort($filas, fn($a, $b) => strcmp($a[0], $b[0]));
|
||||
return self::tabla(
|
||||
['Slug', 'Nombre', 'Vistas', 'Endpoints', 'En SYSTEM_MODULES', 'Descripción'],
|
||||
$filas
|
||||
) . "\n\n_" . count($filas) . " módulos en `modules/`. Generado del filesystem._\n";
|
||||
}
|
||||
|
||||
// ── Endpoints por módulo ──────────────────────────────────────────
|
||||
private static function genEndpoints(): string
|
||||
{
|
||||
$out = '';
|
||||
foreach (glob(self::raiz() . '/modules/*/api', GLOB_ONLYDIR) ?: [] as $dir) {
|
||||
$slug = basename(dirname($dir));
|
||||
$files = glob($dir . '/*.php') ?: [];
|
||||
if (!$files) continue;
|
||||
sort($files);
|
||||
|
||||
$filas = [];
|
||||
foreach ($files as $f) {
|
||||
$nombre = basename($f);
|
||||
if (str_starts_with($nombre, '_')) continue; // helpers internos
|
||||
$filas[] = ['`' . $nombre . '`', self::resumenPhpDoc($f)];
|
||||
}
|
||||
if (!$filas) continue;
|
||||
$out .= "\n### " . $slug . "\n\n" . self::tabla(['Endpoint', 'Qué hace'], $filas) . "\n";
|
||||
}
|
||||
return $out ?: '> Sin endpoints.';
|
||||
}
|
||||
|
||||
// ── Tablas de la base de datos ────────────────────────────────────
|
||||
private static function genTablas(): string
|
||||
{
|
||||
$rows = self::pdo()->query(
|
||||
"SELECT table_name, table_rows, table_comment
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE() AND table_type = 'BASE TABLE'
|
||||
ORDER BY table_name"
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Agrupar por prefijo para que se lea por dominio
|
||||
$grupos = [];
|
||||
foreach ($rows as $r) {
|
||||
$t = $r['table_name'];
|
||||
$pfx = str_contains($t, '_') ? explode('_', $t)[0] : 'otras';
|
||||
$grupos[$pfx][] = $r;
|
||||
}
|
||||
ksort($grupos);
|
||||
|
||||
$out = '';
|
||||
foreach ($grupos as $pfx => $tablas) {
|
||||
$out .= "\n### " . $pfx . "\n\n";
|
||||
$filas = [];
|
||||
foreach ($tablas as $t) {
|
||||
$cols = self::pdo()->prepare(
|
||||
"SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = ?"
|
||||
);
|
||||
$cols->execute([$t['table_name']]);
|
||||
$filas[] = [
|
||||
'`' . $t['table_name'] . '`',
|
||||
(string)(int)$cols->fetchColumn(),
|
||||
number_format((int)$t['table_rows'], 0, ',', '.'),
|
||||
$t['table_comment'] ?: '',
|
||||
];
|
||||
}
|
||||
$out .= self::tabla(['Tabla', 'Columnas', 'Filas aprox.', 'Comentario'], $filas) . "\n";
|
||||
}
|
||||
return $out . "\n_" . count($rows) . " tablas. Generado de `information_schema`; el conteo de filas es una estimación de InnoDB._\n";
|
||||
}
|
||||
|
||||
// ── Roles y permisos efectivos ────────────────────────────────────
|
||||
private static function genRoles(): string
|
||||
{
|
||||
$roles = self::pdo()->query(
|
||||
"SELECT r.id, r.slug, r.name, r.description,
|
||||
(SELECT COUNT(*) FROM admin_users u WHERE u.role_id = r.id AND u.is_active = 1) AS usuarios
|
||||
FROM roles r ORDER BY r.slug"
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$mods = self::pdo()->query(
|
||||
"SELECT role_id, module_slug, permission FROM role_modules ORDER BY module_slug"
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$porRol = [];
|
||||
foreach ($mods as $m) {
|
||||
$porRol[(int)$m['role_id']][] = $m['module_slug'] . ($m['permission'] === 'read' ? ' _(solo lectura)_' : '');
|
||||
}
|
||||
|
||||
$filas = [];
|
||||
foreach ($roles as $r) {
|
||||
$lista = $porRol[(int)$r['id']] ?? [];
|
||||
$filas[] = [
|
||||
'`' . $r['slug'] . '`',
|
||||
$r['name'] ?: '',
|
||||
(string)(int)$r['usuarios'],
|
||||
$lista ? implode(', ', $lista) : '—',
|
||||
];
|
||||
}
|
||||
return self::tabla(['Rol', 'Nombre', 'Usuarios activos', 'Módulos'], $filas)
|
||||
. "\n\n_Generado de `roles` y `role_modules`. El acceso efectivo se carga al iniciar sesión desde `role_id`._\n";
|
||||
}
|
||||
|
||||
// ── Servicios y clases del núcleo ─────────────────────────────────
|
||||
private static function genServicios(): string
|
||||
{
|
||||
$out = '';
|
||||
foreach ([['core', 'Núcleo'], ['services', 'Servicios'], ['classes', 'Clases de dominio']] as [$dir, $tit]) {
|
||||
$files = glob(self::raiz() . '/' . $dir . '/*.php') ?: [];
|
||||
if (!$files) continue;
|
||||
sort($files);
|
||||
$filas = [];
|
||||
foreach ($files as $f) {
|
||||
$filas[] = ['`' . basename($f) . '`', self::resumenPhpDoc($f)];
|
||||
}
|
||||
$out .= "\n### " . $tit . " (`" . $dir . "/`)\n\n" . self::tabla(['Archivo', 'Responsabilidad'], $filas) . "\n";
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** Primera línea con contenido del bloque docblock inicial de un archivo. */
|
||||
private static function resumenPhpDoc(string $archivo): string
|
||||
{
|
||||
$fh = @fopen($archivo, 'r');
|
||||
if (!$fh) return '';
|
||||
$n = 0;
|
||||
$ruta = null;
|
||||
while (($l = fgets($fh)) !== false && $n++ < 25) {
|
||||
$l = trim($l);
|
||||
if (!str_starts_with($l, '*')) continue;
|
||||
$l = trim(ltrim($l, '*/ '));
|
||||
if ($l === '') continue;
|
||||
// Saltar la línea que solo repite la ruta del archivo
|
||||
if ($ruta === null && str_contains($l, '.php')) { $ruta = $l; continue; }
|
||||
if (str_starts_with($l, '@')) break;
|
||||
fclose($fh);
|
||||
return $l;
|
||||
}
|
||||
fclose($fh);
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Arma una tabla Markdown escapando los separadores del contenido. */
|
||||
private static function tabla(array $encabezados, array $filas): string
|
||||
{
|
||||
$esc = fn($v) => str_replace('|', '\\|', (string)$v);
|
||||
$md = '| ' . implode(' | ', array_map($esc, $encabezados)) . " |\n";
|
||||
$md .= '|' . str_repeat('---|', count($encabezados)) . "\n";
|
||||
foreach ($filas as $f) {
|
||||
$md .= '| ' . implode(' | ', array_map($esc, $f)) . " |\n";
|
||||
}
|
||||
return $md;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
<?php
|
||||
/**
|
||||
* modules/soporte/Markdown.php
|
||||
* Renderizador Markdown → HTML para la documentación del proyecto.
|
||||
*
|
||||
* Cubre el subconjunto que usa la documentación: encabezados, listas
|
||||
* (anidadas y numeradas), tablas, bloques de código, citas, reglas, enlaces,
|
||||
* énfasis y código en línea. No pretende ser CommonMark completo — se prefirió
|
||||
* un archivo propio y auditable a incorporar una dependencia externa.
|
||||
*
|
||||
* Todo el texto se escapa antes de aplicar formato, así que el contenido de
|
||||
* los .md no puede inyectar HTML.
|
||||
*/
|
||||
|
||||
final class Markdown
|
||||
{
|
||||
/** Convierte un documento Markdown completo a HTML. */
|
||||
public static function render(string $texto): string
|
||||
{
|
||||
$lineas = preg_split('/\R/', $texto);
|
||||
$html = '';
|
||||
$n = count($lineas);
|
||||
$i = 0;
|
||||
|
||||
while ($i < $n) {
|
||||
$linea = $lineas[$i];
|
||||
|
||||
// ── Bloque de código cercado ──────────────────────────────
|
||||
if (preg_match('/^```\s*([\w-]*)\s*$/', $linea, $m)) {
|
||||
$lang = $m[1];
|
||||
$buffer = [];
|
||||
$i++;
|
||||
while ($i < $n && !preg_match('/^```\s*$/', $lineas[$i])) {
|
||||
$buffer[] = $lineas[$i];
|
||||
$i++;
|
||||
}
|
||||
$i++; // cerrar
|
||||
$clase = $lang ? ' class="lang-' . htmlspecialchars($lang, ENT_QUOTES) . '"' : '';
|
||||
$html .= '<pre><code' . $clase . '>'
|
||||
. htmlspecialchars(implode("\n", $buffer), ENT_QUOTES)
|
||||
. '</code></pre>';
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Línea en blanco ───────────────────────────────────────
|
||||
if (trim($linea) === '') { $i++; continue; }
|
||||
|
||||
// ── Regla horizontal ──────────────────────────────────────
|
||||
if (preg_match('/^(-{3,}|\*{3,}|_{3,})\s*$/', $linea)) {
|
||||
$html .= '<hr>';
|
||||
$i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Encabezado ────────────────────────────────────────────
|
||||
if (preg_match('/^(#{1,6})\s+(.*)$/', $linea, $m)) {
|
||||
$nivel = strlen($m[1]);
|
||||
$texto2 = trim($m[2]);
|
||||
$slug = self::slug($texto2);
|
||||
$html .= "<h{$nivel} id=\"{$slug}\">" . self::inline($texto2) . "</h{$nivel}>";
|
||||
$i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Tabla ─────────────────────────────────────────────────
|
||||
if (strpos($linea, '|') !== false
|
||||
&& isset($lineas[$i + 1])
|
||||
&& preg_match('/^\s*\|?[\s:|-]+\|[\s:|-]*$/', $lineas[$i + 1])) {
|
||||
[$tabla, $i] = self::tabla($lineas, $i);
|
||||
$html .= $tabla;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Cita ──────────────────────────────────────────────────
|
||||
if (preg_match('/^>\s?(.*)$/', $linea)) {
|
||||
$buffer = [];
|
||||
while ($i < $n && preg_match('/^>\s?(.*)$/', $lineas[$i], $m2)) {
|
||||
$buffer[] = $m2[1];
|
||||
$i++;
|
||||
}
|
||||
$html .= '<blockquote>' . self::render(implode("\n", $buffer)) . '</blockquote>';
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Lista (con o sin numerar, admite anidación) ───────────
|
||||
if (preg_match('/^(\s*)([-*+]|\d+\.)\s+/', $linea)) {
|
||||
[$lista, $i] = self::lista($lineas, $i, 0);
|
||||
$html .= $lista;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Párrafo ───────────────────────────────────────────────
|
||||
$buffer = [];
|
||||
while ($i < $n
|
||||
&& trim($lineas[$i]) !== ''
|
||||
&& !preg_match('/^(#{1,6}\s|```|>|\s*([-*+]|\d+\.)\s|(-{3,}|\*{3,}|_{3,})\s*$)/', $lineas[$i])
|
||||
&& !(strpos($lineas[$i], '|') !== false
|
||||
&& isset($lineas[$i + 1])
|
||||
&& preg_match('/^\s*\|?[\s:|-]+\|[\s:|-]*$/', $lineas[$i + 1]))) {
|
||||
$buffer[] = $lineas[$i];
|
||||
$i++;
|
||||
}
|
||||
if ($buffer) $html .= '<p>' . self::inline(implode(' ', $buffer)) . '</p>';
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/** Construye una lista, recursivamente para los niveles anidados. */
|
||||
private static function lista(array $lineas, int $i, int $sangriaBase): array
|
||||
{
|
||||
$n = count($lineas);
|
||||
preg_match('/^(\s*)([-*+]|\d+\.)\s+/', $lineas[$i], $m0);
|
||||
$ordenada = !in_array($m0[2], ['-', '*', '+'], true);
|
||||
$tag = $ordenada ? 'ol' : 'ul';
|
||||
$html = "<{$tag}>";
|
||||
|
||||
while ($i < $n) {
|
||||
if (trim($lineas[$i]) === '') {
|
||||
// Una línea vacía corta la lista salvo que siga otro ítem
|
||||
if (isset($lineas[$i + 1]) && preg_match('/^(\s*)([-*+]|\d+\.)\s+/', $lineas[$i + 1])) {
|
||||
$i++;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (!preg_match('/^(\s*)([-*+]|\d+\.)\s+(.*)$/', $lineas[$i], $m)) break;
|
||||
|
||||
$sangria = strlen($m[1]);
|
||||
if ($sangria < $sangriaBase) break;
|
||||
|
||||
if ($sangria > $sangriaBase) {
|
||||
[$sub, $i] = self::lista($lineas, $i, $sangria);
|
||||
// Colgar la sublista del último ítem abierto
|
||||
$html = preg_replace('/<\/li>$/', '', $html) . $sub . '</li>';
|
||||
continue;
|
||||
}
|
||||
|
||||
$html .= '<li>' . self::inline($m[3]) . '</li>';
|
||||
$i++;
|
||||
}
|
||||
|
||||
return [$html . "</{$tag}>", $i];
|
||||
}
|
||||
|
||||
/** Construye una tabla a partir de la fila de encabezado. */
|
||||
private static function tabla(array $lineas, int $i): array
|
||||
{
|
||||
$n = count($lineas);
|
||||
$celdas = fn(string $l) => array_map('trim', explode('|', trim($l, " \t|")));
|
||||
|
||||
$encabezado = $celdas($lineas[$i]);
|
||||
$alineacion = array_map(function ($c) {
|
||||
$c = trim($c);
|
||||
if (str_starts_with($c, ':') && str_ends_with($c, ':')) return 'center';
|
||||
if (str_ends_with($c, ':')) return 'right';
|
||||
return 'left';
|
||||
}, $celdas($lineas[$i + 1]));
|
||||
$i += 2;
|
||||
|
||||
$html = '<div class="tabla-scroll"><table><thead><tr>';
|
||||
foreach ($encabezado as $k => $c) {
|
||||
$a = $alineacion[$k] ?? 'left';
|
||||
$html .= '<th style="text-align:' . $a . '">' . self::inline($c) . '</th>';
|
||||
}
|
||||
$html .= '</tr></thead><tbody>';
|
||||
|
||||
while ($i < $n && trim($lineas[$i]) !== '' && strpos($lineas[$i], '|') !== false) {
|
||||
$fila = $celdas($lineas[$i]);
|
||||
$html .= '<tr>';
|
||||
foreach ($encabezado as $k => $_) {
|
||||
$a = $alineacion[$k] ?? 'left';
|
||||
$html .= '<td style="text-align:' . $a . '">' . self::inline($fila[$k] ?? '') . '</td>';
|
||||
}
|
||||
$html .= '</tr>';
|
||||
$i++;
|
||||
}
|
||||
|
||||
return [$html . '</tbody></table></div>', $i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Formato dentro de una línea. Se escapa primero y el código en línea se
|
||||
* aparta con marcadores para que su contenido no reciba más formato.
|
||||
*/
|
||||
private static function inline(string $texto): string
|
||||
{
|
||||
$codigos = [];
|
||||
$texto = preg_replace_callback('/`([^`]+)`/', function ($m) use (&$codigos) {
|
||||
$codigos[] = '<code>' . htmlspecialchars($m[1], ENT_QUOTES) . '</code>';
|
||||
return "\x00" . (count($codigos) - 1) . "\x00";
|
||||
}, $texto);
|
||||
|
||||
$texto = htmlspecialchars($texto, ENT_QUOTES);
|
||||
|
||||
// Enlaces [texto](destino) — solo http(s), rutas internas y anclas
|
||||
$texto = preg_replace_callback(
|
||||
'/\[([^\]]+)\]\(([^)\s]+)\)/',
|
||||
function ($m) {
|
||||
$url = html_entity_decode($m[2], ENT_QUOTES);
|
||||
if (!preg_match('#^(https?://|/|\?|\#)#', $url)) return $m[0];
|
||||
$ext = str_starts_with($url, 'http') ? ' target="_blank" rel="noopener"' : '';
|
||||
return '<a href="' . htmlspecialchars($url, ENT_QUOTES) . '"' . $ext . '>' . $m[1] . '</a>';
|
||||
},
|
||||
$texto
|
||||
);
|
||||
|
||||
$texto = preg_replace('/\*\*([^*]+)\*\*/', '<strong>$1</strong>', $texto);
|
||||
$texto = preg_replace('/(?<![\w*])\*([^*\n]+)\*(?![\w*])/', '<em>$1</em>', $texto);
|
||||
$texto = preg_replace('/(?<![\w_])_([^_\n]+)_(?![\w_])/', '<em>$1</em>', $texto);
|
||||
|
||||
// Restaurar código en línea
|
||||
return preg_replace_callback('/\x00(\d+)\x00/', fn($m) => $codigos[(int)$m[1]] ?? '', $texto);
|
||||
}
|
||||
|
||||
/** Ancla estable para un encabezado. */
|
||||
public static function slug(string $texto): string
|
||||
{
|
||||
$t = strtr(mb_strtolower(strip_tags($texto)), [
|
||||
'á'=>'a','é'=>'e','í'=>'i','ó'=>'o','ú'=>'u','ñ'=>'n','ü'=>'u',
|
||||
]);
|
||||
$t = preg_replace('/[^a-z0-9]+/', '-', $t);
|
||||
return trim($t, '-');
|
||||
}
|
||||
|
||||
/** Extrae los encabezados h2/h3 para la tabla de contenidos lateral. */
|
||||
public static function indice(string $texto): array
|
||||
{
|
||||
$out = [];
|
||||
foreach (preg_split('/\R/', $texto) as $l) {
|
||||
if (preg_match('/^(#{2,3})\s+(.*)$/', $l, $m)) {
|
||||
$t = trim($m[2]);
|
||||
$out[] = ['nivel' => strlen($m[1]), 'texto' => $t, 'slug' => self::slug($t)];
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
/**
|
||||
* modules/soporte/api/ai_docs.php
|
||||
* POST { pregunta: string, historial?: [{rol,texto},...] }
|
||||
*
|
||||
* LIA respondiendo únicamente sobre la documentación del sistema. El contexto
|
||||
* lo arma DocIndex, que filtra por rol: solo entra lo que este usuario podría
|
||||
* leer por su cuenta en el módulo Soporte. No accede a datos de pacientes ni
|
||||
* de la operación del día.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once __DIR__ . '/../../../services/GeminiService.php';
|
||||
require_once __DIR__ . '/../DocIndex.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
function salir(array $datos, int $codigo = 200): void
|
||||
{
|
||||
http_response_code($codigo);
|
||||
echo json_encode($datos, JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!isUserLoggedIn()) salir(['ok' => false, 'error' => 'No autenticado'], 401);
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') salir(['ok' => false, 'error' => 'Método no permitido'], 405);
|
||||
|
||||
$body = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||
$pregunta = trim($body['pregunta'] ?? '');
|
||||
|
||||
if ($pregunta === '') salir(['ok' => false, 'error' => 'Pregunta vacía'], 400);
|
||||
if (mb_strlen($pregunta) > 800) salir(['ok' => false, 'error' => 'Pregunta demasiado larga'], 400);
|
||||
|
||||
$gemini = new GeminiService(Database::getInstance()->getConnection());
|
||||
|
||||
if (!$gemini->hayClave()) {
|
||||
salir(['ok' => false, 'error' => 'El asistente no está configurado. Avisa a un administrador.'], 503);
|
||||
}
|
||||
if ($gemini->presupuestoAgotado()) {
|
||||
salir(['ok' => false, 'error' => '⚠️ Se agotaron los tokens de LIA. Solicita tokens a soporte para continuar.'], 402);
|
||||
}
|
||||
|
||||
// ── Documentación visible para este usuario, acotada a la pregunta ──
|
||||
[$contexto, $titulos] = DocIndex::contextoIA($pregunta);
|
||||
|
||||
if ($contexto === '') {
|
||||
salir([
|
||||
'ok' => true,
|
||||
'respuesta' => 'Eso no está en el manual de usuario. Pruebe con otras palabras, '
|
||||
. 'o revise el índice de la izquierda: puede estar en otra sección '
|
||||
. 'de la documentación.',
|
||||
'fuentes' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Historial de la conversación ───────────────────────────────
|
||||
$historial = [];
|
||||
foreach (array_slice(is_array($body['historial'] ?? null) ? $body['historial'] : [], -6) as $h) {
|
||||
$texto = trim((string)($h['texto'] ?? ''));
|
||||
if ($texto === '') continue;
|
||||
$historial[] = [
|
||||
'role' => (($h['rol'] ?? '') === 'model') ? 'model' : 'user',
|
||||
'parts' => [['text' => mb_substr($texto, 0, 1500)]],
|
||||
];
|
||||
}
|
||||
|
||||
$prompt = <<<PROMPT
|
||||
Eres LIA, la asistente de documentación del ERP del Laboratorio Clínico Ximena Caicedo.
|
||||
|
||||
Responde preguntas sobre CÓMO USAR el sistema, apoyándose ÚNICAMENTE en el manual de usuario
|
||||
incluido abajo. Reglas:
|
||||
|
||||
- Si la respuesta no está en la documentación, dilo con claridad. No inventes ni supongas.
|
||||
- Solo conoce el manual de usuario, no la documentación técnica ni la de operación. Si le
|
||||
preguntan algo técnico (base de datos, código, configuración interna), diga que eso está en
|
||||
la documentación del módulo Soporte y que lo consulte allí.
|
||||
- El manual incluido es el que este usuario tiene permitido consultar. No mencione ni deduzca
|
||||
la existencia de contenido que no esté aquí.
|
||||
- Responde en español, tratando SIEMPRE de usted (nunca tú ni vos). Directo y práctico:
|
||||
si es un procedimiento, enumera los pasos.
|
||||
- Usa tablas o viñetas cuando ayuden a leer.
|
||||
- No inventes rutas, nombres de botones ni consultas SQL que no aparezcan en la documentación.
|
||||
|
||||
DOCUMENTACIÓN DISPONIBLE:
|
||||
{$contexto}
|
||||
PROMPT;
|
||||
|
||||
try {
|
||||
$r = $gemini->preguntar($prompt, $pregunta, $historial);
|
||||
salir([
|
||||
'ok' => true,
|
||||
'respuesta' => $r['respuesta'],
|
||||
'truncada' => $r['truncada'],
|
||||
'fuentes' => $titulos,
|
||||
'tokens_usados' => $r['tokens_usados'],
|
||||
'tokens_restantes' => $r['tokens_restantes'],
|
||||
'tokens_max' => $r['tokens_max'],
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
salir(['ok' => false, 'error' => $e->getMessage()], 502);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
# Visión general
|
||||
|
||||
Este sistema es el ERP del **Laboratorio Clínico Ximena Caicedo**. Nació como un bot de WhatsApp y creció hasta cubrir la operación diaria del laboratorio: turnos presenciales, toma de muestras, domicilios, órdenes médicas, formularios firmados digitalmente y facturación del día.
|
||||
|
||||
## Qué resuelve
|
||||
|
||||
| Área | Qué hace el sistema |
|
||||
|---|---|
|
||||
| Atención por WhatsApp | Bot que responde, agenda, envía consentimientos y encuestas |
|
||||
| Turnero presencial | Kiosko, recepción, estaciones de toma de muestras, pantallas de TV |
|
||||
| Domicilios | Agendamiento y asignación de enfermeros a visitas domiciliarias |
|
||||
| Formularios | Consentimientos y fichas clínicas firmadas digitalmente |
|
||||
| Laboratorio | Pacientes, órdenes médicas, exámenes, EPS, empresas, médicos |
|
||||
|
||||
## Las tres capas
|
||||
|
||||
El código está organizado en tres niveles, de lo más general a lo más específico:
|
||||
|
||||
```
|
||||
erp.php punto de entrada único del ERP
|
||||
└── core/App.php arranque, sesión, enrutamiento, control de acceso
|
||||
└── modules/<slug>/views/<vista>.php la pantalla concreta
|
||||
└── modules/<slug>/api/*.php endpoints que consume por fetch
|
||||
```
|
||||
|
||||
Debajo de todo eso están los **servicios** (`services/`), que encapsulan lo que habla con el mundo exterior — sobre todo la API de WhatsApp — y las **clases de dominio** (`classes/lab/`), que concentran las reglas de negocio de pacientes, domicilios, órdenes y formularios.
|
||||
|
||||
## Convivencia con el sistema anterior
|
||||
|
||||
Hay dos generaciones de código funcionando a la vez, y es intencional:
|
||||
|
||||
- **Archivos sueltos en la raíz** (`lab_domicilios.php`, `index.php`, `ver_formulario_enviado.php`, …). Es el sistema original. Siguen siendo el código real de muchas pantallas.
|
||||
- **Módulos en `modules/`**. Es la estructura nueva. Algunos módulos son pantallas completas (turnero, registro de exámenes); otros son apenas un puente que incluye el archivo viejo.
|
||||
|
||||
Un ejemplo de puente, `modules/lab_domicilios/views/index.php`:
|
||||
|
||||
```php
|
||||
require_once APP_ROOT . '/lab_domicilios.php';
|
||||
```
|
||||
|
||||
La migración es gradual y a propósito: mover una pantalla al nuevo esquema no obliga a mover las demás. Al leer el código, **el archivo de la raíz suele ser el que manda**; el módulo solo aporta el registro en el menú y el control de acceso.
|
||||
|
||||
## Stack
|
||||
|
||||
| Componente | Detalle |
|
||||
|---|---|
|
||||
| Lenguaje | PHP 7.4+ (en producción corre sobre versiones más recientes) |
|
||||
| Base de datos | MariaDB 11.8 |
|
||||
| Frontend | HTML server-side + JavaScript sin framework; Bootstrap 5 y Font Awesome |
|
||||
| Mensajería | WhatsApp Cloud API (Meta) |
|
||||
| IA | Google Gemini Flash — asistente LIA del dashboard del turnero |
|
||||
| Dependencias | Predis, Monolog, Guzzle, phpdotenv (vía Composer) |
|
||||
|
||||
No hay build step ni framework de frontend: las vistas son PHP que emite HTML y el JavaScript va embebido en la misma vista. Es deliberado — mantiene el despliegue en un simple `git pull`.
|
||||
|
||||
## Por dónde seguir
|
||||
|
||||
- [Enrutamiento y módulos](?m=soporte&v=documentacion&s=arquitectura&d=enrutamiento) — cómo una URL llega a una pantalla
|
||||
- [Roles y permisos](?m=soporte&v=documentacion&s=arquitectura&d=roles-y-permisos) — quién ve qué
|
||||
- [Modelo de datos](?m=soporte&v=documentacion&s=arquitectura&d=modelo-de-datos) — las 91 tablas, agrupadas
|
||||
- [Integración con WhatsApp](?m=soporte&v=documentacion&s=arquitectura&d=whatsapp) — el punto más delicado del sistema
|
||||
@@ -0,0 +1,123 @@
|
||||
# Enrutamiento y módulos
|
||||
|
||||
Cómo una URL termina ejecutando una pantalla concreta, y qué hace falta para agregar un módulo nuevo.
|
||||
|
||||
## El recorrido de una petición
|
||||
|
||||
```
|
||||
GET /erp.php?m=turnero&v=historial
|
||||
│
|
||||
├── erp.php define APP_ROOT y llama App::run()
|
||||
│
|
||||
├── App::boot() carga config, abre sesión, fija zona horaria
|
||||
│
|
||||
├── Router decide módulo y vista
|
||||
│ ├── 1º intenta la ruta limpia: /turnero/historial
|
||||
│ └── 2º cae a los parámetros: ?m=turnero&v=historial
|
||||
│
|
||||
├── Router::resolveFile()
|
||||
│ └── modules/turnero/views/historial.php ¿existe? si no → 404
|
||||
│
|
||||
├── Rbac::hasModule('turnero') ¿tiene acceso? si no → 403
|
||||
│
|
||||
└── include del archivo de la vista
|
||||
```
|
||||
|
||||
El punto clave: **la ruta es literalmente la ubicación del archivo**. `?m=turnero&v=historial` carga `modules/turnero/views/historial.php`. No hay tabla de rutas ni configuración intermedia.
|
||||
|
||||
## Validación de la URL
|
||||
|
||||
`Router` acepta como módulo y vista solo `[a-zA-Z0-9_]`, máximo 64 caracteres. Cualquier cosa fuera de ese patrón se descarta silenciosamente y se reemplaza por el valor por defecto (`dashboard` / `index`). Eso cierra la puerta a recorrer directorios con `../`.
|
||||
|
||||
## Rutas públicas
|
||||
|
||||
Casi todo exige sesión. Las excepciones están fijas en `core/Router.php`:
|
||||
|
||||
| Ruta | Por qué es pública |
|
||||
|---|---|
|
||||
| `turnero/display` | Pantalla de TV en sala de espera; no hay quién inicie sesión |
|
||||
| `turnero/kiosko` | El paciente saca su turno solo |
|
||||
|
||||
Cualquier otra combinación pasa por el control de acceso.
|
||||
|
||||
> Ojo: `isPublic()` solo omite la verificación **de módulo**. La sesión se maneja aparte, dentro de cada vista.
|
||||
|
||||
## Registrar un módulo nuevo
|
||||
|
||||
Hacen falta tres cosas. Si falta alguna, el módulo no aparece o da 403.
|
||||
|
||||
**1. La carpeta y al menos una vista**
|
||||
|
||||
```
|
||||
modules/mimodulo/
|
||||
module.php
|
||||
views/index.php
|
||||
api/ (opcional)
|
||||
```
|
||||
|
||||
**2. El descriptor `module.php`** — devuelve un arreglo:
|
||||
|
||||
```php
|
||||
<?php return [
|
||||
'slug' => 'mimodulo',
|
||||
'name' => 'Mi Módulo',
|
||||
'icon' => 'fas fa-cube',
|
||||
'category' => 'lab',
|
||||
'route' => '/erp.php?m=mimodulo&v=index',
|
||||
'is_active' => true,
|
||||
'sort_order' => 50,
|
||||
'description' => 'Para qué sirve',
|
||||
'links' => [
|
||||
['name' => 'Inicio', 'icon' => 'fas fa-home', 'route' => '/erp.php?m=mimodulo&v=index'],
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
`links` son las entradas que salen en el menú lateral. El descriptor se ejecuta como PHP, así que puede armar los enlaces según el rol de quien mira — el turnero lo hace: muestra escritorios distintos a recepcionistas y bacteriólogos.
|
||||
|
||||
**3. El registro en `SYSTEM_MODULES`** (`config/config.php`)
|
||||
|
||||
```php
|
||||
define('SYSTEM_MODULES', [
|
||||
...
|
||||
'mimodulo' => 'Mi Módulo',
|
||||
]);
|
||||
```
|
||||
|
||||
Estar acá es lo que **activa la verificación de permisos**. Un módulo ausente de esta lista no se valida y queda accesible para cualquier sesión.
|
||||
|
||||
**4. Dar acceso a los roles** — sin esto, todos reciben 403:
|
||||
|
||||
```sql
|
||||
INSERT INTO role_modules (role_id, module_slug, permission, can_view)
|
||||
SELECT id, 'mimodulo', 'write', 1 FROM roles WHERE slug IN ('admin','superadmin');
|
||||
```
|
||||
|
||||
> Los módulos de la sesión se cargan **al iniciar sesión**, desde `role_id`. Después de tocar `role_modules`, el usuario afectado tiene que volver a entrar para que el cambio surta efecto.
|
||||
|
||||
## Módulos actuales
|
||||
|
||||
{{modulos}}
|
||||
|
||||
## Vistas y layout
|
||||
|
||||
Una vista se escribe así:
|
||||
|
||||
```php
|
||||
require_once APP_ROOT . '/config/config.php';
|
||||
if (!isUserLoggedIn()) { header('Location: ' . BASE_URL . 'login.php'); exit; }
|
||||
|
||||
Layout::open('Título de la pantalla', 'fas fa-icono');
|
||||
// HTML, CSS y JS de la pantalla
|
||||
Layout::close();
|
||||
```
|
||||
|
||||
`Layout::open()` emite el `<head>`, la barra superior y el menú lateral — que construye leyendo los `module.php` de los módulos a los que el usuario tiene acceso. `Layout::close()` cierra el documento.
|
||||
|
||||
## Endpoints
|
||||
|
||||
Cada módulo puede tener su carpeta `api/`. Son archivos PHP sueltos que devuelven JSON y se consumen por `fetch` desde las vistas. No pasan por `Router`: se invocan por su ruta real (`modules/turnero/api/get_historial.php`).
|
||||
|
||||
Por convención, `api/_helpers.php` de cada módulo concentra lo común — conexión, lectura del cuerpo JSON, respuestas `jsonOk()` / `jsonError()` y la verificación de acceso.
|
||||
|
||||
Los archivos que empiezan con guión bajo son de uso interno y no se llaman directamente desde el navegador.
|
||||
@@ -0,0 +1,113 @@
|
||||
# Roles y permisos
|
||||
|
||||
Quién puede ver y hacer qué. Es el punto donde más seguido se cometen errores, así que conviene entenderlo completo.
|
||||
|
||||
## Las dos columnas de un usuario
|
||||
|
||||
En `admin_users` conviven dos campos que parecen redundantes y **no lo son**:
|
||||
|
||||
| Columna | Para qué se usa |
|
||||
|---|---|
|
||||
| `role` | Texto del rol (`admin`, `bacteriologo`, …). Lo consultan las vistas para decidir qué mostrar |
|
||||
| `role_id` | Apunta a `roles.id`. Es de donde se **cargan los módulos** al iniciar sesión |
|
||||
|
||||
> **Hay que mantener las dos sincronizadas.** Cambiar solo `role` deja al usuario con los permisos viejos, porque el acceso real sale de `role_id`. Este error ya ocurrió: un usuario cambió de rol, la interfaz mostraba el rol nuevo y los módulos seguían siendo los anteriores.
|
||||
|
||||
Al cambiar el rol de alguien, actualice las dos a la vez:
|
||||
|
||||
```sql
|
||||
UPDATE admin_users
|
||||
SET role = 'lab_recepcion',
|
||||
role_id = (SELECT id FROM roles WHERE slug = 'lab_recepcion')
|
||||
WHERE id = 12;
|
||||
```
|
||||
|
||||
## Cómo se arma el acceso al iniciar sesión
|
||||
|
||||
`authenticateUser()` (`config/config.php`) valida la contraseña y arma la sesión:
|
||||
|
||||
```
|
||||
admin_users.role_id
|
||||
└── role_modules → lista de module_slug + permission
|
||||
└── $_SESSION['admin_user']['modules'] (qué módulos ve)
|
||||
$_SESSION['admin_user']['module_permissions'] (read o write en cada uno)
|
||||
```
|
||||
|
||||
**Esto ocurre una sola vez, al entrar.** Cualquier cambio en `role_modules` no afecta a las sesiones abiertas: el usuario tiene que cerrar sesión y volver a entrar.
|
||||
|
||||
## Las dos preguntas del control de acceso
|
||||
|
||||
```php
|
||||
hasModule('lab_domicilios') // ¿puede entrar al módulo?
|
||||
hasModuleWrite('lab_domicilios') // ¿puede modificar, o solo mirar?
|
||||
```
|
||||
|
||||
- `hasModule()` mira si el slug está en la lista de módulos de la sesión.
|
||||
- `hasModuleWrite()` mira `module_permissions[slug] === 'write'`. Los administradores siempre pueden escribir.
|
||||
|
||||
Una vista típica lo usa así:
|
||||
|
||||
```php
|
||||
$puedeEscribir = hasModuleWrite('lab_domicilios');
|
||||
...
|
||||
<?php if ($puedeEscribir): ?><button>Nuevo domicilio</button><?php endif; ?>
|
||||
```
|
||||
|
||||
## La columna que manda es `permission`
|
||||
|
||||
`role_modules` tiene dos formas de expresar lo mismo, y solo una se usa:
|
||||
|
||||
| Columnas | ¿Se usan? |
|
||||
|---|---|
|
||||
| `permission` (`read` / `write`) | **Sí.** Es lo que lee `hasModuleWrite()` |
|
||||
| `can_view`, `can_create`, `can_edit`, `can_delete`, `can_export` | No las lee el control de acceso |
|
||||
|
||||
> Poner `can_edit = 0` **no impide editar**. Para dejar un módulo en solo lectura hay que fijar `permission = 'read'`. Las columnas `can_*` quedaron de un diseño anterior; conviene mantenerlas coherentes por prolijidad, pero no protegen nada.
|
||||
|
||||
Solo lectura de verdad:
|
||||
|
||||
```sql
|
||||
UPDATE role_modules SET permission = 'read'
|
||||
WHERE role_id = 1030 AND module_slug IN ('lab_domicilios', 'lab_ordenes');
|
||||
```
|
||||
|
||||
## Roles actuales
|
||||
|
||||
{{roles}}
|
||||
|
||||
## Sesiones sin `role_id`
|
||||
|
||||
Hay dos casos heredados que siguen contemplados en el código:
|
||||
|
||||
- **`modules` nulo y rol `admin`** → acceso total. Cubre usuarios anteriores al sistema de roles.
|
||||
- **Rol `enfermero` sin `role_id`** → recibe `enfermero_portal` y `lab_formularios` de forma fija.
|
||||
|
||||
## Verificaciones adicionales
|
||||
|
||||
El control por módulo no siempre alcanza. Varias pantallas agregan sus propias reglas:
|
||||
|
||||
| Dónde | Regla |
|
||||
|---|---|
|
||||
| `enfermero_portal.php` | Solo `admin`, `superadmin` y `enfermero` |
|
||||
| `api/lab/save_domicilio.php` | Un enfermero solo edita domicilios que creó **o** que tiene asignados |
|
||||
| `api/lab/firmar_profesional.php` | Un enfermero solo firma envíos propios |
|
||||
| `modules/turnero/api/_helpers.php` | `requireTurnero()` en todos los endpoints del turnero |
|
||||
| `modules/turnero/module.php` | El menú cambia según rol y según la IP del equipo |
|
||||
|
||||
Al agregar un endpoint que modifica datos, **no alcanza con confiar en que la vista ocultó el botón**: el endpoint tiene que verificar por su cuenta.
|
||||
|
||||
## Diagnóstico rápido
|
||||
|
||||
Alguien reporta que no ve un módulo o que puede editar lo que no debería:
|
||||
|
||||
```sql
|
||||
-- Qué rol tiene realmente y si 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';
|
||||
|
||||
-- Qué módulos le da ese rol
|
||||
SELECT module_slug, permission FROM role_modules WHERE role_id = <role_id>;
|
||||
```
|
||||
|
||||
Si los datos se ven bien y el usuario sigue sin acceso: **no ha vuelto a iniciar sesión**.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Modelo de datos
|
||||
|
||||
Las tablas están agrupadas por prefijo, y el prefijo dice a qué dominio pertenecen.
|
||||
|
||||
| Prefijo | Dominio |
|
||||
|---|---|
|
||||
| `lab_` | Laboratorio: pacientes, domicilios, órdenes, formularios, configuración |
|
||||
| `turnero_` | Turnos presenciales: sesiones, turnos, solicitudes, muestras, consentimientos |
|
||||
| `exam_` | Catálogo de exámenes y sus consentimientos asociados |
|
||||
| `terms_` | Términos y condiciones del bot y su historial de aceptaciones |
|
||||
| `admin_`, `roles`, `role_modules` | Usuarios y permisos |
|
||||
| resto | Conversaciones de WhatsApp, plantillas, logs, configuración del sistema |
|
||||
|
||||
## Los cuatro núcleos
|
||||
|
||||
### Turno presencial
|
||||
|
||||
Es la cadena más larga del sistema. Un paciente entra al laboratorio y genera esto:
|
||||
|
||||
```
|
||||
turnero_sesiones una fila por día de operación
|
||||
└── turnero_turnos el turno del paciente (código, estado, tiempos)
|
||||
├── turnero_solicitudes qué se le va a hacer y cuánto se cobró
|
||||
│ ├── turnero_examen_items exámenes pedidos
|
||||
│ └── turnero_muestras muestras a recibir
|
||||
├── turnero_consentimientos formularios a firmar
|
||||
└── turnero_comentarios notas del personal
|
||||
```
|
||||
|
||||
`turnero_turnos.estado` gobierna el flujo:
|
||||
|
||||
```
|
||||
espera → en_recepcion → en_espera_lugar → en_servicio → finalizado
|
||||
ausente / cancelado
|
||||
```
|
||||
|
||||
Solo los turnos **finalizados** cuentan como facturación real; los que están en estados intermedios se reportan aparte como "en proceso". Ausentes y cancelados no cuentan.
|
||||
|
||||
### Domicilio
|
||||
|
||||
```
|
||||
lab_domicilios
|
||||
├── lab_asignaciones qué enfermero lo atiende
|
||||
├── lab_domicilio_notas seguimiento
|
||||
└── lab_domicilio_pagos cobros
|
||||
```
|
||||
|
||||
### Formulario firmado
|
||||
|
||||
Un mismo formulario (`lab_formularios`) se firma por dos vías distintas, y cada una guarda en su propia tabla:
|
||||
|
||||
| Vía | Tabla | Token |
|
||||
|---|---|---|
|
||||
| Turnero | `turnero_consentimientos` | UUID (`?token=`) |
|
||||
| Domicilios y envíos sueltos | `lab_form_envios` | 64 caracteres hex (`?t=`) |
|
||||
|
||||
Las dos las muestra `ver_formulario_enviado.php`, que distingue por el **formato del token**. Es la razón de que existan dos parámetros distintos para lo que parece lo mismo.
|
||||
|
||||
La definición del formulario vive en `lab_formularios.esquema`, un JSON con la lista de campos. Las respuestas quedan en `datos_respuestas` (turnero) o `datos_cliente` (envíos), también JSON.
|
||||
|
||||
### Conversación de WhatsApp
|
||||
|
||||
```
|
||||
users / conversations el contacto y su hilo
|
||||
├── messages cada mensaje
|
||||
├── terms_acceptance aceptación de términos
|
||||
└── message_templates plantillas aprobadas por Meta (caché local)
|
||||
```
|
||||
|
||||
## Convenciones
|
||||
|
||||
- **Timestamps**: `creado_at` / `created_at` según la época en que se creó la tabla. No hay una sola convención.
|
||||
- **Autor**: `creado_por` guarda `admin_users.id`. Varias tablas lo agregaron después, así que las filas viejas lo tienen en `NULL`.
|
||||
- **Borrado**: casi todo es borrado físico. No hay *soft delete* generalizado.
|
||||
- **JSON**: se usa bastante (`esquema`, `datos_respuestas`, `pagos_detalle`, `items_precio`). Guardado como `longtext`, no como tipo `JSON` nativo.
|
||||
|
||||
## Cambios de esquema
|
||||
|
||||
Van en `migrations/`, con nombre `AAAAMMDD_descripcion.sql`. La convención del repositorio es que sean **idempotentes** — `IF NOT EXISTS` y guardas en los `UPDATE`/`INSERT` — para poder correrlas más de una vez sin daño.
|
||||
|
||||
> Un cambio aplicado directo en producción sin dejar la migración correspondiente hace que un entorno nuevo no lo tenga. Si toca el esquema, deje el archivo.
|
||||
|
||||
## Inventario completo
|
||||
|
||||
{{tablas}}
|
||||
@@ -0,0 +1,85 @@
|
||||
# Integración con WhatsApp
|
||||
|
||||
Es la parte del sistema con más piezas fuera de nuestro control. Buena parte de la configuración vive **en Meta**, no en la base de datos, y eso explica varios comportamientos que de otro modo parecen inexplicables.
|
||||
|
||||
## Dos números, una misma cuenta
|
||||
|
||||
El laboratorio opera con dos líneas sobre la misma cuenta de WhatsApp Business (WABA):
|
||||
|
||||
| Canal | Configuración | Para qué |
|
||||
|---|---|---|
|
||||
| Principal | `whatsapp_phone_number_id` | Bot de atención general |
|
||||
| Turnero | `whatsapp_phone_number_id_turnero` | Consentimientos, encuestas y avisos de turno |
|
||||
|
||||
Se elige al construir el servicio:
|
||||
|
||||
```php
|
||||
$wa = new WhatsAppService(); // línea principal
|
||||
$wa = new WhatsAppService('turnero'); // línea del turnero
|
||||
```
|
||||
|
||||
> Si un mensaje sale por el número equivocado, casi siempre es porque se instanció sin el canal. Es el mismo WABA y el mismo token: **lo único que cambia es el `phone_number_id`**.
|
||||
|
||||
## Configuración
|
||||
|
||||
Todo en `system_config`:
|
||||
|
||||
| Clave | Qué es |
|
||||
|---|---|
|
||||
| `whatsapp_token` | Token de acceso a la API |
|
||||
| `whatsapp_api_url` | URL base de la Cloud API |
|
||||
| `whatsapp_business_account_id` | Identificador del WABA |
|
||||
| `whatsapp_phone_number_id` | Número principal |
|
||||
| `whatsapp_phone_number_id_turnero` | Número del turnero |
|
||||
| `webhook_verify_token` | Verificación del webhook |
|
||||
|
||||
## Plantillas: la parte que no controlamos
|
||||
|
||||
Para escribir primero a alguien (fuera de la ventana de 24 horas) hay que usar una **plantilla aprobada por Meta**. `message_templates` guarda una copia local, pero **la copia no manda**: la versión real está en Meta.
|
||||
|
||||
Esto tiene una consecuencia importante y poco intuitiva:
|
||||
|
||||
> **Las URL de los botones viven en la plantilla, no en nuestro código.**
|
||||
|
||||
La plantilla `consentimiento_turno_v2` tiene un botón así:
|
||||
|
||||
```
|
||||
https://erp.laboratorioximenacaicedo.com/form_cliente.php?t={{1}}
|
||||
```
|
||||
|
||||
Nuestro código solo envía el token como `{{1}}`. Cambiar el código **no cambia** el enlace que recibe el paciente: hay que editar la plantilla en el WhatsApp Manager de Meta y esperar la reaprobación.
|
||||
|
||||
El único lugar donde sí armamos la URL completa es el **respaldo en texto plano**, que se usa cuando falla el envío por plantilla (`modules/turnero/api/send_consentimiento.php`).
|
||||
|
||||
## Por qué el enlace pasa por dos páginas
|
||||
|
||||
El botón apunta a `form_cliente.php?t=<UUID>`, pero el consentimiento del turnero lo muestra `ver_formulario_enviado.php?token=<UUID>`.
|
||||
|
||||
`form_cliente.php` detecta que el token tiene formato UUID —o sea, que viene del turnero— y redirige. Convive así porque la plantilla ya estaba aprobada apuntando a la página de envíos, y cambiarla obliga a otra ronda de aprobación en Meta.
|
||||
|
||||
## Términos y condiciones
|
||||
|
||||
Antes de conversar, el bot exige aceptar los términos. El usuario responde **ACEPTO** o **NO ACEPTO**.
|
||||
|
||||
La URL del documento está escrita en **dos lugares** y hay que cambiarlos juntos:
|
||||
|
||||
| Dónde | Rol |
|
||||
|---|---|
|
||||
| `terms_versions.documento_url` | El bot la adjunta al final del mensaje |
|
||||
| `system_config.terms_message` | Va escrita dentro del texto de bienvenida |
|
||||
|
||||
Se vuelve a pedir la aceptación si: nunca aceptó, pasaron más de 6 meses, o hay una versión nueva con `forzar_reenvio`.
|
||||
|
||||
## El webhook
|
||||
|
||||
Meta envía los mensajes entrantes al webhook, que los registra y se los pasa a `BotService`. Ahí se decide si responde el bot automático o queda para un operador humano, según el estado de la conversación y el horario de atención (`BusinessHoursService`).
|
||||
|
||||
## Qué revisar cuando algo falla
|
||||
|
||||
| Síntoma | Dónde mirar primero |
|
||||
|---|---|
|
||||
| El mensaje sale por el número equivocado | Que se haya pasado `'turnero'` al constructor |
|
||||
| Un enlace llega roto o apunta mal | La plantilla en Meta, no el código |
|
||||
| No llega ninguna plantilla | Estado de aprobación en el WhatsApp Manager |
|
||||
| Falla el envío pero llega un texto plano | Es el respaldo actuando: la plantilla falló |
|
||||
| El bot no responde | `webhook_logs`, y el horario de atención |
|
||||
@@ -0,0 +1,104 @@
|
||||
# Decisiones y deuda técnica
|
||||
|
||||
Por qué el sistema es como es, y qué cosas conviene saber antes de tocarlo.
|
||||
|
||||
## Decisiones tomadas a propósito
|
||||
|
||||
### Migración gradual, sin corte
|
||||
|
||||
Conviven el sistema original (archivos en la raíz) y el nuevo (módulos). No hubo una reescritura de golpe.
|
||||
|
||||
**Por qué:** el laboratorio opera todos los días. Una reescritura completa implicaba congelar el desarrollo o mantener dos sistemas en paralelo.
|
||||
|
||||
**Costo:** hay que saber en cuál de los dos está el código de cada pantalla. Los `lab_*` suelen estar en la raíz; el turnero está en el módulo.
|
||||
|
||||
### Sin framework de frontend
|
||||
|
||||
Las vistas son PHP que emiten HTML, con JavaScript embebido en la misma vista.
|
||||
|
||||
**Por qué:** despliegue por `git pull`, sin build ni compilación. Un archivo se edita y ya está en producción.
|
||||
|
||||
**Costo:** hay código repetido entre vistas, y las vistas grandes (el turnero) pasan de las 2.000 líneas.
|
||||
|
||||
### Esquemas de formulario en JSON
|
||||
|
||||
Los formularios se definen en JSON dentro de `lab_formularios.esquema`, no en tablas normalizadas.
|
||||
|
||||
**Por qué:** las fichas clínicas cambian seguido y cada una tiene campos distintos. Normalizarlas obligaba a migrar el esquema con cada formulario nuevo.
|
||||
|
||||
**Costo:** no se puede consultar por SQL «todos los pacientes con fiebre». Las respuestas viven dentro de un JSON.
|
||||
|
||||
### La identidad de quien firma se resuelve en el servidor
|
||||
|
||||
Nunca se acepta del navegador quién firmó algo.
|
||||
|
||||
**Por qué:** es un dato con valor legal. Un cliente puede mentir; la sesión no.
|
||||
|
||||
### El turno original nunca se modifica
|
||||
|
||||
Cuando una muestra pendiente se completa en una visita posterior, el turno original **queda como estaba**. Solo se registra el vínculo.
|
||||
|
||||
**Por qué:** el turno cerrado es un registro histórico. Alterarlo retroactivamente falsea los tiempos de atención y la facturación de aquel día.
|
||||
|
||||
### Inventarios generados, no escritos
|
||||
|
||||
Las tablas de módulos, endpoints, tablas y roles de esta documentación se leen del código y la base en cada carga.
|
||||
|
||||
**Por qué:** una lista escrita a mano envejece sin que nadie se entere. Una generada no puede mentir.
|
||||
|
||||
## Deuda técnica conocida
|
||||
|
||||
### Columnas `can_*` que no hacen nada
|
||||
|
||||
`role_modules` tiene `can_view`, `can_create`, `can_edit`, `can_delete`, `can_export` — y **el control de acceso no las lee**. Solo usa `permission` (`read`/`write`).
|
||||
|
||||
**Riesgo:** poner `can_edit = 0` da falsa sensación de haber restringido algo. Ya causó confusión.
|
||||
|
||||
**Arreglo:** o se usan de verdad, o se eliminan. Mientras tanto, conviene mantenerlas coherentes con `permission`.
|
||||
|
||||
### `role` y `role_id` duplicados
|
||||
|
||||
Un usuario tiene el rol en dos columnas. La interfaz lee una, los permisos salen de la otra.
|
||||
|
||||
**Riesgo:** cambiar solo `role` deja al usuario con permisos que no corresponden.
|
||||
|
||||
**Arreglo:** derivar `role` de `role_id` en lugar de almacenarlo.
|
||||
|
||||
### Dos vías para el mismo formulario
|
||||
|
||||
`turnero_consentimientos` y `lab_form_envios` guardan lo mismo con estructuras distintas y tokens de formato distinto. `ver_formulario_enviado.php` tiene que manejar ambos, y `form_cliente.php` existe solo para redirigir entre ellos.
|
||||
|
||||
**Por qué sigue así:** unificarlas obliga a cambiar la plantilla aprobada en Meta y migrar los registros históricos.
|
||||
|
||||
### Convenciones de nombre mezcladas
|
||||
|
||||
Conviven `creado_at` y `created_at`, `creado_por` y `enviado_por`, español e inglés. Depende de la época de cada tabla.
|
||||
|
||||
### Vistas muy grandes
|
||||
|
||||
`ver_formulario_enviado.php` supera las 3.000 líneas y mezcla render, lógica de tomas prolongadas y JavaScript. Es el archivo más delicado de tocar del sistema.
|
||||
|
||||
### Datos históricos incompletos
|
||||
|
||||
Algunas columnas se agregaron después y las filas viejas quedaron en `NULL`, sin forma de recuperarlas:
|
||||
|
||||
| Columna | Desde | Antes |
|
||||
|---|---|---|
|
||||
| `turnero_consentimientos.creado_por` | 3 ago 2026 | `NULL` |
|
||||
| Identidad por toma en F-LAB-28 | 3 ago 2026 | No se guardaba |
|
||||
| `admin_users.cedula` | 3 ago 2026 | Solo enfermeros la tenían |
|
||||
|
||||
No hay traza de auditoría que permita reconstruirlos.
|
||||
|
||||
### El dominio se deduce de cada petición
|
||||
|
||||
`APP_URL` sale del `HTTP_HOST`. Si alguien entra por una IP o un dominio alterno, los enlaces que se generen llevarán esa dirección — y quedan guardados así en el WhatsApp del paciente.
|
||||
|
||||
**Arreglo:** fijar `APP_URL` explícitamente.
|
||||
|
||||
## Al hacer cambios
|
||||
|
||||
- **Cambio de esquema** → deje la migración en `migrations/`, idempotente.
|
||||
- **Endpoint nuevo** → verifique permisos ahí adentro, no confíes en la vista.
|
||||
- **Tocar el turnero** → es lo que más gente usa a diario; pruebe con un turno real.
|
||||
- **Tocar formularios firmados** → tienen valor legal. Un render roto es un documento inválido.
|
||||
@@ -0,0 +1,79 @@
|
||||
# Primeros pasos
|
||||
|
||||
Lo mínimo para moverse por el sistema, sin importar el rol.
|
||||
|
||||
## Entrar
|
||||
|
||||
Se ingresa con usuario y contraseña. Si no reconoce su usuario, busque su **número de cédula**: la mayoría de las cuentas del personal se crearon así.
|
||||
|
||||
Al entrar va directo a la pantalla principal de su rol. No todos ven lo mismo: el menú de la izquierda muestra únicamente los módulos habilitados para usted.
|
||||
|
||||
## Si no ve algo que deberías ver
|
||||
|
||||
Casi siempre es una de estas dos:
|
||||
|
||||
1. **Le cambiaron los permisos hace poco.** Los permisos se cargan **al iniciar sesión**. Cierre sesión y vuelva a entrar.
|
||||
2. **Su rol no lo incluye.** Pídale a un administrador que lo revise.
|
||||
|
||||
## Cómo está organizado
|
||||
|
||||
| Zona | Qué contiene |
|
||||
|---|---|
|
||||
| Menú izquierdo | Los módulos a los que tiene acceso |
|
||||
| Barra superior | Su usuario y el cierre de sesión |
|
||||
| Centro | La pantalla activa |
|
||||
|
||||
## Los módulos principales
|
||||
|
||||
| Módulo | Para qué |
|
||||
|---|---|
|
||||
| **Turnero** | Turnos presenciales: recepción, toma de muestras, pantallas |
|
||||
| **Domicilios** | Visitas domiciliarias y su asignación a enfermeros |
|
||||
| **Pacientes** | Fichas clínicas e historial |
|
||||
| **Órdenes médicas** | Órdenes recibidas |
|
||||
| **Formularios** | Consentimientos y fichas; diseño y envíos |
|
||||
| **Soporte** | Esta documentación |
|
||||
|
||||
## El recorrido de un paciente
|
||||
|
||||
Desde que saca su turno hasta que se va:
|
||||
|
||||
```
|
||||
KIOSKO RECEPCIÓN TOMA DE MUESTRAS
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌──────┐ llama ┌──────┐ deriva ┌──────────────┐ ┌────────────┐
|
||||
│espera├──────────►│recep.├───────────►│ espera lugar ├──►│ en servicio│
|
||||
└──┬───┘ └──┬───┘ └──────────────┘ └──────┬─────┘
|
||||
│ │ │
|
||||
│ no responde │ ▼
|
||||
└──────────────────┴──────────► ausente ┌────────────────┐
|
||||
│ finalizado │
|
||||
cancelado └────────────────┘
|
||||
cuenta para facturar
|
||||
```
|
||||
|
||||
Un turno **nunca se borra**. Si el paciente no aparece se marca *ausente*; si no se hace, *cancelado*. Los dos quedan registrados, y ninguno cuenta como facturación.
|
||||
|
||||
## Cosas que conviene saber desde el principio
|
||||
|
||||
**Los turnos no se borran.** Se cancelan o se marcan como ausente, pero quedan registrados. Es a propósito: el historial tiene valor clínico y administrativo.
|
||||
|
||||
**Las firmas quedan con nombre y cédula.** Cuando firma un formulario, el sistema registra quién es. No es opcional ni configurable.
|
||||
|
||||
**Una muestra pendiente no se pierde.** Si un paciente queda debiendo una muestra y vuelve otro día, aparece sola en la estación, marcada como *visita anterior*, con los exámenes de aquella orden.
|
||||
|
||||
**Nadie factura lo que no terminó.** En los reportes del día, lo cobrado en turnos finalizados y lo que sigue en curso se muestran por separado. Los ausentes y cancelados no se cuentan.
|
||||
|
||||
## Manual según su rol
|
||||
|
||||
- [Kiosko y pantallas](?m=soporte&v=documentacion&s=manual&d=kiosko-y-pantallas)
|
||||
- [Recepción](?m=soporte&v=documentacion&s=manual&d=recepcion)
|
||||
- [Chat de WhatsApp](?m=soporte&v=documentacion&s=manual&d=chat-whatsapp)
|
||||
- [Órdenes médicas](?m=soporte&v=documentacion&s=manual&d=ordenes-medicas)
|
||||
- [Domicilios](?m=soporte&v=documentacion&s=manual&d=domicilios)
|
||||
- [Formularios](?m=soporte&v=documentacion&s=manual&d=formularios)
|
||||
- [Reportes y métricas](?m=soporte&v=documentacion&s=manual&d=reportes)
|
||||
- [Toma de muestras](?m=soporte&v=documentacion&s=manual&d=toma-de-muestras)
|
||||
- [Enfermeros — domicilios](?m=soporte&v=documentacion&s=manual&d=enfermeros)
|
||||
- [Administración](?m=soporte&v=documentacion&s=manual&d=administracion)
|
||||
@@ -0,0 +1,70 @@
|
||||
# Kiosko y pantallas de TV
|
||||
|
||||
Las dos pantallas que funcionan solas, sin nadie operándolas. Conviene entenderlas porque cuando fallan, el que se entera primero es quien está en recepción.
|
||||
|
||||
## El kiosko
|
||||
|
||||
El tótem de la entrada, donde el paciente saca su turno sin ayuda.
|
||||
|
||||
```
|
||||
PACIENTE LLEGA
|
||||
│
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ Elige tipo │ general, preferencial, embarazada,
|
||||
│ de turno │ solo entrega de muestras…
|
||||
└──────┬───────┘
|
||||
▼
|
||||
┌──────────────┐
|
||||
│Datos básicos │ nombre y celular (opcional)
|
||||
└──────┬───────┘
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ Imprime │──► el turno aparece en la pantalla de TV
|
||||
│ su turno │ y en la cola de recepción
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
Es una de las **dos únicas pantallas públicas** del sistema: no pide usuario ni contraseña, porque nadie va a iniciar sesión en el tótem de la entrada.
|
||||
|
||||
### Prioridades
|
||||
|
||||
El tipo de turno que elige el paciente define su lugar en la cola. Las prioridades las configura un administrador; quien atiende no necesita hacer nada: **Llamar siguiente** ya respeta el orden.
|
||||
|
||||
### Si el paciente deja su celular
|
||||
|
||||
Puede recibir por WhatsApp el aviso de su turno y los consentimientos para firmar desde el teléfono mientras espera. Vale la pena insistirle en que lo deje.
|
||||
|
||||
### Cuando el kiosko falla
|
||||
|
||||
Un turno siempre se puede crear a mano desde recepción. El kiosko es una comodidad, no un requisito — si está caído, la atención sigue.
|
||||
|
||||
## Pantalla de TV
|
||||
|
||||
La de la sala de espera. Muestra el turno que se está llamando y la cola, y reproduce contenido del laboratorio de fondo.
|
||||
|
||||
También es pública: se abre en el navegador del televisor y se deja andando.
|
||||
|
||||
### El contenido de fondo
|
||||
|
||||
Es una lista de videos e imágenes que se reproducen en bucle, uno detrás de otro:
|
||||
|
||||
```
|
||||
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
|
||||
│ video 1 │──►│ imagen │──►│ video 2 │──►│ imagen │──┐
|
||||
│ hasta │ │ 8 seg │ │ hasta │ │ 10 seg │ │
|
||||
│ el final│ │ │ │ el final│ │ │ │
|
||||
└─────────┘ └─────────┘ └─────────┘ └─────────┘ │
|
||||
▲ │
|
||||
└──────────────────── vuelve a empezar ────────────┘
|
||||
```
|
||||
|
||||
Los videos van completos; a las imágenes se les fija cuántos segundos duran. Se administra desde **Configuración del turnero → Pantalla TV**: se suben, se reordenan arrastrando y se eliminan de a uno.
|
||||
|
||||
### Si la pantalla se queda pegada
|
||||
|
||||
Recargue la página en el televisor. Si el contenido no cambió, verifique que la lista tenga elementos activos en la configuración.
|
||||
|
||||
## Verificar paciente
|
||||
|
||||
Una consulta rápida por cédula para ver la ficha y el historial de alguien, sin abrir su turno. Útil cuando el paciente pregunta algo en el mostrador y no quiere perder lo que está haciendo.
|
||||
@@ -0,0 +1,98 @@
|
||||
---
|
||||
roles: recepcionista, lab_recepcion, supervisor
|
||||
---
|
||||
|
||||
# Recepción
|
||||
|
||||
Guía de la pantalla de recepción del turnero: desde que llama al paciente hasta que pasa a toma de muestras.
|
||||
|
||||
## Su escritorio
|
||||
|
||||
Si el equipo está registrado por IP o token, el menú le muestra **solo su escritorio**. Si no lo está, ve todos y elige.
|
||||
|
||||
Esto lo configura un administrador en `Configuración del turnero`. Si está viendo escritorios que no son el tuyo, avise: significa que ese equipo no quedó registrado.
|
||||
|
||||
## El flujo completo
|
||||
|
||||
### 1. Llamar al paciente
|
||||
|
||||
**Llamar siguiente** toma el turno con mayor prioridad de la cola. También puede llamar a uno específico si hace falta saltarse el orden.
|
||||
|
||||
El turno aparece en la pantalla de TV de la sala de espera.
|
||||
|
||||
Si el paciente no responde, **Marcar ausente**. Queda registrado como ausente y no cuenta en la facturación.
|
||||
|
||||
### 2. Verificar o crear el paciente
|
||||
|
||||
Busque por cédula o nombre.
|
||||
|
||||
- **Existe** → se cargan sus datos y su historial.
|
||||
- **No existe** → cree la ficha. Cédula, nombre completo, fecha de nacimiento, teléfono y EPS.
|
||||
|
||||
> El teléfono importa más de lo que parece: es a donde se envían los consentimientos y las encuestas. Un número mal escrito significa un consentimiento que nunca llega.
|
||||
|
||||
### 3. Seleccionar exámenes
|
||||
|
||||
Cargue los exámenes solicitados. Si viene con una orden en RIPS, se pueden importar directamente en vez de cargarlos a mano.
|
||||
|
||||
Al elegir los exámenes, el sistema decide solo qué consentimientos hacen falta: algunos van atados a un examen concreto (VIH, por ejemplo) y otros a la estación de destino.
|
||||
|
||||
### 4. Datos de facturación
|
||||
|
||||
Valor cobrado, método de pago, número de recibo. Si es por empresa o EPS, cargue el NIT y la autorización.
|
||||
|
||||
Se admite pago combinado — efectivo más tarjeta, por ejemplo.
|
||||
|
||||
### 5. Consentimientos
|
||||
|
||||
Quién firma qué, y dónde:
|
||||
|
||||
```
|
||||
RECEPCIÓN TOMA DE MUESTRAS
|
||||
┌─────────────────────┐ ┌──────────────────────┐
|
||||
│ Consentimiento │ │ Datos Toma de │
|
||||
│ pruebas de lab │ │ Muestras (F-LAB-08) │
|
||||
│ │ │ │
|
||||
│ firma: EL PACIENTE │ │ firma: EL PERSONAL │
|
||||
│ vía WhatsApp │ │ en la estación │
|
||||
└─────────────────────┘ └──────────────────────┘
|
||||
obligatorio obligatorio SIEMPRE
|
||||
salvo "solo entrega" (incluso solo entrega)
|
||||
```
|
||||
|
||||
|
||||
Los que hagan falta aparecen listados con su estado. Se envían al WhatsApp del paciente, que los firma desde el celular.
|
||||
|
||||
**No puede guardar la solicitud si quedan consentimientos sin firmar**, salvo que sea una visita de *solo entrega de muestras*.
|
||||
|
||||
Si el envío por WhatsApp falla, el sistema manda un enlace en texto plano como respaldo.
|
||||
|
||||
### 6. Elegir destino y guardar
|
||||
|
||||
Seleccione la estación de toma de muestras y guarde. El turno pasa a esa estación y el paciente sale de su escritorio.
|
||||
|
||||
## Situaciones frecuentes
|
||||
|
||||
### El paciente solo viene a entregar una muestra
|
||||
|
||||
Marque **Solo entrega de muestras**. Se saltan los consentimientos por examen y no hace falta seleccionar exámenes.
|
||||
|
||||
> Ojo: el formulario **Datos Toma de Muestras (F-LAB-08)** se sigue exigiendo en la estación. Ese no se omite nunca, porque recoge la historia clínica del momento de la toma.
|
||||
|
||||
### El paciente ya vino antes y quedó debiendo una muestra
|
||||
|
||||
No tiene que hacer nada especial. En la estación de toma de muestras le va a aparecer sola, marcada como *visita anterior*, junto con los exámenes de aquella orden.
|
||||
|
||||
### El paciente no recibió el consentimiento
|
||||
|
||||
1. Verifique el número de teléfono en su ficha.
|
||||
2. Reenvialo desde la lista de consentimientos.
|
||||
3. Si sigue sin llegar, avise a un administrador: puede ser un problema de la plantilla en Meta, que no se arregla desde acá.
|
||||
|
||||
### Hay que corregir algo después de guardar
|
||||
|
||||
Mientras el turno no esté finalizado, un administrador puede reabrirlo desde el historial y cambiar su estado.
|
||||
|
||||
## Encuestas
|
||||
|
||||
Desde el historial se le puede enviar una encuesta de satisfacción al paciente por WhatsApp.
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
roles: recepcionista, lab_recepcion, operador_bot, supervisor
|
||||
---
|
||||
|
||||
# Chat de WhatsApp
|
||||
|
||||
La línea de WhatsApp del laboratorio la atiende un bot, pero cuando hace falta una persona, la conversación pasa a un operador. Esta es esa pantalla.
|
||||
|
||||
## Cómo se reparte el trabajo
|
||||
|
||||
```
|
||||
MENSAJE DEL PACIENTE
|
||||
│
|
||||
▼
|
||||
┌─────────────┐ no ┌──────────────────────┐
|
||||
│ ¿Aceptó los ├────────►│ Le pide aceptar y │
|
||||
│ términos? │ │ no avanza hasta que │
|
||||
└──────┬──────┘ │ responda ACEPTO │
|
||||
│ sí └──────────────────────┘
|
||||
▼
|
||||
┌─────────────┐ sí ┌──────────────────────┐
|
||||
│ ¿La tomó un ├────────►│ El bot NO interviene │
|
||||
│ operador? │ │ Responde usted │
|
||||
└──────┬──────┘ └──────────────────────┘
|
||||
│ no
|
||||
▼
|
||||
┌─────────────┐ fuera ┌──────────────────────┐
|
||||
│ ¿Está en ├────────►│ Responde con el │
|
||||
│ horario? │ │ mensaje de fuera de │
|
||||
└──────┬──────┘ │ horario │
|
||||
│ dentro └──────────────────────┘
|
||||
▼
|
||||
El bot responde
|
||||
```
|
||||
|
||||
Lo importante: **cuando toma una conversación, el bot deja de responder ahí**. No hay riesgo de que le conteste encima al paciente mientras usted está escribiendo.
|
||||
|
||||
## Lo que el bot resuelve solo
|
||||
|
||||
Antes de llegar a una persona, el bot responde por su cuenta las preguntas más
|
||||
frecuentes —precios, horarios, ubicación, indicaciones previas— y ofrece un menú
|
||||
numerado para agendar, consultar resultados o ver el portafolio.
|
||||
|
||||
Ese contenido es configurable: si una respuesta quedó desactualizada o hace falta
|
||||
una nueva, un administrador la cambia sin necesidad de programar nada.
|
||||
|
||||
## Atender una conversación
|
||||
|
||||
La lista muestra los hilos con mensajes recientes. Al abrir uno ve el historial completo y puede responder.
|
||||
|
||||
Si el paciente ya está registrado, se ve su ficha; si no, se puede crear desde ahí mismo.
|
||||
|
||||
## La ventana de 24 horas
|
||||
|
||||
Es una regla de WhatsApp, no del sistema:
|
||||
|
||||
> Puede escribir libremente durante **24 horas** desde el último mensaje del paciente. Pasado ese plazo, solo se le puede escribir con una **plantilla aprobada**.
|
||||
|
||||
Por eso a veces el sistema no le deja mandar un texto libre y ofrece plantillas. No es una falla: es la restricción de WhatsApp.
|
||||
|
||||
Las plantillas las crea y aprueba Meta. Si necesita una nueva para un caso que se repite, pídala a un administrador — el trámite lleva días.
|
||||
|
||||
## Términos y condiciones
|
||||
|
||||
Todo contacto nuevo debe aceptar los términos antes de que el bot converse. Responde **ACEPTO** o **NO ACEPTO**.
|
||||
|
||||
Se le vuelven a pedir cuando pasan 6 meses o cuando se publica una versión nueva. Si un paciente dice que no puede abrir el documento de términos, avise: puede ser que el enlace esté caído, y eso lo resuelve un administrador.
|
||||
|
||||
## Problemas frecuentes
|
||||
|
||||
**El paciente dice que escribió y nadie le respondió.**
|
||||
Revise si la conversación quedó tomada por un operador que no siguió. En ese estado el bot no responde y queda esperando a una persona.
|
||||
|
||||
**No me deja enviar un mensaje.**
|
||||
Pasaron más de 24 horas desde el último mensaje del paciente. Use una plantilla.
|
||||
|
||||
**El paciente no recibe los consentimientos.**
|
||||
Verifique el número en su ficha. Si está bien y aun así no llegan, es problema de plantilla — avise a un administrador.
|
||||
@@ -0,0 +1,108 @@
|
||||
---
|
||||
roles: bacteriologo, supervisor
|
||||
---
|
||||
|
||||
# Toma de muestras
|
||||
|
||||
Guía de la estación de toma de muestras: atender al paciente, recibir sus muestras y firmar los formularios.
|
||||
|
||||
## Su estación
|
||||
|
||||
Igual que en recepción, si el equipo está registrado por IP o token, ve **solo su estación**. Si no, las ve todas.
|
||||
|
||||
## Atender un turno
|
||||
|
||||
Los pacientes derivados desde recepción aparecen en su bandeja. Al abrir uno ve su ficha completa: datos, exámenes solicitados, muestras a recibir y formularios pendientes.
|
||||
|
||||
## Recibir muestras
|
||||
|
||||
Cada muestra tiene tres estados posibles:
|
||||
|
||||
| Estado | Significado |
|
||||
|---|---|
|
||||
| **Recibida** | La tomaste o el paciente la entregó correctamente |
|
||||
| **Pendiente** | No se pudo obtener; queda debiendo |
|
||||
| **Rechazada** | Se obtuvo pero no sirve — hemólisis, volumen insuficiente, mal rotulada |
|
||||
|
||||
Al rechazar hay que indicar el motivo. Ese motivo queda registrado y se ve después en el historial.
|
||||
|
||||
### Muestras de visitas anteriores
|
||||
|
||||
```
|
||||
VISITA 1 · lunes VISITA 2 · jueves
|
||||
┌────────────────────┐ ┌────────────────────┐
|
||||
│ Turno A-042 │ │ Turno B-017 │
|
||||
│ │ │ │
|
||||
│ Sangre recibida │ │ Orina recibida │
|
||||
│ Orina PENDIENTE ├───────────────►│ └ visita anterior│
|
||||
└────────────────────┘ reaparece └────────────────────┘
|
||||
finalizado sola │
|
||||
NO se modifica ◄─────────────────────────────
|
||||
quedan enlazados
|
||||
```
|
||||
|
||||
Si el paciente quedó debiendo una muestra otro día, le aparece con una etiqueta ámbar **visita anterior**, e incluye los exámenes de aquella orden para que sepa de qué se trataba.
|
||||
|
||||
Se reciben con un clic, igual que cualquier otra. Al hacerlo, los dos turnos quedan enlazados: desde el historial puede saltar de uno al otro.
|
||||
|
||||
> **El turno original no se modifica.** Sigue finalizado como estaba. Solo se registra en qué visita se completó la muestra.
|
||||
|
||||
## Formularios
|
||||
|
||||
### Datos Toma de Muestras (F-LAB-08)
|
||||
|
||||
Obligatorio en todas las estaciones. Recoge la historia clínica del momento: síntomas, antecedentes familiares y personales, medicación, datos obstétricos si corresponde.
|
||||
|
||||
Lo firma usted, no el paciente.
|
||||
|
||||
**Si el paciente ya lo llenó en una visita anterior**, aparece el botón *Cargar datos de la visita anterior*. Trae las respuestas de la última vez para que solo revises y ajustes lo que cambió. **No trae la firma**: esa la pone usted, con la fecha de hoy.
|
||||
|
||||
### Control de Tomas de Muestras Prolongadas (F-LAB-28)
|
||||
|
||||
Para exámenes que requieren varias tomas en el tiempo: curvas de glicemia, prolactina, cortisol, test de Sullivan.
|
||||
|
||||
```
|
||||
Glicemia pre y post carga
|
||||
|
||||
min 0 min 30 min 60 min 120
|
||||
│ │ │ │
|
||||
┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐
|
||||
│ ✔ │ ────► │ ✔ │ ────► │ │ ... │ │
|
||||
└─────┘ 30min └─────┘ 30min └─────┘ └─────┘
|
||||
07:28 08:06 pendiente bloqueada
|
||||
M. Monterrosa Y. Parada ▲
|
||||
└ cuenta regresiva
|
||||
|
||||
Cada toma guarda SU hora y QUIÉN la firmó. Si cambia
|
||||
el turno del personal, cada firma conserva su nombre.
|
||||
```
|
||||
|
||||
Cómo funciona:
|
||||
|
||||
1. **Marque el examen.** El formulario muestra solo las tomas de ese examen; si el paciente tiene dos exámenes seriados, muestra las de ambos.
|
||||
2. **Configure los tiempos** si se lo pide (minuto 0, 30, 60…).
|
||||
3. **Firme cada toma** a medida que la hace. El sistema registra la hora y **quién firmó**.
|
||||
4. Cuando firma una, el sistema calcula cuándo toca la siguiente y muestra una cuenta regresiva.
|
||||
|
||||
> Cada toma se firma por separado y queda con el nombre de quien la hizo. Si cambia el turno del personal a mitad del protocolo, cada toma conserva el nombre correcto.
|
||||
|
||||
Si hay que cerrar el protocolo antes de terminar todas las tomas, se puede hacer indicando el motivo.
|
||||
|
||||
## Antes de finalizar
|
||||
|
||||
El sistema no le deja finalizar si quedan muestras sin decidir. Cada una tiene que estar recibida, pendiente o rechazada.
|
||||
|
||||
## Comentarios
|
||||
|
||||
Puede dejar notas en el turno. Quedan visibles para el resto del personal y en el historial.
|
||||
|
||||
## Preguntas frecuentes
|
||||
|
||||
**¿Puedo revertir una muestra que marqué mal?**
|
||||
Sí. Con el botón de deshacer vuelve a pendiente.
|
||||
|
||||
**El formulario me muestra secciones de exámenes que el paciente no tiene.**
|
||||
Avise a soporte. Debería mostrar únicamente las del examen marcado.
|
||||
|
||||
**¿Qué pasa si el paciente se va sin dar una muestra?**
|
||||
Déjela en **pendiente**. Cuando vuelva —el día que sea— le va a aparecer sola a quien lo atienda.
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
roles: lab_readonly, recepcionista, lab_recepcion, supervisor
|
||||
---
|
||||
|
||||
# Órdenes médicas
|
||||
|
||||
Las órdenes que el paciente envía por WhatsApp, casi siempre como foto de la orden en papel. Acá se revisan y se autorizan.
|
||||
|
||||
## El recorrido
|
||||
|
||||
```
|
||||
PACIENTE USTED RESULTADO
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────┐ ┌───────────────┐ ┌────────────────┐
|
||||
│ envía │───────────►│ PENDIENTE │───────────►│ AUTORIZADA │
|
||||
│ la foto │ │ la revisa │ │ puede atenderse│
|
||||
└─────────┘ └───────┬───────┘ └────────────────┘
|
||||
│
|
||||
└──────────────────► ┌────────────────┐
|
||||
│ RECHAZADA │
|
||||
│ con comentario│
|
||||
└────────────────┘
|
||||
```
|
||||
|
||||
Toda orden llega como **pendiente**. Queda registrado quién la revisó, quién la autorizó y cuándo.
|
||||
|
||||
## Revisar una orden
|
||||
|
||||
Al abrirla ve la imagen que envió el paciente y los campos para transcribir lo que dice:
|
||||
|
||||
| Dato | Por qué importa |
|
||||
|---|---|
|
||||
| Médico y registro | Identifica quién la solicitó |
|
||||
| Fecha de la orden | Una orden vieja puede estar vencida |
|
||||
| Diagnóstico | Va en los reportes y en la facturación |
|
||||
| Exámenes solicitados | Define qué se le va a hacer |
|
||||
| **Requiere ayuno** y horas | Determina qué se le indica al paciente antes de venir |
|
||||
| Indicaciones | Cualquier preparación adicional |
|
||||
|
||||
> El ayuno es el dato que más consecuencias tiene si se transcribe mal: un paciente que viene sin ayunar cuando debía obliga a repetir la visita.
|
||||
|
||||
## Autorizar o rechazar
|
||||
|
||||
- **Autorizar** — la orden queda lista y el paciente puede ser atendido.
|
||||
- **Rechazar** — hay que dejar un comentario explicando por qué. Ese comentario es lo que permite entender después qué pasó.
|
||||
|
||||
Motivos frecuentes de rechazo: foto ilegible, orden vencida, faltan datos del médico, o los exámenes no corresponden a los que presta el laboratorio.
|
||||
|
||||
## Si solo tiene permiso de lectura
|
||||
|
||||
Algunos roles ven las órdenes sin poder modificarlas. En ese caso no aparecen los botones de autorizar ni rechazar: puede consultarlas e informarse, pero la decisión la toma otra persona.
|
||||
|
||||
## Preguntas frecuentes
|
||||
|
||||
**La foto llegó ilegible.**
|
||||
Rechace la orden indicándolo en el comentario, para que se le pueda pedir al paciente que la envíe de nuevo.
|
||||
|
||||
**El paciente dice que envió la orden y no aparece.**
|
||||
Verifique en el chat de WhatsApp que el mensaje haya llegado. Si llegó pero no se registró como orden, avise a un administrador.
|
||||
|
||||
**Me equivoqué al transcribir un dato.**
|
||||
Mientras la orden no esté autorizada se puede corregir. Si ya lo está, pídale a un administrador que la reabra.
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
roles: recepcionista, lab_recepcion, lab_readonly, supervisor
|
||||
---
|
||||
|
||||
# Domicilios
|
||||
|
||||
La pantalla administrativa de las visitas domiciliarias: agendarlas, asignarles enfermero y seguirlas.
|
||||
|
||||
> Es distinta del **portal del enfermero**. Aquí se ve y administra todo; el portal muestra a cada enfermero solo lo suyo y está pensado para el celular.
|
||||
|
||||
## Agendar una visita
|
||||
|
||||
1. **Paciente** — búsquelo por cédula. Si no existe, se crea en el momento.
|
||||
2. **Dirección** — la de su ficha con un botón, o escriba otra. Agregue indicaciones si el lugar es difícil de ubicar.
|
||||
3. **Fecha y hora**.
|
||||
4. **Servicio** — qué se va a hacer.
|
||||
5. **Seguro y autorización**, si aplica.
|
||||
6. **Valores** — domicilio y copago.
|
||||
|
||||
## Asignar un enfermero
|
||||
|
||||
Una visita agendada queda **sin asignar** hasta que se le pone un enfermero. Las sin asignar del día son las que hay que resolver primero: nadie las va a atender solo porque estén agendadas.
|
||||
|
||||
Al asignarla, la visita aparece en el portal de ese enfermero, que puede editarla.
|
||||
|
||||
## Estados
|
||||
|
||||
```
|
||||
┌────────────┐ ┌───────────┐ ┌──────────────┐
|
||||
│ programado ├──►│ en curso ├──►│ completado │
|
||||
└─────┬──────┘ └───────────┘ └──────────────┘
|
||||
│
|
||||
└────────────────────────► ┌──────────────┐
|
||||
│ cancelado │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
## Notas y archivos
|
||||
|
||||
Cada visita admite notas de seguimiento, con fotos y archivos adjuntos. Sirve para dejar la orden médica en papel, un resultado o una observación de lo ocurrido.
|
||||
|
||||
Las notas quedan visibles para quien atienda después. Es el lugar correcto para dejar constancia de algo que el próximo necesita saber.
|
||||
|
||||
## Formularios
|
||||
|
||||
Se le puede enviar un formulario al paciente para que lo firme desde su celular, o copiar el enlace para hacérselo llegar por otro medio. Los ya firmados se consultan desde la misma visita.
|
||||
|
||||
## Si solo tiene permiso de lectura
|
||||
|
||||
Algunos roles ven los domicilios sin poder modificarlos: no aparecen los botones de crear ni editar. Puede consultar la agenda, el estado de cada visita y sus notas.
|
||||
|
||||
## Preguntas frecuentes
|
||||
|
||||
**Agendé una visita y el enfermero dice que no la ve.**
|
||||
Verifique que le haya asignado el enfermero. Sin asignación, la visita no aparece en ningún portal.
|
||||
|
||||
**Hay que reprogramar.**
|
||||
Edite la fecha y la hora, y avísele al paciente. Desde el portal del enfermero hay un enlace que abre WhatsApp con el mensaje ya redactado.
|
||||
|
||||
**El paciente cambió de dirección.**
|
||||
Edite la visita. Si el cambio es permanente, actualice también la ficha del paciente, o la próxima vez volverá a aparecer la anterior.
|
||||
|
||||
**¿Puedo ver los domicilios de todos los enfermeros?**
|
||||
Desde esta pantalla sí. El portal, en cambio, muestra a cada enfermero solo los suyos.
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
roles: enfermero, supervisor
|
||||
---
|
||||
|
||||
# Enfermeros — domicilios
|
||||
|
||||
Guía del portal del enfermero: sus visitas domiciliarias, cómo agendarlas y qué hacer en cada una.
|
||||
|
||||
## Su portal
|
||||
|
||||
Al entrar va directo al portal. Ve **sus** domicilios: los que le asignaron y los que agendó usted.
|
||||
|
||||
Está pensado para usarse desde el celular en la calle.
|
||||
|
||||
## Agendar un domicilio
|
||||
|
||||
**Nuevo domicilio** abre el formulario:
|
||||
|
||||
1. **Paciente** — búsquelo por cédula. Si no existe, se crea ahí mismo.
|
||||
2. **Dirección** — la del paciente con un botón, o escriba otra. Agregue indicaciones si el lugar es difícil de encontrar («apto 302, tocar campanilla»).
|
||||
3. **Fecha y hora**.
|
||||
4. **Servicio** — qué se va a hacer.
|
||||
5. **Seguro y autorización** si aplica.
|
||||
6. **Valores** — domicilio, copago.
|
||||
|
||||
## Editar
|
||||
|
||||
Puede editar los domicilios **que agendó usted** y también **los que le asignaron**. El botón *Editar* aparece mientras el domicilio no esté completado ni cancelado.
|
||||
|
||||
## Avisar al paciente por WhatsApp
|
||||
|
||||
Hay un enlace que abre WhatsApp con el mensaje ya escrito, presentándote como profesional del Laboratorio Ximena Caicedo. Solo revisa y envía.
|
||||
|
||||
## Notas y archivos
|
||||
|
||||
Puede dejar dos tipos de nota en cada domicilio:
|
||||
|
||||
- **Nota de ficha** — estructurada, para datos clínicos.
|
||||
- **Nota libre** — texto suelto.
|
||||
|
||||
Ambas admiten fotos y archivos adjuntos, útil para órdenes médicas en papel o resultados.
|
||||
|
||||
## Formularios
|
||||
|
||||
Puede enviarle un formulario al paciente para que lo firme desde su celular, o copiar el enlace para pasárselo por otro medio. Los que ya firmó se pueden consultar desde el mismo domicilio.
|
||||
|
||||
Cuando firma usted un formulario, queda registrado con su **nombre y cédula**, tomados de su ficha de enfermero.
|
||||
|
||||
## Estados de un domicilio
|
||||
|
||||
| Estado | Significado |
|
||||
|---|---|
|
||||
| Programado | Agendado, sin atender |
|
||||
| En curso | Está en la visita |
|
||||
| Completado | Terminado |
|
||||
| Cancelado | No se hizo |
|
||||
|
||||
## Preguntas frecuentes
|
||||
|
||||
**No puedo editar un domicilio.**
|
||||
Solo se pueden editar los propios o los asignados a usted, y solo si no está completado ni cancelado.
|
||||
|
||||
**El paciente cambió de dirección.**
|
||||
Edite el domicilio. Si el cambio es permanente, actualice también la ficha del paciente.
|
||||
|
||||
**Necesito reprogramar.**
|
||||
Edite la fecha y hora, y avísele al paciente por WhatsApp.
|
||||
|
||||
**¿Puedo ver domicilios de otro enfermero?**
|
||||
No. El portal muestra únicamente los tuyos.
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
roles: formularios_readonly, enfermero, supervisor
|
||||
---
|
||||
|
||||
# Formularios
|
||||
|
||||
Los consentimientos y fichas clínicas que firman pacientes y personal. Acá se diseñan, se consultan y se revisan los que ya se firmaron.
|
||||
|
||||
## Cómo está armado un formulario
|
||||
|
||||
Un formulario es una lista de campos en orden. El diseñador permite armarlo sin escribir nada técnico: se agregan campos, se les pone nombre y se ordenan.
|
||||
|
||||
| Tipo de campo | Para qué sirve |
|
||||
|---|---|
|
||||
| **Separador** | Divide el formulario en secciones |
|
||||
| **Párrafo** | Texto fijo — es lo que se usa para el articulado legal de un consentimiento |
|
||||
| **Texto**, **Área de texto**, **Número** | Respuestas escritas |
|
||||
| **Fecha**, **Hora** | Fechas y horas |
|
||||
| **Opción única**, **Casillas**, **Lista** | Respuestas para elegir |
|
||||
| **Vinculado** | Se llena solo con un dato del paciente (nombre, documento, EPS) |
|
||||
| **Firma del paciente** | La dibuja el paciente |
|
||||
| **Firma del profesional** | La dibuja quien atiende |
|
||||
|
||||
> Los campos **vinculados** evitan que alguien escriba mal un nombre o un documento: los toma de la ficha del paciente.
|
||||
|
||||
## Secciones que aparecen solo a veces
|
||||
|
||||
Una sección puede depender de una respuesta anterior. Por ejemplo, los datos obstétricos solo aparecen si el sexo registrado es femenino; las tomas de insulina, solo si se marcó ese examen.
|
||||
|
||||
Se configura eligiendo de qué campo depende la sección y con qué respuestas debe mostrarse. Si la condición no se cumple, ni la sección ni sus campos aparecen.
|
||||
|
||||
## Quién firma qué
|
||||
|
||||
```
|
||||
┌───────────────────────┐ ┌───────────────────────┐
|
||||
│ FIRMA DEL PACIENTE │ │ FIRMA DEL PROFESIONAL │
|
||||
│ │ │ │
|
||||
│ la dibuja el paciente │ │ la dibuja quien atiende│
|
||||
│ desde su celular │ │ en el equipo del lab │
|
||||
│ │ │ │
|
||||
│ llega por WhatsApp │ │ queda con nombre │
|
||||
│ │ │ y cédula │
|
||||
└───────────────────────┘ └───────────────────────┘
|
||||
```
|
||||
|
||||
La firma del profesional **siempre** queda identificada con nombre y cédula. No es opcional.
|
||||
|
||||
## Formularios con varias firmas
|
||||
|
||||
Algunos formularios se firman por partes a lo largo de horas — el de tomas prolongadas es el caso típico. Cada firma se guarda por separado, con su hora y el nombre de quien la hizo, de modo que si cambia el turno del personal cada toma conserva el nombre correcto.
|
||||
|
||||
## Consultar formularios firmados
|
||||
|
||||
Se puede ver el documento tal como quedó, con las respuestas y las firmas. Los formularios firmados **no se modifican**: son un registro con valor legal.
|
||||
|
||||
Si un formulario tiene un error, se emite uno nuevo; no se corrige el anterior.
|
||||
|
||||
## Si solo tiene permiso de lectura
|
||||
|
||||
Puede consultar los formularios y los documentos firmados, pero no crear ni modificar plantillas. Es el permiso habitual para quien audita o revisa documentación.
|
||||
|
||||
## Preguntas frecuentes
|
||||
|
||||
**Cambié un formulario. ¿Se modifican los ya firmados?**
|
||||
No. Los firmados conservan la versión con la que se firmaron. El cambio aplica de ahí en adelante.
|
||||
|
||||
**Un documento firmado muestra secciones que no correspondían.**
|
||||
Avise a soporte con el número de turno. Debería mostrar únicamente las secciones del examen del paciente.
|
||||
|
||||
**No aparece el nombre de quien firmó.**
|
||||
Los documentos anteriores a agosto de 2026 no guardaban ese dato y no se puede recuperar. Los posteriores sí lo registran.
|
||||
@@ -0,0 +1,91 @@
|
||||
---
|
||||
roles: supervisor
|
||||
---
|
||||
|
||||
# Administración
|
||||
|
||||
Tareas de administrador: usuarios, permisos, configuración y reportes.
|
||||
|
||||
## Usuarios y permisos
|
||||
|
||||
### Crear un usuario
|
||||
|
||||
La convención de la casa es usar el **número de cédula como nombre de usuario** para el personal asistencial.
|
||||
|
||||
Cargue también la **cédula** en su ficha: es lo que aparece bajo la firma en los formularios. Si falta, el documento sale firmado sin identificación.
|
||||
|
||||
Los enfermeros son un caso aparte: su cédula sale de la ficha de enfermero, no del usuario.
|
||||
|
||||
### Cambiar el rol de alguien
|
||||
|
||||
> Un usuario tiene **dos** campos de rol y hay que cambiar los dos. El texto (`role`) es lo que muestra la interfaz; el vínculo (`role_id`) es de donde salen los permisos reales.
|
||||
|
||||
Si cambia solo uno, el usuario ve un rol y tiene los permisos del otro. Ya pasó.
|
||||
|
||||
Después del cambio, **el usuario debe cerrar sesión y volver a entrar**: los permisos se cargan al iniciar sesión, no en cada pantalla.
|
||||
|
||||
### Dejar un módulo en solo lectura
|
||||
|
||||
Lo que decide si alguien puede modificar es el campo `permission` (`read` o `write`) de cada módulo del rol.
|
||||
|
||||
> Las columnas `can_editar`, `can_crear` y similares **no se usan** para el control de acceso. Ponerlas en cero no impide nada. Lo que manda es `permission`.
|
||||
|
||||
## Turnero
|
||||
|
||||
### Escritorios y estaciones
|
||||
|
||||
Cada puesto de recepción y cada estación de muestras es un *lugar*. Se les puede asignar:
|
||||
|
||||
- **Formularios obligatorios** — todo paciente que pase por ahí los debe firmar.
|
||||
- **Equipos por IP o token** — así el operador ve solo su puesto y no puede confundirse.
|
||||
|
||||
### Formularios obligatorios
|
||||
|
||||
Se exigen por dos vías, y se acumulan:
|
||||
|
||||
| Vía | Ejemplo |
|
||||
|---|---|
|
||||
| Por examen | VIH exige su consentimiento específico |
|
||||
| Por estación | Toda toma de muestras exige F-LAB-08 |
|
||||
|
||||
### Pantalla de TV
|
||||
|
||||
Admite una lista de videos e imágenes que se reproducen en bucle, uno detrás de otro. Se pueden reordenar arrastrando y a cada imagen se le fija cuántos segundos dura.
|
||||
|
||||
### Reabrir un turno
|
||||
|
||||
Desde el historial se puede cambiar el estado de un turno, incluso reabrir uno finalizado, ausente o cancelado.
|
||||
|
||||
## Facturación del día
|
||||
|
||||
El panel muestra tres cifras:
|
||||
|
||||
| Cifra | Qué incluye |
|
||||
|---|---|
|
||||
| **Facturado** | Turnos finalizados |
|
||||
| **En proceso** | Turnos aún activos, ya cobrados pero sin cerrar |
|
||||
| **Total estimado** | La suma de ambos |
|
||||
|
||||
Los turnos **ausentes y cancelados no se cuentan** en ninguna: no se van a cobrar.
|
||||
|
||||
## LIA
|
||||
|
||||
El asistente del dashboard del turnero responde preguntas sobre la operación del día: tiempos por profesional, facturación, exámenes más pedidos, buscar un paciente.
|
||||
|
||||
Tiene un presupuesto de consumo. Cuando se agota, se bloquea y hay que reponerlo. El consumo por pregunta es alto porque envía el contexto completo del día cada vez.
|
||||
|
||||
## Documentos
|
||||
|
||||
Los datos que salen en el encabezado de todos los documentos —nombre, dirección, ciudad, teléfono, logo, color— se editan desde **Configuración del laboratorio**, sin tocar código.
|
||||
|
||||
> La dirección física se cambia desde ahí. Pero **la URL de los botones que llegan por WhatsApp no**: esa vive en la plantilla aprobada por Meta y se cambia en el WhatsApp Manager, con reaprobación de por medio.
|
||||
|
||||
## Términos y condiciones
|
||||
|
||||
El bot exige aceptarlos antes de conversar. La URL del documento está en **dos lugares** que hay que cambiar juntos: la versión activa de términos y el texto del mensaje de bienvenida, que la repite dentro.
|
||||
|
||||
Se vuelve a pedir la aceptación cuando pasan 6 meses o cuando se publica una versión nueva marcada para reenvío.
|
||||
|
||||
## Cuando algo falla
|
||||
|
||||
El [runbook de incidentes](?m=soporte&v=documentacion&s=operacion&d=runbook) tiene los casos frecuentes con su diagnóstico y solución.
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
roles: supervisor
|
||||
---
|
||||
|
||||
# Reportes y métricas
|
||||
|
||||
Dónde mirar cada cosa. Hay **dos lugares distintos** y conviene no confundirlos.
|
||||
|
||||
```
|
||||
┌──────────────────────────┐ ┌──────────────────────────┐
|
||||
│ DASHBOARD DEL TURNERO │ │ REPORTES │
|
||||
│ │ │ │
|
||||
│ la operación de HOY │ │ el laboratorio en │
|
||||
│ │ │ general y los domicilios │
|
||||
│ • facturación del día │ │ │
|
||||
│ • tiempos de atención │ │ • ingresos y pagos │
|
||||
│ • turnos en espera │ │ • rendimiento │
|
||||
│ • asistente LIA │ │ • traslado de enfermeros │
|
||||
└──────────────────────────┘ └──────────────────────────┘
|
||||
```
|
||||
|
||||
## Facturación del día
|
||||
|
||||
En el dashboard del turnero, tres cifras separadas:
|
||||
|
||||
| Cifra | Qué incluye |
|
||||
|---|---|
|
||||
| **Facturado** | Turnos finalizados — plata efectivamente cobrada |
|
||||
| **En proceso** | Turnos aún activos, ya cobrados pero sin cerrar |
|
||||
| **Total estimado** | La suma de ambos |
|
||||
|
||||
Lo importante de entender: **los ausentes y cancelados no se cuentan en ninguna**. No se van a cobrar, así que sumarlos daría una cifra falsa.
|
||||
|
||||
La gráfica por hora usa la misma separación: azul lo finalizado, ámbar lo que sigue en curso.
|
||||
|
||||
## Tiempos de atención
|
||||
|
||||
El dashboard muestra promedios de espera, recepción y servicio, y los desglosa por persona.
|
||||
|
||||
En el historial, un servicio que superó los 20 minutos se resalta en rojo. **No siempre es un problema**: un examen seriado dura horas por definición. Al abrir el detalle se ve el desglose por toma y quién atendió cada una.
|
||||
|
||||
## Traslado de enfermeros
|
||||
|
||||
En Reportes. Sale de comparar las marcas de hora de las visitas domiciliarias, y sirve para detectar rutas mal armadas — mucho tiempo entre visitas suele significar que el orden geográfico no se tuvo en cuenta al agendar.
|
||||
|
||||
## Preguntar en lugar de buscar
|
||||
|
||||
En el dashboard del turnero está **LIA**, que responde sobre la operación del día:
|
||||
|
||||
- ¿Cuántos pacientes se atendieron hoy y cuántos faltan?
|
||||
- ¿Cuánto se facturó?
|
||||
- ¿Cuáles son los tiempos promedio por bacteriólogo?
|
||||
- ¿Cuántos están en espera ahora?
|
||||
|
||||
Responde con los datos del día, no con estimaciones. Si no tiene el dato, lo dice.
|
||||
|
||||
## Exportar
|
||||
|
||||
El historial de turnos se exporta a CSV con los filtros que tenga aplicados. Sirve para analizar en una planilla lo que la pantalla no muestra.
|
||||
|
||||
## Preguntas frecuentes
|
||||
|
||||
**Los números del dashboard y de reportes no coinciden.**
|
||||
Miran cosas distintas: el dashboard es la operación presencial de hoy; reportes abarca el laboratorio en general, incluidos domicilios. No son comparables directamente.
|
||||
|
||||
**Un turno aparece con un tiempo de servicio enorme.**
|
||||
Revise si era un examen seriado. Esos duran horas por diseño y el tiempo alto es correcto.
|
||||
|
||||
**Necesito un reporte que no existe.**
|
||||
Exporte el historial a CSV y arme el cálculo en una planilla. Si es algo que va a necesitar seguido, pídalo a un administrador.
|
||||
@@ -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, 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;
|
||||
```
|
||||
@@ -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 aplica un cambio directo en producción, **deje también la migración**: sin ella, un entorno nuevo no tendrá ese cambio y nadie se va a enterar hasta que falle.
|
||||
@@ -0,0 +1,79 @@
|
||||
# Despliegue y mantenimiento
|
||||
|
||||
## Cómo se despliega
|
||||
|
||||
No hay build ni compilación. El código PHP se sirve directo:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
```
|
||||
|
||||
Con eso los cambios están en producción. Es la contrapartida de no usar framework de frontend.
|
||||
|
||||
**Si el cambio incluye esquema de base de datos**, hay que correr la migración además del `git pull`.
|
||||
|
||||
## Repositorio
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Remoto | `gitea` |
|
||||
| Rama | `main` |
|
||||
|
||||
Se trabaja directo sobre `main`.
|
||||
|
||||
## Migraciones
|
||||
|
||||
Van en `migrations/`, con nombre `AAAAMMDD_descripcion.sql`.
|
||||
|
||||
**Deben ser idempotentes** — poder correrse más de una vez sin causar daño:
|
||||
|
||||
```sql
|
||||
ALTER TABLE admin_users
|
||||
ADD COLUMN IF NOT EXISTS cedula VARCHAR(30) NULL AFTER cargo;
|
||||
|
||||
UPDATE admin_users SET cedula = username
|
||||
WHERE cedula IS NULL AND username REGEXP '^[0-9]{5,15}$';
|
||||
```
|
||||
|
||||
MariaDB 11.8 admite `IF NOT EXISTS` en `ALTER TABLE`. Para `UPDATE` e `INSERT`, la guarda va en el `WHERE`.
|
||||
|
||||
Antes de dar por buena una migración, córrala dos veces y verifique que la segunda no cambie nada.
|
||||
|
||||
> Aplicar un cambio directo en producción sin dejar la migración hace que un entorno nuevo no lo tenga, y nadie se entera hasta que algo falla. Si toca el esquema, deje el archivo.
|
||||
|
||||
## Archivos subidos
|
||||
|
||||
| Carpeta | Contenido |
|
||||
|---|---|
|
||||
| `uploads/turnero/tv_media/` | Videos e imágenes de la pantalla de TV |
|
||||
| `uploads/terms/` | Documentos de términos y condiciones |
|
||||
|
||||
Se crean solas al primer uso. **No están en el repositorio**: al mover el sistema de servidor hay que copiarlas aparte, o los enlaces quedan rotos.
|
||||
|
||||
## Verificaciones después de desplegar
|
||||
|
||||
```bash
|
||||
# Sintaxis de los archivos tocados
|
||||
php -l archivo.php
|
||||
|
||||
# ¿Responde un enlace público?
|
||||
curl -s -o /dev/null -w "%{http_code}\n" "https://<dominio>/<ruta>"
|
||||
```
|
||||
|
||||
Si el cambio afectó permisos, recuerde que **las sesiones abiertas conservan los permisos viejos** hasta que el usuario vuelva a entrar.
|
||||
|
||||
## Configuración por entorno
|
||||
|
||||
Las credenciales se leen de variables de entorno (`.env`, vía phpdotenv) y de `system_config`. El dominio no se configura: se deduce del `HTTP_HOST` de cada petición.
|
||||
|
||||
## Mantenimiento periódico
|
||||
|
||||
| Cada | Revisar |
|
||||
|---|---|
|
||||
| Semana | Consumo de LIA (`lab_config.lia_tokens_usados`) contra el tope de 1.000.000 |
|
||||
| Mes | Que los enlaces enviados por WhatsApp respondan — sobre todo el de términos |
|
||||
| Mes | Consentimientos que quedaron sin firmar |
|
||||
| Trimestre | Plantillas de Meta: que sigan aprobadas |
|
||||
| Trimestre | Muestras pendientes acumuladas por paciente |
|
||||
|
||||
Las consultas para varias de estas revisiones están en el [runbook](?m=soporte&v=documentacion&s=operacion&d=runbook).
|
||||
@@ -0,0 +1,88 @@
|
||||
# Respaldos y recuperación
|
||||
|
||||
Qué hay que respaldar, y qué se pierde si no está.
|
||||
|
||||
## Las tres cosas a respaldar
|
||||
|
||||
```
|
||||
┌────────────────────┐ ┌────────────────────┐ ┌────────────────────┐
|
||||
│ BASE DE DATOS │ │ ARCHIVOS SUBIDOS │ │ CÓDIGO │
|
||||
│ │ │ │ │ │
|
||||
│ 91 tablas │ │ uploads/terms/ │ │ repositorio git │
|
||||
│ pacientes, turnos, │ │ uploads/turnero/ │ │ │
|
||||
│ formularios, │ │ │ │ ya respaldado por │
|
||||
│ firmas │ │ NO están en git │ │ estar en el remoto │
|
||||
└────────────────────┘ └────────────────────┘ └────────────────────┘
|
||||
crítico crítico cubierto
|
||||
```
|
||||
|
||||
El código está a salvo por estar versionado. Los otros dos **no tienen respaldo automático por el solo hecho de existir**.
|
||||
|
||||
## Base de datos
|
||||
|
||||
Es lo único irreemplazable. Contiene historia clínica, consentimientos firmados y facturación — información con valor legal y sin forma de reconstruirse.
|
||||
|
||||
```bash
|
||||
mysqldump -h <host> -u <usuario> -p <base> \
|
||||
--single-transaction --routines --triggers \
|
||||
| gzip > respaldo_$(date +%F).sql.gz
|
||||
```
|
||||
|
||||
`--single-transaction` evita bloquear las tablas mientras corre, así se puede hacer con el sistema en uso.
|
||||
|
||||
### Qué contiene lo crítico
|
||||
|
||||
| Tabla | Por qué importa |
|
||||
|---|---|
|
||||
| `lab_pacientes` | Fichas clínicas |
|
||||
| `turnero_consentimientos`, `lab_form_envios` | **Formularios firmados** — valor legal |
|
||||
| `turnero_turnos`, `turnero_solicitudes` | Historial de atención y facturación |
|
||||
| `terms_acceptance` | Aceptación de términos, ~5.000 registros |
|
||||
| `admin_users`, `roles`, `role_modules` | Acceso al sistema |
|
||||
|
||||
Las firmas se guardan como imagen **dentro** de las tablas, no como archivos sueltos. Un respaldo de la base las incluye.
|
||||
|
||||
## Archivos subidos
|
||||
|
||||
```
|
||||
uploads/terms/ documentos de términos y condiciones
|
||||
uploads/turnero/tv_media/ videos e imágenes de la pantalla de TV
|
||||
```
|
||||
|
||||
**No están en el repositorio.** Al mover el sistema de servidor hay que copiarlos aparte, o los enlaces quedan apuntando a archivos que ya no existen.
|
||||
|
||||
Es exactamente lo que pasó con el documento de términos cuando cambió el dominio: la base seguía apuntando a una URL que ya no respondía.
|
||||
|
||||
## Antes de un cambio riesgoso
|
||||
|
||||
Si va a tocar datos en producción, respalde **solo lo que va a tocar**:
|
||||
|
||||
```bash
|
||||
mysqldump -h <host> -u <usuario> -p <base> role_modules admin_users \
|
||||
> antes_del_cambio.sql
|
||||
```
|
||||
|
||||
Es rápido y suele alcanzar. Un respaldo completo para cambiar una columna es desproporcionado; no tener ninguno es imprudente.
|
||||
|
||||
## Verificar que el respaldo sirve
|
||||
|
||||
Un respaldo que nunca se restauró no es un respaldo, es un archivo:
|
||||
|
||||
```bash
|
||||
gunzip -t respaldo_2026-08-03.sql.gz # ¿está íntegro?
|
||||
zcat respaldo_2026-08-03.sql.gz | head -40 # ¿tiene lo que espera?
|
||||
```
|
||||
|
||||
Lo ideal es restaurarlo de vez en cuando en una base de prueba y comprobar que el sistema arranca contra ella.
|
||||
|
||||
## Qué NO es recuperable
|
||||
|
||||
Aunque tengas respaldos, hay datos que nunca se guardaron y no hay de dónde sacarlos:
|
||||
|
||||
| Dato | Desde cuándo existe |
|
||||
|---|---|
|
||||
| Quién creó cada consentimiento del turnero | 3 de agosto de 2026 |
|
||||
| Quién firmó cada toma de F-LAB-28 | 3 de agosto de 2026 |
|
||||
| Cédula del personal no enfermero | 3 de agosto de 2026 |
|
||||
|
||||
Los registros anteriores tienen esos campos vacíos, y **no hay traza de auditoría** que permita reconstruirlos. Vale como advertencia: cuando se agrega una columna para registrar quién hizo algo, lo anterior se pierde.
|
||||
@@ -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)
|
||||
@@ -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,96 @@
|
||||
# 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.
|
||||
|
||||
## Contenido configurable
|
||||
|
||||
Buena parte de lo que responde el bot vive en la base, no en el código: respuestas
|
||||
automáticas por palabra clave y menús interactivos. Ver
|
||||
[Menús y respuestas automáticas](?m=soporte&v=documentacion&s=tecnica&d=bot-menus-y-respuestas).
|
||||
|
||||
## `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,97 @@
|
||||
# Menús y respuestas automáticas del bot
|
||||
|
||||
Buena parte de lo que responde el bot **no está en el código**: es contenido configurable en la base. Cambiar lo que contesta no requiere tocar PHP ni desplegar.
|
||||
|
||||
## Respuestas automáticas
|
||||
|
||||
`autoresponses` — dispara una respuesta según lo que escriba el paciente.
|
||||
|
||||
| Columna | Para qué |
|
||||
|---|---|
|
||||
| `trigger_type` | Cómo se dispara: `welcome`, `keyword`, `contains` |
|
||||
| `trigger_value` | Las palabras que la activan, separadas por coma |
|
||||
| `response_text` | Lo que responde |
|
||||
| `response_type` | `text` o plantilla |
|
||||
| `template_name` | Plantilla de Meta, si aplica |
|
||||
| `menu_id` | Si en vez de texto debe mostrar un menú |
|
||||
| `priority` | Cuál gana si varias coinciden |
|
||||
| `is_active` | Permite desactivar sin borrar |
|
||||
|
||||
### Tipos de disparo
|
||||
|
||||
| Tipo | Cuándo actúa |
|
||||
|---|---|
|
||||
| `welcome` | Primer contacto |
|
||||
| `keyword` | El mensaje **es** una de las palabras listadas |
|
||||
| `contains` | El mensaje **contiene** el texto |
|
||||
|
||||
`keyword` es más estricto que `contains`. Para *«hola»* conviene `keyword` — si fuera `contains` se dispararía dentro de cualquier frase que la incluya.
|
||||
|
||||
### Las que están activas
|
||||
|
||||
Cubren saludo, precios, horarios, indicaciones previas, cotización y preparación para frotis. Son las preguntas que más se repiten, resueltas sin intervención humana.
|
||||
|
||||
## Menús interactivos
|
||||
|
||||
`menu_options` — el árbol de opciones numeradas que el paciente recorre respondiendo con un número.
|
||||
|
||||
| Columna | Para qué |
|
||||
|---|---|
|
||||
| `menu_id` | A qué menú pertenece la opción |
|
||||
| `option_number` | El número que marca el paciente |
|
||||
| `text` | Lo que se muestra |
|
||||
| `action_type` | Qué pasa al elegirla |
|
||||
| `action_value` | El destino o el texto de respuesta |
|
||||
| `is_active` | Permite ocultar sin borrar |
|
||||
|
||||
### Acciones
|
||||
|
||||
| `action_type` | Efecto |
|
||||
|---|---|
|
||||
| `menu` | Abre otro menú — `action_value` es su identificador |
|
||||
| `end` | Responde con `action_value` y cierra |
|
||||
| `message` | Envía el texto y sigue en el mismo menú |
|
||||
|
||||
### La estructura actual
|
||||
|
||||
```
|
||||
main_menu (1)
|
||||
├─ 1 Agendar toma a domicilio end
|
||||
├─ 2 Consultar resultados end
|
||||
├─ 3 Información sobre exámenes ─────► menu informacion_examenes (10)
|
||||
│ ├─ 1 Pruebas de embarazo end
|
||||
│ ├─ 2 Prueba de paternidad message
|
||||
│ ├─ 3 Pruebas de aliento end
|
||||
│ ├─ 4 Orina de 24 horas end
|
||||
│ ├─ 5 Solicitar cotización end
|
||||
│ └─ 6 Volver ──────────────────► main_menu
|
||||
├─ 4 Ubicación y horarios end
|
||||
├─ 5 Ver portafolio end
|
||||
├─ 6 Convenios end
|
||||
└─ 7 Salir end
|
||||
```
|
||||
|
||||
> Un menú se apunta por su **identificador** (`main_menu`, `informacion_examenes`), no por su número de fila. Cambiar el orden no rompe los enlaces.
|
||||
|
||||
## Estado de la conversación
|
||||
|
||||
`user_states` recuerda en qué punto quedó cada paciente: en qué menú está, si espera un dato, o si lo tomó un operador. Es lo que permite que responder «3» signifique algo.
|
||||
|
||||
Es la tabla más voluminosa del bot — una fila por contacto activo.
|
||||
|
||||
## Envíos masivos
|
||||
|
||||
`broadcast_history` registra los envíos a varios destinatarios a la vez.
|
||||
|
||||
> Fuera de la ventana de 24 horas hay que usar plantilla aprobada. Un envío masivo a contactos que no escribieron recientemente **solo puede hacerse con plantilla**.
|
||||
|
||||
## Encuestas
|
||||
|
||||
`survey_responses` guarda las respuestas de satisfacción. La encuesta se envía desde el historial del turnero con la plantilla `encuesta_turnero`.
|
||||
|
||||
## Al modificar el bot
|
||||
|
||||
- **Primero mire si alcanza con la base.** Muchos cambios de comportamiento son una fila en `autoresponses` o `menu_options`, sin desplegar nada.
|
||||
- **Use `is_active` en vez de borrar.** Permite volver atrás y conserva el historial.
|
||||
- **Cuide `priority`.** Si dos respuestas coinciden, gana la de mayor prioridad; sin ella el resultado depende del orden de la consulta.
|
||||
- **Verifique el árbol completo** después de tocar un menú: una opción que apunta a un menú inexistente deja al paciente sin salida.
|
||||
@@ -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 enfermero–domicilio |
|
||||
| `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`.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Registro de exámenes
|
||||
|
||||
Registro de exámenes realizados en sede: creación de órdenes, toma de muestras e ingreso de resultados. Es de los módulos nuevos — todo su código vive dentro de `modules/registro_exams/`.
|
||||
|
||||
## Vistas
|
||||
|
||||
| Vista | Qué hace |
|
||||
|---|---|
|
||||
| `lista` | Órdenes con filtros y estado |
|
||||
| `nueva_orden` | Alta de una orden |
|
||||
| `orden` | Detalle: ítems, estados e ingreso de resultados |
|
||||
| `etiqueta` | Etiqueta imprimible para rotular la muestra |
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Endpoint | Qué hace |
|
||||
|---|---|
|
||||
| `get_ordenes.php` | Lista con filtros |
|
||||
| `get_orden.php` | Detalle de una orden |
|
||||
| `save_orden.php` | Crea o actualiza |
|
||||
| `save_resultado.php` | Guarda el resultado de un ítem |
|
||||
| `cambiar_estado_item.php` | Avanza el estado de un ítem individual |
|
||||
|
||||
Usan `api/_helpers.php` del módulo, con las mismas convenciones que el turnero.
|
||||
|
||||
## Flujo
|
||||
|
||||
```
|
||||
nueva_orden orden orden
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────┐ ┌───────────┐ ┌─────────────┐
|
||||
│ crear ├──────►│ muestra ├──────────►│ resultado │
|
||||
│ orden │ │ tomada │ │ ingresado │
|
||||
└─────────┘ └───────────┘ └─────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
etiqueta se rotula
|
||||
imprimible la muestra
|
||||
```
|
||||
|
||||
El estado se lleva **por ítem**, no por orden completa: una orden puede tener unos exámenes resueltos y otros pendientes.
|
||||
|
||||
## Relación con otros módulos
|
||||
|
||||
| Módulo | Vínculo |
|
||||
|---|---|
|
||||
| `lab_examenes` | De ahí sale el catálogo y los valores de referencia |
|
||||
| `lab_pacientes` | El paciente de la orden |
|
||||
| `medicos` | El médico que la solicitó |
|
||||
|
||||
## Al tocarlo
|
||||
|
||||
Los resultados de laboratorio son información clínica: un ítem mal guardado o un valor de referencia equivocado tienen consecuencias reales. Cualquier cambio en `save_resultado.php` merece probarse con una orden de prueba antes de subirlo.
|
||||
@@ -0,0 +1,61 @@
|
||||
# Órdenes médicas, reportes y configuración
|
||||
|
||||
Tres módulos de la generación anterior: el código vive en archivos de la raíz y el módulo solo aporta el registro en el menú.
|
||||
|
||||
| Módulo | Archivo real |
|
||||
|---|---|
|
||||
| `lab_ordenes` | `lab_ordenes.php` |
|
||||
| `lab_reportes` | `lab_reportes.php` |
|
||||
| `lab_configuracion` | `lab_configuracion.php` |
|
||||
| `lab_dashboard` | `index.php` |
|
||||
|
||||
## Órdenes médicas
|
||||
|
||||
Las órdenes que el paciente envía por WhatsApp, normalmente como foto de la orden en papel. El módulo permite revisarlas y autorizarlas.
|
||||
|
||||
### Datos
|
||||
|
||||
`lab_ordenes_medicas` guarda tanto la imagen recibida como lo que el personal extrae de ella:
|
||||
|
||||
| Grupo | Columnas |
|
||||
|---|---|
|
||||
| Origen | `conversation_id`, `whatsapp_media_id`, `local_file` |
|
||||
| Vínculos | `paciente_id`, `domicilio_id` |
|
||||
| Contenido de la orden | `medico_nombre`, `medico_registro`, `fecha_orden`, `diagnostico`, `examenes_solicitados` |
|
||||
| Preparación | `requiere_ayuno`, `horas_ayuno`, `indicaciones` |
|
||||
| Trazabilidad | `revisada_por`, `revisada_at`, `autorizada_por`, `autorizada_at`, `comentario_revision` |
|
||||
|
||||
### Estados
|
||||
|
||||
```
|
||||
pendiente ──► autorizada
|
||||
│
|
||||
└──────► rechazada
|
||||
(con comentario)
|
||||
```
|
||||
|
||||
La orden llega en `pendiente`. Al revisarla se autoriza o se rechaza, y en ambos casos queda **quién** lo hizo y **cuándo** — es de los pocos flujos que registró trazabilidad desde el principio.
|
||||
|
||||
`requiere_ayuno` y `horas_ayuno` importan porque condicionan lo que se le indica al paciente antes de la toma.
|
||||
|
||||
## Reportes
|
||||
|
||||
`lab_reportes.php` — informes de ingresos, rendimiento y pagos.
|
||||
|
||||
Incluye el **tiempo de traslado del enfermero**, que sale de comparar las marcas de hora de las visitas domiciliarias. Es la métrica que permite ver si una ruta está mal armada.
|
||||
|
||||
> Para métricas del turnero (tiempos de atención, facturación del día) el lugar es el **dashboard del turnero**, no este módulo. Los dos existen y cubren cosas distintas: este mira el laboratorio en general y los domicilios; aquel mira la operación presencial del día.
|
||||
|
||||
## Configuración del laboratorio
|
||||
|
||||
`lab_configuracion.php` edita lo que aparece en el encabezado de todos los documentos generados: nombre, subtítulo, dirección, ciudad, teléfono, correo, logo y color.
|
||||
|
||||
Se guarda en `lab_config`, con las claves `empresa_*` y `doc_*`. Ver [Configuraciones críticas](?m=soporte&v=documentacion&s=operacion&d=configuraciones-criticas) para el detalle de qué se configura desde aquí y qué no.
|
||||
|
||||
> Desde aquí **no** se cambian las URL que llegan por WhatsApp en los botones de plantilla: esas viven en Meta.
|
||||
|
||||
## Dashboard
|
||||
|
||||
`index.php` es el panel principal del laboratorio y, a la vez, el punto de entrada histórico del sistema. Convive con `erp.php`, que es el del ERP modular.
|
||||
|
||||
Que el archivo de entrada original siga siendo una pantalla activa es una de las consecuencias de la [migración gradual](?m=soporte&v=documentacion&s=arquitectura&d=decisiones-y-deuda).
|
||||
@@ -1,4 +1,7 @@
|
||||
# Webhook WhatsApp — Endpoints y Características
|
||||
# Webhook de WhatsApp
|
||||
|
||||
_Migrado de `WEBHOOK_ENDPOINTS.md` (raíz del repositorio), donde vivía suelto._
|
||||
|
||||
|
||||
## Endpoint principal
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# Catálogos del laboratorio
|
||||
|
||||
Los módulos que administran las tablas maestras. Se agrupan acá porque comparten la misma forma: una pantalla de listado con alta, edición y baja sobre una tabla.
|
||||
|
||||
## Los módulos
|
||||
|
||||
| Módulo | Tabla principal | Qué administra |
|
||||
|---|---|---|
|
||||
| `lab_examenes` | `exam_tipos` | Catálogo de exámenes, valores de referencia y tarifas |
|
||||
| `medicos` | `medicos` | Médicos solicitantes |
|
||||
| `lab_pacientes` | `lab_pacientes` | Fichas clínicas e historial |
|
||||
| `lab_enfermeras` | `lab_enfermeras` | Personal clínico |
|
||||
| `lab_eps` | `lab_eps` | EPS y aseguradoras |
|
||||
| `lab_empresas` | `lab_empresas` | Empresas, convenios, subgrupos y tarifas |
|
||||
| `lab_ciudades` | `lab_ciudades` | Ciudades de pacientes |
|
||||
| `usuarios` | `admin_users`, `roles` | Usuarios y asignación de roles |
|
||||
|
||||
## Los que tienen endpoints propios
|
||||
|
||||
La mayoría son puentes al sistema anterior. Dos tienen código propio:
|
||||
|
||||
### `lab_examenes`
|
||||
|
||||
| Endpoint | Qué hace |
|
||||
|---|---|
|
||||
| `list.php` / `get.php` | Listado y detalle |
|
||||
| `save.php` | Alta y edición del examen |
|
||||
| `save_item.php` | Ítems que componen un examen |
|
||||
| `get_tarifas.php` / `save_tarifa.php` | Tarifas por empresa o convenio |
|
||||
|
||||
Es el catálogo del que dependen el turnero y el registro de exámenes. Un examen mal configurado se propaga a todo lo demás: consentimientos que no se piden, tarifas que no se aplican.
|
||||
|
||||
**`exam_tipo_consentimientos`** vincula un examen con los formularios que obliga a firmar. Es una de las dos fuentes de consentimientos del turnero; la otra es la estación destino.
|
||||
|
||||
### `medicos`
|
||||
|
||||
`list.php`, `save.php`, `delete.php`. El médico se asocia a la solicitud del turnero y sale impreso en los documentos.
|
||||
|
||||
## Pacientes
|
||||
|
||||
`lab_pacientes` es el más consultado de todos: lo usan el turnero, domicilios, registro de exámenes y el bot.
|
||||
|
||||
Campos que otros módulos dan por sentados:
|
||||
|
||||
| Campo | Quién lo usa |
|
||||
|---|---|
|
||||
| `numero_documento` | Búsqueda en todas las pantallas |
|
||||
| `telefono` | Envío de consentimientos y encuestas |
|
||||
| `nombre_completo`, `tipo_documento` | Encabezado de todos los documentos |
|
||||
| `fecha_nacimiento` | Cálculo de edad en formularios |
|
||||
| `eps` | Facturación |
|
||||
|
||||
Un teléfono mal cargado se manifiesta lejos de donde se originó: como un consentimiento que nunca llegó.
|
||||
|
||||
## Usuarios
|
||||
|
||||
Ver [Roles y permisos](?m=soporte&v=documentacion&s=arquitectura&d=roles-y-permisos) para el detalle del control de acceso.
|
||||
|
||||
Lo esencial al crear o editar un usuario:
|
||||
|
||||
- **`role` y `role_id` deben cambiarse juntos.** La interfaz lee uno, los permisos salen del otro.
|
||||
- **Cargue la cédula.** Es lo que aparece bajo la firma en los formularios.
|
||||
- El usuario debe **volver a iniciar sesión** para que un cambio de permisos surta efecto.
|
||||
|
||||
## Al agregar un catálogo nuevo
|
||||
|
||||
Si es un ABM simple, siga el patrón de `medicos`: un `views/index.php` y tres endpoints (`list`, `save`, `delete`). Registralo en `SYSTEM_MODULES` y concédalo a los roles que corresponda, o nadie lo verá.
|
||||
@@ -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. **Verifique permisos en el propio endpoint.** Que la vista haya ocultado el botón no protege nada.
|
||||
2. **Resuelva la identidad en el servidor.** Para saber quién hace una acción, use `adminId()`, no un valor que mande el navegador.
|
||||
3. **Consultas preparadas siempre.**
|
||||
4. **Deje 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`.
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
/**
|
||||
* Descriptor del módulo Soporte — documentación del proyecto.
|
||||
* El manual de usuario es visible para cualquier usuario autenticado; el resto
|
||||
* de secciones (técnica, arquitectura, operación) solo para administradores.
|
||||
*/
|
||||
|
||||
// Una sola entrada en el menú: la navegación por secciones y documentos vive
|
||||
// dentro de la propia página. Tener aquí un enlace por sección hacía que al
|
||||
// abrir una quedaran dos entradas marcadas como activas a la vez.
|
||||
$_sopLinks = [
|
||||
['name' => 'Documentación', 'icon' => 'fas fa-book', 'route' => '/erp.php?m=soporte&v=documentacion'],
|
||||
];
|
||||
|
||||
return [
|
||||
'slug' => 'soporte',
|
||||
'name' => 'Soporte',
|
||||
'icon' => 'fas fa-life-ring',
|
||||
'category' => 'sistema',
|
||||
'route' => '/erp.php?m=soporte&v=documentacion',
|
||||
'is_active' => true,
|
||||
'sort_order' => 90,
|
||||
'oleada' => 1,
|
||||
'description' => 'Documentación del proyecto: manual de usuario, técnica, arquitectura y operación',
|
||||
'links' => $_sopLinks,
|
||||
];
|
||||
@@ -0,0 +1,512 @@
|
||||
<?php
|
||||
/**
|
||||
* modules/soporte/views/documentacion.php
|
||||
* Visor de la documentación del proyecto.
|
||||
*
|
||||
* ?s=<seccion>&d=<documento> documento puntual
|
||||
* ?s=<seccion> portada de la sección
|
||||
* (sin parámetros) portada general
|
||||
*/
|
||||
require_once APP_ROOT . '/config/config.php';
|
||||
if (!isUserLoggedIn()) { header('Location: ' . BASE_URL . 'login.php'); exit; }
|
||||
|
||||
require_once __DIR__ . '/../Markdown.php';
|
||||
require_once __DIR__ . '/../DocIndex.php';
|
||||
require_once __DIR__ . '/../Generadores.php';
|
||||
|
||||
$arbol = DocIndex::arbol();
|
||||
$seccion = preg_replace('/[^a-z0-9-]/', '', $_GET['s'] ?? '');
|
||||
$docSlug = preg_replace('/[^a-z0-9-]/', '', $_GET['d'] ?? '');
|
||||
|
||||
$archivo = ($seccion && $docSlug) ? DocIndex::resolver($seccion, $docSlug) : null;
|
||||
$titulo = 'Documentación';
|
||||
$cuerpo = '';
|
||||
$toc = [];
|
||||
|
||||
if ($archivo) {
|
||||
[, $crudo] = DocIndex::leer($archivo); // descarta la cabecera de permisos
|
||||
$md = Generadores::expandir($crudo);
|
||||
$cuerpo = Markdown::render($md);
|
||||
$toc = Markdown::indice($md);
|
||||
foreach ($arbol[$seccion]['docs'] ?? [] as $d) {
|
||||
if ($d['slug'] === $docSlug) { $titulo = $d['titulo']; break; }
|
||||
}
|
||||
} elseif ($seccion && isset($arbol[$seccion])) {
|
||||
$titulo = $arbol[$seccion]['titulo'];
|
||||
}
|
||||
|
||||
Layout::open('Soporte · Documentación', 'fas fa-life-ring');
|
||||
?>
|
||||
<style>
|
||||
.doc-wrap { display:flex; gap:0; align-items:flex-start; min-height:calc(100vh - 60px); background:#fff; }
|
||||
|
||||
/* ── Índice lateral ── */
|
||||
.doc-nav { width:280px; flex-shrink:0; border-right:1px solid #e2e8f0; background:#f8fafc;
|
||||
align-self:stretch; padding:18px 0 60px; position:sticky; top:0; max-height:100vh; overflow-y:auto; }
|
||||
.doc-nav .buscador { padding:0 16px 14px; }
|
||||
.doc-nav .buscador input { width:100%; font-size:.84rem; padding:7px 11px; border:1px solid #cbd5e1;
|
||||
border-radius:8px; background:#fff; }
|
||||
.doc-nav .buscador input:focus { outline:none; border-color:#2563eb; box-shadow:0 0 0 3px rgba(37,99,235,.12); }
|
||||
.doc-nav .btn-pdf { display:block; margin-top:8px; text-align:center; font-size:.78rem;
|
||||
padding:6px 10px; border:1px solid #cbd5e1; border-radius:8px;
|
||||
color:#475569; text-decoration:none; background:#fff; }
|
||||
.doc-nav .btn-pdf:hover { border-color:#2563eb; color:#1d4ed8; }
|
||||
.doc-nav-sec { padding:10px 16px 4px; font-size:.68rem; font-weight:800; text-transform:uppercase;
|
||||
letter-spacing:.08em; color:#64748b; display:flex; align-items:center; gap:6px; }
|
||||
.doc-nav a { display:block; padding:5px 16px 5px 30px; font-size:.83rem; color:#334155;
|
||||
text-decoration:none; border-left:2px solid transparent; }
|
||||
.doc-nav a:hover { background:#eef2f7; color:#1e293b; }
|
||||
.doc-nav a.activo { background:#e0edff; color:#1d4ed8; font-weight:600; border-left-color:#2563eb; }
|
||||
|
||||
/* ── Resultados de búsqueda ── */
|
||||
#resultados { padding:0 16px; }
|
||||
#resultados .r { display:block; padding:8px 10px; border-radius:8px; text-decoration:none;
|
||||
margin-bottom:4px; background:#fff; border:1px solid #e2e8f0; }
|
||||
#resultados .r:hover { border-color:#2563eb; }
|
||||
#resultados .r-t { font-size:.83rem; font-weight:600; color:#1e293b; }
|
||||
#resultados .r-c { font-size:.68rem; color:#64748b; text-transform:uppercase; letter-spacing:.05em; }
|
||||
#resultados .r-x { font-size:.75rem; color:#475569; margin-top:2px; }
|
||||
#resultados .vacio { font-size:.8rem; color:#94a3b8; padding:8px 4px; }
|
||||
|
||||
/* ── Contenido ── */
|
||||
.doc-main { flex:1; min-width:0; display:flex; gap:0; }
|
||||
.doc-body { flex:1; min-width:0; padding:26px 40px 80px; max-width:900px; }
|
||||
.doc-ruta { font-size:.73rem; color:#94a3b8; margin-bottom:14px; }
|
||||
.doc-ruta a { color:#64748b; text-decoration:none; }
|
||||
.doc-ruta a:hover { text-decoration:underline; }
|
||||
|
||||
.doc-body h1 { font-size:1.65rem; font-weight:800; color:#0f172a; margin:0 0 18px;
|
||||
padding-bottom:12px; border-bottom:1px solid #e2e8f0; }
|
||||
.doc-body h2 { font-size:1.2rem; font-weight:700; color:#1e293b; margin:32px 0 12px;
|
||||
padding-top:6px; scroll-margin-top:20px; }
|
||||
.doc-body h3 { font-size:1rem; font-weight:700; color:#334155; margin:24px 0 9px; scroll-margin-top:20px; }
|
||||
.doc-body h4 { font-size:.9rem; font-weight:700; color:#475569; margin:18px 0 7px; }
|
||||
.doc-body p { font-size:.9rem; line-height:1.7; color:#334155; margin:0 0 13px; }
|
||||
.doc-body ul, .doc-body ol { font-size:.9rem; line-height:1.7; color:#334155; margin:0 0 13px; padding-left:24px; }
|
||||
.doc-body li { margin-bottom:4px; }
|
||||
.doc-body li > ul, .doc-body li > ol { margin:4px 0 2px; }
|
||||
.doc-body a { color:#2563eb; }
|
||||
.doc-body strong { color:#0f172a; }
|
||||
.doc-body code { background:#f1f5f9; color:#be185d; padding:1px 5px; border-radius:4px;
|
||||
font-size:.83em; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; }
|
||||
.doc-body pre { background:#0f172a; color:#e2e8f0; padding:14px 16px; border-radius:10px;
|
||||
overflow-x:auto; margin:0 0 15px; }
|
||||
.doc-body pre code { background:none; color:inherit; padding:0; font-size:.8rem; line-height:1.6; }
|
||||
.doc-body blockquote { border-left:3px solid #f59e0b; background:#fffbeb; margin:0 0 15px;
|
||||
padding:10px 16px; border-radius:0 8px 8px 0; }
|
||||
.doc-body blockquote p { margin:0; color:#78350f; font-size:.86rem; }
|
||||
.doc-body hr { border:none; border-top:1px solid #e2e8f0; margin:26px 0; }
|
||||
.tabla-scroll { overflow-x:auto; margin:0 0 16px; }
|
||||
.doc-body table { width:100%; border-collapse:collapse; font-size:.82rem; }
|
||||
.doc-body thead th { background:#f8fafc; text-align:left; font-weight:700; color:#475569;
|
||||
padding:8px 11px; border-bottom:2px solid #e2e8f0; white-space:nowrap; }
|
||||
.doc-body tbody td { padding:7px 11px; border-bottom:1px solid #f1f5f9; color:#334155; vertical-align:top; }
|
||||
.doc-body tbody tr:hover { background:#f8fafc; }
|
||||
|
||||
/* ── Tabla de contenidos ── */
|
||||
.doc-toc { width:210px; flex-shrink:0; padding:32px 20px 60px; position:sticky; top:0;
|
||||
max-height:100vh; overflow-y:auto; }
|
||||
.doc-toc-t { font-size:.66rem; font-weight:800; text-transform:uppercase; letter-spacing:.08em;
|
||||
color:#94a3b8; margin-bottom:9px; }
|
||||
.doc-toc a { display:block; font-size:.76rem; color:#64748b; text-decoration:none;
|
||||
padding:3px 0 3px 9px; border-left:2px solid #e2e8f0; line-height:1.4; }
|
||||
.doc-toc a:hover { color:#2563eb; border-left-color:#93c5fd; }
|
||||
.doc-toc a.n3 { padding-left:20px; font-size:.73rem; }
|
||||
|
||||
/* ── Portadas ── */
|
||||
.doc-hero { margin-bottom:26px; }
|
||||
.doc-hero h1 { font-size:1.7rem; font-weight:800; color:#0f172a; margin:0 0 8px; }
|
||||
.doc-hero p { font-size:.92rem; color:#64748b; margin:0; }
|
||||
.tarjetas { display:grid; grid-template-columns:repeat(auto-fill,minmax(260px,1fr)); gap:14px; }
|
||||
.tarjeta { display:block; padding:18px; border:1px solid #e2e8f0; border-radius:12px;
|
||||
text-decoration:none; background:#fff; transition:border-color .15s, transform .15s; }
|
||||
.tarjeta:hover { border-color:#2563eb; transform:translateY(-2px); }
|
||||
.tarjeta i { font-size:1.3rem; color:#2563eb; }
|
||||
.tarjeta .t { font-size:1rem; font-weight:700; color:#1e293b; margin:9px 0 5px; }
|
||||
.tarjeta .d { font-size:.82rem; color:#64748b; line-height:1.5; }
|
||||
.tarjeta .n { font-size:.72rem; color:#94a3b8; margin-top:9px; }
|
||||
|
||||
.lista-docs { list-style:none; padding:0; margin:0; }
|
||||
.lista-docs li { border-bottom:1px solid #f1f5f9; }
|
||||
.lista-docs a { display:block; padding:12px 4px; text-decoration:none; color:#1e293b; font-size:.92rem; }
|
||||
.lista-docs a:hover { color:#2563eb; }
|
||||
|
||||
@media (max-width:1100px) { .doc-toc { display:none; } }
|
||||
@media (max-width:820px) {
|
||||
.doc-nav { position:static; width:100%; max-height:none; border-right:none;
|
||||
border-bottom:1px solid #e2e8f0; }
|
||||
.doc-wrap { flex-direction:column; }
|
||||
.doc-body { padding:20px 18px 60px; }
|
||||
}
|
||||
@media print {
|
||||
.doc-nav, .doc-toc, .doc-ruta, #btnAI, #aiPanel { display:none !important; }
|
||||
.doc-body { max-width:none; padding:0; }
|
||||
.doc-body pre { background:#f8fafc; color:#0f172a; border:1px solid #cbd5e1; }
|
||||
}
|
||||
|
||||
/* ── Panel LIA — mismo diseño que el dashboard del turnero ── */
|
||||
#aiPanel {
|
||||
position:fixed; bottom:0; right:24px; width:400px; z-index:1080;
|
||||
box-shadow:0 -8px 40px rgba(0,0,0,.22); border-radius:20px 20px 0 0;
|
||||
background:#fff; border:1px solid #e2e8f0; border-bottom:none;
|
||||
transform:translateY(100%); transition:transform .35s cubic-bezier(.4,0,.2,1);
|
||||
display:flex; flex-direction:column; max-height:580px;
|
||||
}
|
||||
#aiPanel.open { transform:translateY(0); }
|
||||
#aiPanel.minimized .ai-body { display:none; }
|
||||
#aiPanel.minimized { max-height:none; }
|
||||
.ai-header {
|
||||
background:linear-gradient(135deg,#0d47a1,#1565c0,#0288d1);
|
||||
color:#fff; padding:14px 18px; border-radius:20px 20px 0 0;
|
||||
display:flex; align-items:center; gap:12px; flex-shrink:0; cursor:pointer; user-select:none;
|
||||
}
|
||||
.lia-logo {
|
||||
width:44px; height:44px; border-radius:50%;
|
||||
background:rgba(255,255,255,.15); border:2px solid rgba(255,255,255,.4);
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
font-size:1rem; font-weight:900; letter-spacing:-1px; flex-shrink:0;
|
||||
transition:transform .3s;
|
||||
}
|
||||
.lia-logo:hover { transform:scale(1.08); }
|
||||
.lia-logo .lia-i { color:#7dd3fc; }
|
||||
.ai-title { font-weight:800; font-size:1.05rem; letter-spacing:.5px; }
|
||||
.btn-tts { background:rgba(255,255,255,.15); border:1px solid rgba(255,255,255,.3);
|
||||
color:rgba(255,255,255,.7); cursor:pointer; padding:5px 9px; border-radius:8px;
|
||||
font-size:.88rem; transition:all .2s; }
|
||||
.btn-tts:hover { background:rgba(255,255,255,.25); color:#fff; }
|
||||
.ai-header .btn-close { filter:invert(1) brightness(2); opacity:.7; }
|
||||
.ai-header .btn-close:hover { opacity:1; }
|
||||
.ai-body { display:flex; flex-direction:column; flex:1; overflow:hidden; }
|
||||
.ai-quick { display:flex; gap:7px; flex-wrap:wrap; padding:10px 14px;
|
||||
border-bottom:1px solid #f1f5f9; flex-shrink:0; background:#fafbff; }
|
||||
.ai-chip { background:#fff; border:1.5px solid #e2e8f0; border-radius:99px; padding:6px 13px;
|
||||
font-size:.82rem; cursor:pointer; color:#334155; white-space:nowrap;
|
||||
transition:all .15s; font-weight:500; }
|
||||
.ai-chip:hover { background:#eff6ff; border-color:#93c5fd; color:#1d4ed8; }
|
||||
.ai-messages { flex:1; overflow-y:auto; padding:14px 16px; display:flex; flex-direction:column; gap:10px; }
|
||||
.ai-msg { max-width:92%; padding:10px 14px; border-radius:16px; font-size:.9rem;
|
||||
line-height:1.55; animation:msg-in .2s ease; word-wrap:break-word; }
|
||||
@keyframes msg-in { from{opacity:0;transform:translateY(6px)} to{opacity:1;transform:none} }
|
||||
.ai-msg.user { background:linear-gradient(135deg,#1565c0,#0288d1); color:#fff;
|
||||
align-self:flex-end; border-radius:16px 16px 2px 16px; }
|
||||
.ai-msg.bot { background:#f1f5f9; color:#1e293b; align-self:flex-start; border-radius:16px 16px 16px 2px; }
|
||||
.ai-msg.bot.typing::after { content:'●●●'; animation:dots 1.2s infinite; letter-spacing:2px; }
|
||||
@keyframes dots { 0%,100%{opacity:.3} 50%{opacity:1} }
|
||||
.ai-msg.bot strong { color:#1565c0; }
|
||||
.ai-msg.bot ul, .ai-msg.bot ol { margin:4px 0 4px 16px; padding:0; }
|
||||
.ai-msg.bot code { background:#e2e8f0; color:#be185d; padding:1px 4px; border-radius:3px; font-size:.9em; }
|
||||
.ai-msg.bot pre { background:#0f172a; color:#e2e8f0; padding:9px 11px; border-radius:8px;
|
||||
overflow-x:auto; font-size:.76rem; margin:6px 0; }
|
||||
.ai-fuentes { font-size:.68rem; color:#94a3b8; align-self:flex-start; margin:-6px 0 0 4px; }
|
||||
.ai-footer { padding:10px 14px; border-top:1px solid #f1f5f9; display:flex; gap:8px;
|
||||
flex-shrink:0; background:#fafbff; }
|
||||
.ai-footer input { flex:1; border:1.5px solid #e2e8f0; border-radius:10px; padding:9px 14px;
|
||||
font-size:.9rem; outline:none; background:#fff; }
|
||||
.ai-footer input:focus { border-color:#1565c0; }
|
||||
.ai-footer .btn-send { background:linear-gradient(135deg,#1565c0,#0288d1); color:#fff; border:none;
|
||||
border-radius:10px; padding:9px 16px; cursor:pointer; font-size:.88rem; min-width:42px; }
|
||||
.ai-footer .btn-send:disabled { opacity:.5; cursor:default; }
|
||||
#btnAI {
|
||||
position:fixed; bottom:24px; right:24px; z-index:1079;
|
||||
background:linear-gradient(135deg,#0d47a1,#1565c0,#0288d1);
|
||||
color:#fff; border:none; border-radius:50%; width:58px; height:58px;
|
||||
font-size:.78rem; font-weight:900; letter-spacing:-.5px;
|
||||
cursor:pointer; box-shadow:0 4px 20px rgba(21,101,192,.45);
|
||||
transition:transform .2s,box-shadow .2s;
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
}
|
||||
#btnAI:hover { transform:scale(1.1); box-shadow:0 6px 28px rgba(21,101,192,.55); }
|
||||
#btnAI.hidden { display:none; }
|
||||
@media(max-width:600px) { #aiPanel { width:100%; right:0; border-radius:16px 16px 0 0; } }
|
||||
</style>
|
||||
|
||||
<div class="doc-wrap">
|
||||
<!-- ── Índice lateral ── -->
|
||||
<nav class="doc-nav">
|
||||
<div class="buscador">
|
||||
<input type="search" id="q" placeholder="Buscar en la documentación…" autocomplete="off">
|
||||
<a class="btn-pdf" href="<?= BASE_URL ?>erp.php?m=soporte&v=imprimir" target="_blank">
|
||||
<i class="fas fa-file-pdf"></i> Exportar a PDF
|
||||
</a>
|
||||
</div>
|
||||
<div id="resultados" style="display:none"></div>
|
||||
<div id="arbol">
|
||||
<?php foreach ($arbol as $sec => $cfg): ?>
|
||||
<div class="doc-nav-sec">
|
||||
<i class="<?= htmlspecialchars($cfg['icono']) ?>"></i>
|
||||
<a href="<?= BASE_URL ?>erp.php?m=soporte&v=documentacion&s=<?= urlencode($sec) ?>"
|
||||
style="padding:0;border:none;color:inherit;font:inherit;letter-spacing:inherit">
|
||||
<?= htmlspecialchars($cfg['titulo']) ?>
|
||||
</a>
|
||||
</div>
|
||||
<?php foreach ($cfg['docs'] as $d): ?>
|
||||
<a href="<?= BASE_URL ?>erp.php?m=soporte&v=documentacion&s=<?= urlencode($sec) ?>&d=<?= urlencode($d['slug']) ?>"
|
||||
class="<?= ($sec === $seccion && $d['slug'] === $docSlug) ? 'activo' : '' ?>">
|
||||
<?= htmlspecialchars($d['titulo']) ?>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="doc-main">
|
||||
<div class="doc-body">
|
||||
<?php if ($archivo): ?>
|
||||
<div class="doc-ruta">
|
||||
<a href="<?= BASE_URL ?>erp.php?m=soporte&v=documentacion">Documentación</a>
|
||||
›
|
||||
<a href="<?= BASE_URL ?>erp.php?m=soporte&v=documentacion&s=<?= urlencode($seccion) ?>">
|
||||
<?= htmlspecialchars($arbol[$seccion]['titulo'] ?? $seccion) ?>
|
||||
</a>
|
||||
› <?= htmlspecialchars($titulo) ?>
|
||||
</div>
|
||||
<?= $cuerpo ?>
|
||||
|
||||
<?php elseif ($seccion && isset($arbol[$seccion])): ?>
|
||||
<div class="doc-ruta">
|
||||
<a href="<?= BASE_URL ?>erp.php?m=soporte&v=documentacion">Documentación</a>
|
||||
› <?= htmlspecialchars($arbol[$seccion]['titulo']) ?>
|
||||
</div>
|
||||
<div class="doc-hero">
|
||||
<h1><?= htmlspecialchars($arbol[$seccion]['titulo']) ?></h1>
|
||||
<p><?= htmlspecialchars($arbol[$seccion]['resumen']) ?></p>
|
||||
</div>
|
||||
<?php if ($arbol[$seccion]['docs']): ?>
|
||||
<ul class="lista-docs">
|
||||
<?php foreach ($arbol[$seccion]['docs'] as $d): ?>
|
||||
<li><a href="<?= BASE_URL ?>erp.php?m=soporte&v=documentacion&s=<?= urlencode($seccion) ?>&d=<?= urlencode($d['slug']) ?>">
|
||||
<?= htmlspecialchars($d['titulo']) ?>
|
||||
</a></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php else: ?>
|
||||
<p class="text-muted">Esta sección aún no tiene documentos.</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php else: ?>
|
||||
<div class="doc-hero">
|
||||
<h1>Documentación del sistema</h1>
|
||||
<p>Todo sobre cómo funciona este ERP: cómo se usa, cómo está construido y qué hacer cuando algo falla.</p>
|
||||
</div>
|
||||
<div class="tarjetas">
|
||||
<?php foreach ($arbol as $sec => $cfg): ?>
|
||||
<a class="tarjeta" href="<?= BASE_URL ?>erp.php?m=soporte&v=documentacion&s=<?= urlencode($sec) ?>">
|
||||
<i class="<?= htmlspecialchars($cfg['icono']) ?>"></i>
|
||||
<div class="t"><?= htmlspecialchars($cfg['titulo']) ?></div>
|
||||
<div class="d"><?= htmlspecialchars($cfg['resumen']) ?></div>
|
||||
<div class="n"><?= count($cfg['docs']) ?> documento<?= count($cfg['docs']) === 1 ? '' : 's' ?></div>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if ($toc): ?>
|
||||
<aside class="doc-toc">
|
||||
<div class="doc-toc-t">En esta página</div>
|
||||
<?php foreach ($toc as $t): ?>
|
||||
<a href="#<?= htmlspecialchars($t['slug']) ?>" class="<?= $t['nivel'] === 3 ? 'n3' : '' ?>">
|
||||
<?= htmlspecialchars($t['texto']) ?>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</aside>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Panel LIA — asistente sobre la documentación ── -->
|
||||
<button id="btnAI" onclick="toggleAI()" title="Preguntar a LIA">LIA</button>
|
||||
<div id="aiPanel">
|
||||
<div class="ai-header" onclick="minimizarIA(event)">
|
||||
<div class="lia-logo" id="liaLogo">
|
||||
<span>L</span><span class="lia-i">I</span><span>A</span>
|
||||
</div>
|
||||
<div style="flex:1;line-height:1.1">
|
||||
<div class="ai-title">LIA</div>
|
||||
<div style="font-size:.68rem;opacity:.75;font-weight:400">Asistente de Documentación</div>
|
||||
</div>
|
||||
<button class="btn-tts" onclick="event.stopPropagation();minimizarIA(event)" title="Minimizar" id="btnMin">
|
||||
<i class="fas fa-minus" id="iconMin"></i>
|
||||
</button>
|
||||
<button class="btn-close" onclick="event.stopPropagation();toggleAI()"></button>
|
||||
</div>
|
||||
<div class="ai-body" id="aiBody">
|
||||
<div class="ai-quick" id="aiQuick"></div>
|
||||
<div class="ai-messages" id="aiMessages"></div>
|
||||
<div class="ai-footer">
|
||||
<input type="text" id="aiInput" placeholder="Pregunta cómo hacer algo…" maxlength="400"
|
||||
onkeydown="if(event.key==='Enter')enviarIA()">
|
||||
<button class="btn-send" onclick="enviarIA()"><i class="fas fa-paper-plane"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/* ── Buscador: índice completo servido con la página ── */
|
||||
const DOCS = <?= json_encode(DocIndex::indiceBusqueda(), JSON_UNESCAPED_UNICODE) ?>;
|
||||
const BASEQ = '<?= BASE_URL ?>erp.php?m=soporte&v=documentacion';
|
||||
|
||||
const norm = s => (s || '').toLowerCase()
|
||||
.normalize('NFD').replace(/[̀-ͯ]/g, '');
|
||||
|
||||
function buscar(termino) {
|
||||
const q = norm(termino).trim();
|
||||
const cajaR = document.getElementById('resultados');
|
||||
const cajaA = document.getElementById('arbol');
|
||||
if (q.length < 2) { cajaR.style.display = 'none'; cajaA.style.display = ''; return; }
|
||||
|
||||
const palabras = q.split(/\s+/);
|
||||
const hits = [];
|
||||
DOCS.forEach(d => {
|
||||
const heno = norm(d.t + ' ' + d.c + ' ' + d.x);
|
||||
if (!palabras.every(p => heno.includes(p))) return;
|
||||
// El título pesa más que el cuerpo
|
||||
const enTitulo = palabras.every(p => norm(d.t).includes(p));
|
||||
const pos = heno.indexOf(palabras[0]);
|
||||
hits.push({ d, score: (enTitulo ? 0 : 1000) + pos, pos });
|
||||
});
|
||||
hits.sort((a, b) => a.score - b.score);
|
||||
|
||||
cajaA.style.display = 'none';
|
||||
cajaR.style.display = '';
|
||||
if (!hits.length) {
|
||||
cajaR.innerHTML = '<div class="vacio">Sin resultados para «' + esc(termino) + '»</div>';
|
||||
return;
|
||||
}
|
||||
cajaR.innerHTML = hits.slice(0, 20).map(h => {
|
||||
const ini = Math.max(0, h.pos - 40);
|
||||
const frag = h.d.x.substr(ini, 120).trim();
|
||||
return `<a class="r" href="${BASEQ}&s=${encodeURIComponent(h.d.s)}&d=${encodeURIComponent(h.d.u)}">
|
||||
<div class="r-c">${esc(h.d.c)}</div>
|
||||
<div class="r-t">${esc(h.d.t)}</div>
|
||||
<div class="r-x">${ini > 0 ? '…' : ''}${esc(frag)}…</div>
|
||||
</a>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s == null ? '' : String(s);
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
/* ── LIA: asistente sobre la documentación ──
|
||||
Mismo comportamiento y nombres que el panel del dashboard del turnero. */
|
||||
const AI_API = '<?= BASE_URL ?>modules/soporte/api/ai_docs.php';
|
||||
let _liaHistorial = [];
|
||||
|
||||
function toggleAI() {
|
||||
const panel = document.getElementById('aiPanel');
|
||||
const fab = document.getElementById('btnAI');
|
||||
const abre = !panel.classList.contains('open');
|
||||
panel.classList.toggle('open', abre);
|
||||
panel.classList.remove('minimized');
|
||||
fab.classList.toggle('hidden', abre);
|
||||
if (abre) {
|
||||
if (!document.getElementById('aiMessages').children.length) saludarIA();
|
||||
document.getElementById('aiInput').focus();
|
||||
}
|
||||
}
|
||||
|
||||
function minimizarIA(e) {
|
||||
if (e) e.stopPropagation();
|
||||
const panel = document.getElementById('aiPanel');
|
||||
panel.classList.toggle('minimized');
|
||||
const icono = document.getElementById('iconMin');
|
||||
icono.className = panel.classList.contains('minimized') ? 'fas fa-chevron-up' : 'fas fa-minus';
|
||||
}
|
||||
|
||||
function saludarIA() {
|
||||
agregarMsg('bot', 'Hola, soy LIA. Pregúnteme cómo hacer algo en el sistema y le respondo con la documentación.');
|
||||
// Atajos armados con los documentos que este usuario realmente puede ver
|
||||
const quick = document.getElementById('aiQuick');
|
||||
quick.innerHTML = DOCS.slice(0, 4)
|
||||
.map(d => `<button class="ai-chip" onclick="enviarRapido('¿Qué explica ${esc(d.t)}?')">${esc(d.t)}</button>`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
function enviarRapido(texto) {
|
||||
document.getElementById('aiInput').value = texto;
|
||||
enviarIA();
|
||||
}
|
||||
|
||||
function agregarMsg(tipo, html) {
|
||||
const msgs = document.getElementById('aiMessages');
|
||||
const div = document.createElement('div');
|
||||
div.className = 'ai-msg ' + tipo;
|
||||
div.innerHTML = html;
|
||||
msgs.appendChild(div);
|
||||
msgs.scrollTop = msgs.scrollHeight;
|
||||
return div;
|
||||
}
|
||||
|
||||
/* Formato mínimo de la respuesta: negritas, código, listas y saltos. */
|
||||
function mdToHtml(md) {
|
||||
let h = esc(md);
|
||||
h = h.replace(/```[\s\S]*?```/g, m => '<pre>' + m.replace(/```\w*\n?/g, '') + '</pre>');
|
||||
h = h.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||
h = h.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||
h = h.replace(/^\s*[-*]\s+(.*)$/gm, '<li>$1</li>');
|
||||
h = h.replace(/(<li>[\s\S]*?<\/li>)/g, '<ul>$1</ul>');
|
||||
return h.replace(/\n{2,}/g, '<br><br>').replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
async function enviarIA() {
|
||||
const input = document.getElementById('aiInput');
|
||||
const pregunta = input.value.trim();
|
||||
if (!pregunta) return;
|
||||
|
||||
document.getElementById('aiQuick').innerHTML = '';
|
||||
agregarMsg('user', esc(pregunta));
|
||||
|
||||
const typing = agregarMsg('bot typing', '');
|
||||
input.value = '';
|
||||
input.disabled = true;
|
||||
|
||||
try {
|
||||
const res = await fetch(AI_API, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pregunta, historial: _liaHistorial }),
|
||||
});
|
||||
const d = await res.json();
|
||||
typing.className = 'ai-msg bot';
|
||||
|
||||
if (d.ok) {
|
||||
typing.innerHTML = mdToHtml(d.respuesta);
|
||||
if (d.fuentes && d.fuentes.length) {
|
||||
const f = document.createElement('div');
|
||||
f.className = 'ai-fuentes';
|
||||
f.textContent = 'Según: ' + d.fuentes.join(' · ');
|
||||
document.getElementById('aiMessages').appendChild(f);
|
||||
}
|
||||
_liaHistorial.push({ rol: 'user', texto: pregunta }, { rol: 'model', texto: d.respuesta });
|
||||
if (_liaHistorial.length > 6) _liaHistorial = _liaHistorial.slice(-6);
|
||||
} else {
|
||||
typing.textContent = d.error || 'No pude responder.';
|
||||
}
|
||||
} catch (_) {
|
||||
typing.className = 'ai-msg bot';
|
||||
typing.textContent = 'Error de conexión';
|
||||
} finally {
|
||||
input.disabled = false;
|
||||
input.focus();
|
||||
const msgs = document.getElementById('aiMessages');
|
||||
msgs.scrollTop = msgs.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
const inputQ = document.getElementById('q');
|
||||
let _t = null;
|
||||
inputQ.addEventListener('input', () => {
|
||||
clearTimeout(_t);
|
||||
_t = setTimeout(() => buscar(inputQ.value), 120);
|
||||
});
|
||||
// Atajo: "/" enfoca el buscador
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === '/' && document.activeElement !== inputQ) { e.preventDefault(); inputQ.focus(); }
|
||||
if (e.key === 'Escape' && document.activeElement === inputQ) { inputQ.value = ''; buscar(''); inputQ.blur(); }
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php Layout::close(); ?>
|
||||
@@ -0,0 +1,270 @@
|
||||
<?php
|
||||
/**
|
||||
* modules/soporte/views/imprimir.php
|
||||
* Documentación completa en un solo documento, maquetado para imprimir a PDF.
|
||||
*
|
||||
* ?s=<seccion> limita la exportación a una sección
|
||||
* (sin parámetro) exporta todo lo que el usuario puede ver
|
||||
*
|
||||
* Se sirve sin el layout del ERP: la idea es que la página sea el entregable.
|
||||
*/
|
||||
require_once APP_ROOT . '/config/config.php';
|
||||
if (!isUserLoggedIn()) { header('Location: ' . BASE_URL . 'login.php'); exit; }
|
||||
|
||||
require_once __DIR__ . '/../Markdown.php';
|
||||
require_once __DIR__ . '/../DocIndex.php';
|
||||
require_once __DIR__ . '/../Generadores.php';
|
||||
|
||||
$arbol = DocIndex::arbol();
|
||||
|
||||
// Filtro opcional por sección
|
||||
$soloSeccion = preg_replace('/[^a-z0-9-]/', '', $_GET['s'] ?? '');
|
||||
if ($soloSeccion && isset($arbol[$soloSeccion])) {
|
||||
$arbol = [$soloSeccion => $arbol[$soloSeccion]];
|
||||
}
|
||||
|
||||
// Datos del laboratorio para la portada
|
||||
$emp = ['empresa_nombre' => 'Laboratorio Clínico', 'empresa_subtitulo' => '', 'doc_logo_base64' => ''];
|
||||
try {
|
||||
$rows = Database::getInstance()->getConnection()->query(
|
||||
"SELECT clave, valor FROM lab_config
|
||||
WHERE clave IN ('empresa_nombre','empresa_subtitulo','doc_logo_base64')"
|
||||
)->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
$emp = array_merge($emp, $rows ?: []);
|
||||
} catch (\Throwable $_) {}
|
||||
|
||||
// Numeración: 1. Sección → 1.1 Documento
|
||||
$capitulos = [];
|
||||
$nSec = 0;
|
||||
foreach ($arbol as $sec => $cfg) {
|
||||
$nSec++;
|
||||
$docs = [];
|
||||
$nDoc = 0;
|
||||
foreach ($cfg['docs'] as $d) {
|
||||
$nDoc++;
|
||||
[, $cuerpo] = DocIndex::leer($d['archivo']);
|
||||
$md = Generadores::expandir($cuerpo);
|
||||
// El H1 del documento se reemplaza por el encabezado numerado
|
||||
$md = preg_replace('/^#\s+.*$/m', '', $md, 1);
|
||||
$docs[] = [
|
||||
'num' => "{$nSec}.{$nDoc}",
|
||||
'titulo' => $d['titulo'],
|
||||
'id' => 'doc-' . $sec . '-' . $d['slug'],
|
||||
'html' => Markdown::render($md),
|
||||
];
|
||||
}
|
||||
$capitulos[] = ['num' => (string)$nSec, 'titulo' => $cfg['titulo'],
|
||||
'resumen' => $cfg['resumen'], 'id' => 'cap-' . $sec, 'docs' => $docs];
|
||||
}
|
||||
|
||||
$totalDocs = array_sum(array_map(fn($c) => count($c['docs']), $capitulos));
|
||||
$esc = fn($s) => htmlspecialchars((string)$s, ENT_QUOTES);
|
||||
?><!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Documentación del Sistema — <?= $esc($emp['empresa_nombre']) ?></title>
|
||||
<style>
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 18mm 16mm 16mm 16mm;
|
||||
}
|
||||
@page :first { margin: 0; }
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
body {
|
||||
font-family: "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 10.5pt; line-height: 1.55; color: #1e293b;
|
||||
margin: 0; padding: 0; background: #fff;
|
||||
}
|
||||
|
||||
/* ── Barra de acciones (no se imprime) ── */
|
||||
.barra {
|
||||
position: sticky; top: 0; z-index: 10;
|
||||
background: #0f172a; color: #fff; padding: 11px 20px;
|
||||
display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
|
||||
}
|
||||
.barra .t { font-weight: 700; flex: 1; font-size: .95rem; }
|
||||
.barra button, .barra a {
|
||||
background: #2563eb; color: #fff; border: none; border-radius: 7px;
|
||||
padding: 7px 15px; font-size: .84rem; cursor: pointer; text-decoration: none;
|
||||
}
|
||||
.barra a.sec { background: rgba(255,255,255,.14); }
|
||||
.barra button:hover { background: #1d4ed8; }
|
||||
.hoja { max-width: 195mm; margin: 0 auto; padding: 14mm 12mm 20mm; }
|
||||
|
||||
/* ── Portada ── */
|
||||
.portada {
|
||||
display: flex; flex-direction: column; justify-content: center;
|
||||
min-height: 247mm; padding: 0 22mm; text-align: center;
|
||||
page-break-after: always; break-after: page;
|
||||
}
|
||||
.portada img { max-width: 170px; margin: 0 auto 26px; }
|
||||
.portada .lab { font-size: 13pt; color: #64748b; letter-spacing: .06em;
|
||||
text-transform: uppercase; margin-bottom: 6px; }
|
||||
.portada .sub { font-size: 10.5pt; color: #94a3b8; margin-bottom: 46px; }
|
||||
.portada h1 { font-size: 30pt; font-weight: 800; color: #0d47a1;
|
||||
margin: 0 0 10px; line-height: 1.15; letter-spacing: -.5px; }
|
||||
.portada .lin { width: 76px; height: 3px; background: #0d47a1;
|
||||
margin: 22px auto 24px; border-radius: 2px; }
|
||||
.portada .desc { font-size: 11pt; color: #475569; max-width: 118mm;
|
||||
margin: 0 auto 50px; line-height: 1.65; }
|
||||
.portada .meta { font-size: 9.5pt; color: #64748b; line-height: 1.9; }
|
||||
.portada .meta b { color: #334155; }
|
||||
|
||||
/* ── Índice ── */
|
||||
.indice { page-break-after: always; break-after: page; }
|
||||
.indice h2 { font-size: 17pt; color: #0d47a1; margin: 0 0 22px;
|
||||
padding-bottom: 10px; border-bottom: 2px solid #e2e8f0; }
|
||||
.idx-cap { margin-bottom: 16px; }
|
||||
.idx-cap > a { display: block; font-size: 11.5pt; font-weight: 700; color: #0f172a;
|
||||
text-decoration: none; margin-bottom: 3px; }
|
||||
.idx-cap .res { font-size: 9pt; color: #94a3b8; margin: 0 0 7px 20px; }
|
||||
.idx-doc { display: block; font-size: 10pt; color: #334155; text-decoration: none;
|
||||
margin: 0 0 3px 20px; }
|
||||
.idx-doc .n { color: #94a3b8; display: inline-block; min-width: 30px; }
|
||||
|
||||
/* ── Capítulo ── */
|
||||
.cap { page-break-before: always; break-before: page; padding-top: 26mm; text-align: center; }
|
||||
.cap .n { font-size: 46pt; font-weight: 800; color: #dbeafe; line-height: 1; }
|
||||
.cap h2 { font-size: 22pt; color: #0d47a1; margin: 6px 0 12px; font-weight: 800; }
|
||||
.cap p { color: #64748b; font-size: 10.5pt; max-width: 115mm; margin: 0 auto; }
|
||||
|
||||
/* ── Documento ── */
|
||||
.doc { page-break-before: always; break-before: page; }
|
||||
.doc-h {
|
||||
border-bottom: 2px solid #0d47a1; padding-bottom: 8px; margin-bottom: 16px;
|
||||
page-break-after: avoid; break-after: avoid;
|
||||
}
|
||||
.doc-h .n { font-size: 9pt; color: #2563eb; font-weight: 700; letter-spacing: .08em; }
|
||||
.doc-h h3 { font-size: 16pt; color: #0f172a; margin: 2px 0 0; font-weight: 700; }
|
||||
|
||||
h2, h3, h4 { page-break-after: avoid; break-after: avoid; }
|
||||
.doc h2 { font-size: 12.5pt; color: #1565c0; margin: 17px 0 7px; font-weight: 700; }
|
||||
.doc h3 { font-size: 11pt; color: #334155; margin: 13px 0 5px; font-weight: 700; }
|
||||
.doc h4 { font-size: 10pt; color: #475569; margin: 11px 0 4px; font-weight: 700; }
|
||||
.doc p { margin: 0 0 8px; text-align: justify; }
|
||||
.doc ul, .doc ol { margin: 0 0 9px; padding-left: 20px; }
|
||||
.doc li { margin-bottom: 2px; }
|
||||
.doc a { color: #1d4ed8; text-decoration: none; }
|
||||
.doc strong { color: #0f172a; }
|
||||
|
||||
code { background: #f1f5f9; color: #be185d; padding: 1px 4px; border-radius: 3px;
|
||||
font-family: "Consolas", "SF Mono", Menlo, monospace; font-size: .86em; }
|
||||
pre { background: #f8fafc; border: 1px solid #cbd5e1; border-left: 3px solid #1565c0;
|
||||
padding: 9px 12px; border-radius: 4px; margin: 0 0 10px; overflow-x: auto;
|
||||
page-break-inside: avoid; break-inside: avoid; }
|
||||
pre code { background: none; color: #0f172a; padding: 0; font-size: 8.2pt; line-height: 1.45; }
|
||||
|
||||
blockquote { border-left: 3px solid #f59e0b; background: #fffbeb; margin: 0 0 10px;
|
||||
padding: 8px 13px; page-break-inside: avoid; break-inside: avoid; }
|
||||
blockquote p { margin: 0; color: #78350f; font-size: 9.8pt; text-align: left; }
|
||||
|
||||
.tabla-scroll { margin: 0 0 11px; page-break-inside: avoid; break-inside: avoid; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 9pt; }
|
||||
thead { display: table-header-group; }
|
||||
th { background: #1565c0; color: #fff; text-align: left; font-weight: 600;
|
||||
padding: 5px 8px; border: 1px solid #1565c0; }
|
||||
td { padding: 4px 8px; border: 1px solid #e2e8f0; vertical-align: top; }
|
||||
tbody tr:nth-child(even) { background: #f8fafc; }
|
||||
|
||||
hr { border: none; border-top: 1px solid #e2e8f0; margin: 16px 0; }
|
||||
|
||||
/* Tablas muy largas (inventarios generados) pueden partirse entre páginas */
|
||||
.doc table { page-break-inside: auto; }
|
||||
tr { page-break-inside: avoid; break-inside: avoid; }
|
||||
|
||||
@media print {
|
||||
.barra { display: none !important; }
|
||||
.hoja { max-width: none; margin: 0; padding: 0; }
|
||||
a { color: #1e293b !important; text-decoration: none; }
|
||||
.portada { min-height: 250mm; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="barra">
|
||||
<span class="t">
|
||||
<?= $totalDocs ?> documento<?= $totalDocs === 1 ? '' : 's' ?>
|
||||
<?= $soloSeccion ? '· ' . $esc($arbol[$soloSeccion]['titulo']) : '· documentación completa' ?>
|
||||
</span>
|
||||
<?php if ($soloSeccion): ?>
|
||||
<a class="sec" href="<?= BASE_URL ?>erp.php?m=soporte&v=imprimir">Exportar todo</a>
|
||||
<?php else: ?>
|
||||
<?php foreach (DocIndex::seccionesVisibles() as $s => $c): ?>
|
||||
<a class="sec" href="<?= BASE_URL ?>erp.php?m=soporte&v=imprimir&s=<?= urlencode($s) ?>">Solo <?= $esc($c['titulo']) ?></a>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
<a class="sec" href="<?= BASE_URL ?>erp.php?m=soporte&v=documentacion">Volver</a>
|
||||
<button onclick="window.print()">Imprimir / Guardar como PDF</button>
|
||||
</div>
|
||||
|
||||
<div class="hoja">
|
||||
|
||||
<!-- ── Portada ── -->
|
||||
<div class="portada">
|
||||
<?php if (!empty($emp['doc_logo_base64'])): ?>
|
||||
<img src="<?= $esc($emp['doc_logo_base64']) ?>" alt="">
|
||||
<?php endif; ?>
|
||||
<div class="lab"><?= $esc($emp['empresa_nombre']) ?></div>
|
||||
<?php if (!empty($emp['empresa_subtitulo'])): ?>
|
||||
<div class="sub"><?= $esc($emp['empresa_subtitulo']) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<h1>Documentación<br>del Sistema</h1>
|
||||
<div class="lin"></div>
|
||||
<p class="desc">
|
||||
<?php if ($soloSeccion): ?>
|
||||
<?= $esc($arbol[$soloSeccion]['resumen']) ?>
|
||||
<?php else: ?>
|
||||
Manual de usuario, documentación técnica, arquitectura y guía de operación
|
||||
del sistema de gestión del laboratorio.
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
<div class="meta">
|
||||
<?php if ($soloSeccion): ?><b><?= $esc($arbol[$soloSeccion]['titulo']) ?></b><br><?php endif; ?>
|
||||
<b><?= $totalDocs ?></b> documentos<br>
|
||||
Generado el <b><?= date('d/m/Y') ?></b><br>
|
||||
<span style="font-size:8.5pt">Los inventarios técnicos reflejan el estado del sistema a esta fecha</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Índice ── -->
|
||||
<div class="indice">
|
||||
<h2>Contenido</h2>
|
||||
<?php foreach ($capitulos as $cap): ?>
|
||||
<div class="idx-cap">
|
||||
<a href="#<?= $esc($cap['id']) ?>"><?= $esc($cap['num']) ?>. <?= $esc($cap['titulo']) ?></a>
|
||||
<p class="res"><?= $esc($cap['resumen']) ?></p>
|
||||
<?php foreach ($cap['docs'] as $d): ?>
|
||||
<a class="idx-doc" href="#<?= $esc($d['id']) ?>">
|
||||
<span class="n"><?= $esc($d['num']) ?></span><?= $esc($d['titulo']) ?>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<!-- ── Contenido ── -->
|
||||
<?php foreach ($capitulos as $cap): ?>
|
||||
<div class="cap" id="<?= $esc($cap['id']) ?>">
|
||||
<div class="n"><?= $esc($cap['num']) ?></div>
|
||||
<h2><?= $esc($cap['titulo']) ?></h2>
|
||||
<p><?= $esc($cap['resumen']) ?></p>
|
||||
</div>
|
||||
<?php foreach ($cap['docs'] as $d): ?>
|
||||
<div class="doc" id="<?= $esc($d['id']) ?>">
|
||||
<div class="doc-h">
|
||||
<div class="n">CAPÍTULO <?= $esc($d['num']) ?></div>
|
||||
<h3><?= $esc($d['titulo']) ?></h3>
|
||||
</div>
|
||||
<?= $d['html'] ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php endforeach; ?>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
/**
|
||||
* modules/turnero/_acceso.php
|
||||
* Restricción por rol dentro del módulo Turnero.
|
||||
*
|
||||
* El control de acceso del ERP es por módulo: quien tiene `turnero` puede abrir
|
||||
* cualquiera de sus vistas escribiendo la URL, aunque el menú no se la muestre.
|
||||
* Para las pantallas que operan sobre la atención o cambian configuración eso no
|
||||
* alcanza, así que exigen además un rol.
|
||||
*
|
||||
* Uso, al inicio de la vista:
|
||||
* require_once __DIR__ . '/../_acceso.php';
|
||||
* turneroExigirRol(['admin', 'superadmin', 'supervisor']);
|
||||
*/
|
||||
|
||||
if (!function_exists('turneroExigirRol')) {
|
||||
/**
|
||||
* Corta la ejecución si el rol de la sesión no está entre los permitidos.
|
||||
* Los administradores pasan siempre.
|
||||
*/
|
||||
function turneroExigirRol(array $permitidos): void
|
||||
{
|
||||
$rol = $_SESSION['admin_user']['role'] ?? '';
|
||||
if (in_array($rol, ['admin', 'superadmin'], true)) return;
|
||||
if (in_array($rol, $permitidos, true)) return;
|
||||
|
||||
// Devolver a una pantalla que sí pueda ver, en vez de un error seco
|
||||
$destino = defined('BASE_URL') ? BASE_URL : '/';
|
||||
header('Location: ' . $destino . 'erp.php?m=turnero&v=dashboard&sin_acceso=1');
|
||||
exit;
|
||||
}
|
||||
}
|
||||
@@ -182,16 +182,34 @@ if (!$ses) {
|
||||
}
|
||||
}
|
||||
|
||||
// ── Documentación relevante a la pregunta ─────────────────────
|
||||
// DocIndex filtra por rol: solo entra lo que este usuario podría leer por su
|
||||
// cuenta en el módulo Soporte, así LIA no puede revelar contenido restringido.
|
||||
$docCtx = '';
|
||||
try {
|
||||
require_once __DIR__ . '/../../soporte/DocIndex.php';
|
||||
[$docCtx] = DocIndex::contextoIA($pregunta);
|
||||
} catch (\Throwable $_) { /* sin documentación, LIA sigue respondiendo con los datos del día */ }
|
||||
|
||||
$bloqueDocs = $docCtx === '' ? '' : <<<DOCS
|
||||
|
||||
|
||||
MANUAL DE USUARIO (para preguntas de cómo se usa el sistema):
|
||||
{$docCtx}
|
||||
DOCS;
|
||||
|
||||
// ── Prompt ────────────────────────────────────────────────────
|
||||
$systemPrompt = <<<PROMPT
|
||||
Eres LIA, la asistente inteligente del sistema de turnero del Laboratorio Clínico.
|
||||
Responde en español, con el detalle que la pregunta requiera: si te piden un dato puntual sé breve, pero si te piden un listado, un análisis o una comparación, desarróllalo completo. Usa tablas o viñetas cuando ayuden a leer los datos.
|
||||
Responde en español, tratando SIEMPRE de usted (nunca tú ni vos), con el detalle que la pregunta requiera: si te piden un dato puntual sé breve, pero si te piden un listado, un análisis o una comparación, desarróllalo completo. Usa tablas o viñetas cuando ayuden a leer los datos.
|
||||
Puedes analizar: tiempos de espera y servicio por paciente, recepcionista o bacteriólogo; facturación del día; exámenes solicitados; franjas horarias con mayor demanda; buscar pacientes por nombre o cédula.
|
||||
Solo usa la información del contexto proporcionado. Si no tienes el dato, dilo claramente.
|
||||
También responde preguntas de cómo se usa el sistema, apoyándose en el MANUAL que se incluye más abajo cuando esté presente.
|
||||
Solo usa la información del contexto proporcionado. Si no tienes el dato, dilo claramente y no lo inventes.
|
||||
El manual incluido es el que este usuario tiene permitido consultar: no mencione ni deduzca contenido que no esté ahí.
|
||||
Tienes el historial de esta conversación: si el usuario pregunta algo que se refiere a tu respuesta anterior, respóndelo sin pedir que repita el contexto.
|
||||
|
||||
CONTEXTO DEL DÍA:
|
||||
{$ctx}
|
||||
{$ctx}{$bloqueDocs}
|
||||
PROMPT;
|
||||
|
||||
// ── Llamada a Gemini Flash ────────────────────────────────────
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<?php
|
||||
/**
|
||||
* GET ?q= — Busca diagnósticos CIE-10 por código o descripción.
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireMethod('GET');
|
||||
requireTurnero();
|
||||
|
||||
@@ -76,6 +76,8 @@ if (empty($escritoriosRec)) {
|
||||
JOIN turnero_prioridades p ON p.id = t.prioridad_id
|
||||
WHERE t.sesion_id = ?
|
||||
AND t.estado = 'en_recepcion'
|
||||
-- Misma regla que en los puestos: el turno vale hasta medianoche
|
||||
AND DATE(t.llamado_recepcion_at) = CURDATE()
|
||||
ORDER BY t.llamado_recepcion_at DESC"
|
||||
);
|
||||
$stmtRec->execute([$sesionId]);
|
||||
@@ -120,6 +122,10 @@ foreach ($lugares as $lugar) {
|
||||
WHERE t.sesion_id = ?
|
||||
AND t.estado = 'en_servicio'
|
||||
AND t.lugar_destino_id = ?
|
||||
-- Un turno vale hasta la medianoche de su día. Quedan bastantes sin
|
||||
-- cerrar, y sin este filtro el puesto mostraba como \"llamado ahora\"
|
||||
-- a un paciente de días atrás en cuanto se cerraba el turno real.
|
||||
AND DATE(t.llamado_lugar_at) = CURDATE()
|
||||
ORDER BY t.llamado_lugar_at DESC
|
||||
LIMIT 1"
|
||||
);
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
/**
|
||||
* GET /modules/turnero/api/get_firma_pendiente.php
|
||||
*
|
||||
* Le dice a la tablet del paciente qué mostrar: si hay alguien siendo atendido
|
||||
* en su puesto y le falta firmar el consentimiento de bienvenida (F-LAB-01).
|
||||
*
|
||||
* SIN SESIÓN DE OPERADOR, a propósito: esta tablet la manipula el público y
|
||||
* nadie va a iniciar sesión en ella cada mañana. Se identifica por la cookie
|
||||
* del dispositivo, que es un token de 64 caracteres registrado en Configuración.
|
||||
*
|
||||
* Por eso devuelve lo mínimo: el turno que está en ese puesto en este instante
|
||||
* y nada más. No permite consultar otros turnos, ni buscar, ni ver historial.
|
||||
* Sin dispositivo reconocido no responde nada.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('Cache-Control: no-store');
|
||||
|
||||
/** El único formulario que se firma en esta tablet: el de bienvenida. */
|
||||
const FORMULARIO_BIENVENIDA = 17;
|
||||
|
||||
function responder(array $datos): void {
|
||||
echo json_encode($datos, JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$pdo = Database::getInstance()->getConnection();
|
||||
|
||||
// ── 1. ¿Qué puesto es esta tablet? ────────────────────────────────────────
|
||||
// Solo por token de navegador. La IP no sirve aquí: varias tablets salen por
|
||||
// la misma y acabaríamos mostrándole a un paciente los datos de otro puesto.
|
||||
$token = trim($_COOKIE['turnero_token'] ?? '');
|
||||
if ($token === '') {
|
||||
responder(['ok' => false, 'motivo' => 'sin_dispositivo']);
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT td.lugar_id, td.nombre AS dispositivo, tl.nombre AS lugar, tl.tipo
|
||||
FROM turnero_dispositivos td
|
||||
JOIN turnero_lugares tl ON tl.id = td.lugar_id
|
||||
WHERE td.token = ? AND td.activo = 1
|
||||
LIMIT 1"
|
||||
);
|
||||
$stmt->execute([$token]);
|
||||
$disp = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$disp || $disp['tipo'] !== 'recepcion') {
|
||||
responder(['ok' => false, 'motivo' => 'sin_dispositivo']);
|
||||
}
|
||||
|
||||
// ── 2. ¿Hay alguien siendo atendido ahí ahora? ────────────────────────────
|
||||
// Mismo criterio que la pantalla del televisor: el turno vale hasta la
|
||||
// medianoche de su día, para no mostrar a un paciente que ya se fue.
|
||||
// La sesión está abierta mientras fin_at siga en nulo; no hay columna de estado.
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT t.id, t.codigo, t.paciente_id, t.paciente_nombre
|
||||
FROM turnero_turnos t
|
||||
JOIN turnero_sesiones s ON s.id = t.sesion_id
|
||||
WHERE t.estado = 'en_recepcion'
|
||||
AND t.recepcion_desk_id = ?
|
||||
AND DATE(t.llamado_recepcion_at) = CURDATE()
|
||||
AND s.fin_at IS NULL
|
||||
ORDER BY t.llamado_recepcion_at DESC
|
||||
LIMIT 1"
|
||||
);
|
||||
$stmt->execute([(int)$disp['lugar_id']]);
|
||||
$turno = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$turno) {
|
||||
responder(['ok' => true, 'estado' => 'reposo', 'lugar' => $disp['lugar']]);
|
||||
}
|
||||
|
||||
// Sin paciente vinculado no hay a quién atribuirle la firma
|
||||
if (empty($turno['paciente_id'])) {
|
||||
responder(['ok' => true, 'estado' => 'reposo', 'lugar' => $disp['lugar']]);
|
||||
}
|
||||
|
||||
// ── 3. ¿Le falta firmar el consentimiento de bienvenida? ──────────────────
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT token, estado FROM turnero_consentimientos
|
||||
WHERE turno_id = ? AND formulario_id = ?
|
||||
LIMIT 1"
|
||||
);
|
||||
$stmt->execute([(int)$turno['id'], FORMULARIO_BIENVENIDA]);
|
||||
$consent = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($consent && in_array($consent['estado'], ['firmado', 'rechazado'], true)) {
|
||||
responder([
|
||||
'ok' => true,
|
||||
'estado' => 'firmado',
|
||||
'turno_id' => (int)$turno['id'],
|
||||
'codigo' => $turno['codigo'],
|
||||
]);
|
||||
}
|
||||
|
||||
// Si el consentimiento todavía no existe se crea aquí. Es lo que permite que
|
||||
// la tablet aparezca sola, sin que la recepcionista tenga que mandarlo.
|
||||
if (!$consent) {
|
||||
$tokenFirma = sprintf(
|
||||
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
||||
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
|
||||
mt_rand(0, 0xffff),
|
||||
mt_rand(0, 0x0fff) | 0x4000,
|
||||
mt_rand(0, 0x3fff) | 0x8000,
|
||||
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
|
||||
);
|
||||
$ins = $pdo->prepare(
|
||||
"INSERT IGNORE INTO turnero_consentimientos
|
||||
(turno_id, formulario_id, token, estado, creado_por)
|
||||
VALUES (?, ?, ?, 'pendiente', NULL)"
|
||||
);
|
||||
$ins->execute([(int)$turno['id'], FORMULARIO_BIENVENIDA, $tokenFirma]);
|
||||
|
||||
// INSERT IGNORE puede no haber insertado si otra petición se adelantó:
|
||||
// se relee para quedarse con el token que realmente quedó guardado.
|
||||
$stmt->execute([(int)$turno['id'], FORMULARIO_BIENVENIDA]);
|
||||
$consent = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$consent) {
|
||||
responder(['ok' => false, 'motivo' => 'no_se_pudo_crear']);
|
||||
}
|
||||
}
|
||||
|
||||
responder([
|
||||
'ok' => true,
|
||||
'estado' => 'por_firmar',
|
||||
'turno_id' => (int)$turno['id'],
|
||||
'codigo' => $turno['codigo'],
|
||||
'paciente' => $turno['paciente_nombre'],
|
||||
'url' => BASE_URL . 'ver_formulario_enviado.php?token=' . urlencode($consent['token']),
|
||||
]);
|
||||
@@ -183,13 +183,15 @@ if ($turnoIds) {
|
||||
"SELECT tc.turno_id, tc.usuario_nombre, tc.comentario, tc.tipo, tc.creado_at
|
||||
FROM turnero_comentarios tc
|
||||
WHERE tc.turno_id IN ($ph)
|
||||
ORDER BY tc.turno_id, tc.creado_at DESC"
|
||||
ORDER BY tc.turno_id, tc.creado_at ASC"
|
||||
);
|
||||
$stmtCom->execute($turnoIds);
|
||||
foreach ($stmtCom->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$tid = (int)$row['turno_id'];
|
||||
if (!isset($comentarios[$tid])) $comentarios[$tid] = [];
|
||||
if (count($comentarios[$tid]) < 3) {
|
||||
// Se traen todos: cortar en 3 escondía notas —entre ellas el motivo de
|
||||
// una ausencia— sin que nada indicara que faltaban.
|
||||
if (count($comentarios[$tid]) < 20) {
|
||||
unset($row['turno_id']);
|
||||
$comentarios[$tid][] = $row;
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ $_trAdminRoles = ['superadmin', 'admin', 'supervisor'];
|
||||
$_trIsAdmin = in_array($_trRole, $_trAdminRoles, true);
|
||||
$_trIsRecep = in_array($_trRole, ['recepcionista', 'lab_recepcion'], true);
|
||||
$_trIsBacte = $_trRole === 'bacteriologo';
|
||||
$_trIsCalidad = $_trRole === 'calidad';
|
||||
$_trClientIp = trim(explode(',', $_SERVER['HTTP_X_FORWARDED_FOR'] ?? $_SERVER['HTTP_X_REAL_IP'] ?? $_SERVER['REMOTE_ADDR'] ?? '')[0]);
|
||||
|
||||
try {
|
||||
@@ -19,7 +20,7 @@ try {
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$_trMuestras = $_trPdo->query(
|
||||
"SELECT id, nombre FROM turnero_lugares WHERE activo=1 AND tipo='muestras' ORDER BY sort_order"
|
||||
"SELECT id, nombre, acceso_libre FROM turnero_lugares WHERE activo=1 AND tipo='muestras' ORDER BY sort_order"
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
} catch (\Throwable $_) {
|
||||
$_trDesks = [];
|
||||
@@ -28,8 +29,13 @@ try {
|
||||
|
||||
$_trLinks = [];
|
||||
|
||||
// ── Calidad: solo consulta de turnos y tiempos ────────────────
|
||||
if ($_trIsCalidad) {
|
||||
$_trLinks[] = ['name' => 'Dashboard', 'icon' => 'fas fa-tachometer-alt', 'route' => '/erp.php?m=turnero&v=dashboard'];
|
||||
$_trLinks[] = ['name' => 'Historial', 'icon' => 'fas fa-history', 'route' => '/erp.php?m=turnero&v=historial'];
|
||||
|
||||
// ── Recepcionista: pantallas comunes + desks según IP ─────────
|
||||
if ($_trIsRecep) {
|
||||
} elseif ($_trIsRecep) {
|
||||
$_trLinks[] = ['name' => 'Chat Turnero', 'icon' => 'fab fa-whatsapp', 'route' => '/erp.php?m=turnero&v=chat'];
|
||||
$_trLinks[] = ['name' => 'Verificar Paciente', 'icon' => 'fas fa-id-card', 'route' => '/erp.php?m=turnero&v=verificar_paciente'];
|
||||
$_trLinks[] = ['name' => 'Pantalla TV', 'icon' => 'fas fa-tv', 'route' => '/erp.php?m=turnero&v=display_global'];
|
||||
@@ -108,16 +114,16 @@ if ($_trIsRecep) {
|
||||
$_trLinks[] = ['name' => 'Bandeja del día', 'icon' => 'fas fa-layer-group', 'route' => '/erp.php?m=turnero&v=bandeja'];
|
||||
|
||||
if ($_trIpLugar) {
|
||||
// IP registrada: solo ese lugar en el sidebar
|
||||
// IP registrada: su estación, más las marcadas como de acceso libre
|
||||
// (Pediatría, Ginecología), que se atienden desde cualquier puesto.
|
||||
foreach ($_trMuestras as $_m) {
|
||||
if ((int)$_m['id'] === (int)$_trIpLugar) {
|
||||
$_esSuyo = (int)$_m['id'] === (int)$_trIpLugar;
|
||||
if (!$_esSuyo && empty($_m['acceso_libre'])) continue;
|
||||
$_trLinks[] = [
|
||||
'name' => $_m['nombre'],
|
||||
'icon' => 'fas fa-flask',
|
||||
'icon' => $_esSuyo ? 'fas fa-flask' : 'fas fa-share-square',
|
||||
'route' => '/erp.php?m=turnero&v=lugar&lugar_id=' . (int)$_m['id'],
|
||||
];
|
||||
break;
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// IP no registrada: TV + todos los lugares muestras
|
||||
|
||||
@@ -12,6 +12,9 @@ require_once __DIR__ . '/../../../config/config.php';
|
||||
|
||||
if (!isUserLoggedIn()) { header('Location: ' . BASE_URL . 'login.php'); exit; }
|
||||
|
||||
require_once __DIR__ . '/../_acceso.php';
|
||||
turneroExigirRol(['supervisor']);
|
||||
|
||||
$db = Database::getInstance();
|
||||
$pdo = $db->getConnection();
|
||||
|
||||
@@ -1157,10 +1160,13 @@ $tab = $_GET['tab'] ?? 'lugares';
|
||||
<div class="tv-media-item d-flex align-items-center gap-3 p-2" data-id="<?= (int)$m['id'] ?>"
|
||||
style="border:1px solid #e2e8f0;border-radius:10px">
|
||||
<i class="fas fa-grip-vertical text-muted" style="cursor:grab"></i>
|
||||
<?php /* Miniatura cuadrada, igual que la pantalla del televisor: con la
|
||||
antigua de 90x60 el material cuadrado se veía recortado aquí y
|
||||
no coincidía con lo que después salía al aire. */ ?>
|
||||
<?php if ($m['tipo'] === 'video'): ?>
|
||||
<video muted style="width:90px;height:60px;object-fit:cover;border-radius:6px" src="<?= htmlspecialchars($m['url']) ?>"></video>
|
||||
<video muted style="width:64px;height:64px;object-fit:cover;border-radius:6px;background:#000" src="<?= htmlspecialchars($m['url']) ?>"></video>
|
||||
<?php else: ?>
|
||||
<img style="width:90px;height:60px;object-fit:cover;border-radius:6px" src="<?= htmlspecialchars($m['url']) ?>" alt="">
|
||||
<img style="width:64px;height:64px;object-fit:cover;border-radius:6px;background:#000" src="<?= htmlspecialchars($m['url']) ?>" alt="">
|
||||
<?php endif; ?>
|
||||
<div class="flex-grow-1">
|
||||
<div class="small fw-semibold"><i class="fas fa-<?= $m['tipo']==='video'?'film':'image' ?> me-1"></i><?= $m['tipo']==='video'?'Video':'Imagen' ?></div>
|
||||
|
||||
@@ -83,6 +83,13 @@ Layout::open('Dashboard Turnero', 'fas fa-chart-bar');
|
||||
.det-row td { padding:0 !important; border-top:none !important; }
|
||||
.det-inner { padding:12px 16px 14px; border-top:2px solid color-mix(in srgb, var(--brand,#1565c0) 20%, #e2e8f0); background:#f8fafc; }
|
||||
|
||||
/* ── Origen de cada nota ── */
|
||||
.com-tipo { font-size:.62rem; font-weight:700; letter-spacing:.04em; padding:1px 7px;
|
||||
border-radius:10px; text-transform:uppercase; margin-right:6px; }
|
||||
.com-tipo.recepcion { background:#dbeafe; color:#1d4ed8; }
|
||||
.com-tipo.muestras { background:#dcfce7; color:#15803d; }
|
||||
.com-tipo.general { background:#fef3c7; color:#92400e; }
|
||||
|
||||
/* ── Sesión badge ── */
|
||||
.sesion-open { background:#dcfce7; color:#15803d; }
|
||||
.sesion-closed { background:#f1f5f9; color:#64748b; }
|
||||
@@ -657,8 +664,9 @@ function renderDashDetalle(t) {
|
||||
// Comentarios
|
||||
if (t.comentarios && t.comentarios.length) {
|
||||
grid += `<div style="border-top:1px solid #e2e8f0;padding-top:8px">
|
||||
<div style="font-size:.67rem;text-transform:uppercase;letter-spacing:.07em;color:#94a3b8;margin-bottom:5px"><i class="fas fa-comments me-1"></i>Comentarios</div>
|
||||
<div style="font-size:.67rem;text-transform:uppercase;letter-spacing:.07em;color:#94a3b8;margin-bottom:5px"><i class="fas fa-comments me-1"></i>Notas y observaciones (${t.comentarios.length})</div>
|
||||
${t.comentarios.map(c=>`<div style="font-size:.8rem;padding:4px 0;border-bottom:1px solid #f1f5f9">
|
||||
<span class="com-tipo ${e(c.tipo||'general')}">${({recepcion:'Recepción',muestras:'Toma de muestras',general:'General'})[c.tipo]||c.tipo}</span>
|
||||
<strong>${e(c.usuario_nombre)}</strong>
|
||||
<span style="color:#94a3b8;font-size:.72rem;margin-left:6px">${fmt(c.creado_at)}</span>
|
||||
<div style="color:#475569">${e(c.comentario)}</div>
|
||||
|
||||
@@ -271,9 +271,13 @@ $_labColor = preg_match('/^#[0-9a-fA-F]{3,8}$/', $_dispCfg['doc_color'] ?? '')
|
||||
</div>
|
||||
|
||||
<!-- Panel publicitario (se activa con ?video=URL) -->
|
||||
<div id="video-panel" style="display:none;overflow:hidden;background:#000;position:relative">
|
||||
<!-- El material es cuadrado: la caja también, y centrada. Estirada a
|
||||
toda la altura de una columna angosta, cover recortaba los bordes
|
||||
del video y solo se veía la franja central. -->
|
||||
<div id="video-panel" style="display:none;overflow:hidden;background:#000;position:relative;
|
||||
align-items:center;justify-content:center">
|
||||
<video id="video-pub" autoplay muted loop playsinline
|
||||
style="width:100%;height:100%;object-fit:cover">
|
||||
style="width:100%;height:auto;aspect-ratio:1/1;max-height:100%;object-fit:cover">
|
||||
<source id="video-src" src="">
|
||||
</video>
|
||||
</div>
|
||||
@@ -338,7 +342,7 @@ document.getElementById('lbl-area').textContent =
|
||||
const body = document.getElementById('display-body');
|
||||
src.src = videoUrl;
|
||||
vid.load();
|
||||
panel.style.display = 'block';
|
||||
panel.style.display = 'flex'; // flex, para poder centrar el cuadro
|
||||
// 3 columnas: turno | video | cola
|
||||
body.style.gridTemplateColumns = '1fr 320px 280px';
|
||||
})();
|
||||
|
||||
@@ -112,8 +112,12 @@ $_hasVideo = (bool)$_tvPlaylist;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
}
|
||||
/* La columna del video se dimensiona sola: `auto` la deja del ancho que
|
||||
pida su contenido, y el contenido es un cuadrado tan alto como el
|
||||
cuerpo. Antes era una franja fija del 29% —proporción de reel—, así
|
||||
que un video cuadrado quedaba diminuto y con medio espacio vacío. */
|
||||
.pg-body.has-video {
|
||||
grid-template-columns: 1fr 29%;
|
||||
grid-template-columns: 1fr auto;
|
||||
}
|
||||
.pg-body.has-video .turno-activo { display: none; }
|
||||
|
||||
@@ -240,16 +244,29 @@ $_hasVideo = (bool)$_tvPlaylist;
|
||||
}
|
||||
.ul-item.activo .ul-dest { color: rgba(255,255,255,.85); }
|
||||
|
||||
/* ── Video reel ── */
|
||||
/* ── Video cuadrado ──
|
||||
El material ya no es de proporción reel sino cuadrado. La caja toma
|
||||
todo el alto disponible y el ancho lo deduce de ahí (aspect-ratio),
|
||||
así que el cuadrado sale tan grande como quepa. Se limita a la mitad
|
||||
de la pantalla para no ahogar la columna de los llamados. */
|
||||
.reel-wrap {
|
||||
display: none;
|
||||
height: 100%;
|
||||
max-width: 50vw;
|
||||
overflow: hidden;
|
||||
padding: 8px 8px 8px 0;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
.reel-wrap.has-video { display: block; }
|
||||
.reel-wrap.has-video { display: flex; }
|
||||
/* El cuadrado se define aquí y no en el contenedor: con box-sizing
|
||||
border-box, el padding asimétrico del contenedor deformaría la
|
||||
proporción. La columna se ajusta sola al ancho que resulte. */
|
||||
.reel-wrap .reel-inner {
|
||||
width: 100%; height: 100%;
|
||||
height: 100%;
|
||||
width: auto;
|
||||
aspect-ratio: 1 / 1;
|
||||
max-width: 100%;
|
||||
border-radius: 16px;
|
||||
overflow: hidden;
|
||||
box-shadow: -8px 0 30px rgba(0,0,0,.08);
|
||||
@@ -378,7 +395,7 @@ $_hasVideo = (bool)$_tvPlaylist;
|
||||
<div class="dot"></div>
|
||||
</div>
|
||||
|
||||
<div id="sonido-banner" onclick="activarSonido()" style="display:none;position:fixed;bottom:0;left:0;right:0;z-index:9999;background:rgba(239,68,68,.92);color:#fff;padding:.6rem 1.2rem;font-size:1rem;font-weight:600;cursor:pointer;align-items:center;justify-content:center;gap:.6rem">
|
||||
<div id="sonido-banner" onclick="activarSonido(true)" style="display:none;position:fixed;bottom:0;left:0;right:0;z-index:9999;background:rgba(239,68,68,.92);color:#fff;padding:.6rem 1.2rem;font-size:1rem;font-weight:600;cursor:pointer;align-items:center;justify-content:center;gap:.6rem">
|
||||
<i class="fas fa-volume-mute"></i> Haz clic aquí para activar el sonido
|
||||
</div>
|
||||
|
||||
@@ -404,7 +421,7 @@ $_hasVideo = (bool)$_tvPlaylist;
|
||||
<div class="hd-right">
|
||||
<div class="live-dot"></div>
|
||||
<div class="reloj" id="reloj">--:--:--</div>
|
||||
<button class="btn-fs" id="btn-sonido" onclick="activarSonido()" title="Activar sonido">
|
||||
<button class="btn-fs" id="btn-sonido" onclick="activarSonido(true)" title="Activar sonido">
|
||||
<i class="fas fa-volume-mute"></i>
|
||||
</button>
|
||||
<button class="btn-fs" onclick="toggleFullscreen()" title="Pantalla completa">
|
||||
@@ -499,14 +516,22 @@ function tick() {
|
||||
tick(); setInterval(tick, 1000);
|
||||
|
||||
/* ── Audio ── */
|
||||
let audioCtx = null, sonidoActivo = false;
|
||||
// Arranca encendido. Esta pantalla vive en un televisor que nadie toca: si el
|
||||
// sonido esperara un clic, cualquier recarga —corte de red, reinicio, refresco—
|
||||
// la dejaba muda hasta que alguien fuera físicamente a tocarla, que es lo que
|
||||
// venía pasando. Si el navegador termina bloqueándolo, se avisa con el letrero.
|
||||
let audioCtx = null, sonidoActivo = true;
|
||||
|
||||
function activarSonido() {
|
||||
function activarSonido(porGesto) {
|
||||
try {
|
||||
if (!audioCtx) {
|
||||
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
||||
} else if (audioCtx.state === 'suspended') {
|
||||
audioCtx.resume();
|
||||
// resume() es asíncrono: sin esto el letrero parpadeaba al abrir,
|
||||
// porque el estado todavía decía "suspended" cuando ya iba a sonar.
|
||||
audioCtx.onstatechange = revisarBloqueo;
|
||||
}
|
||||
if (audioCtx.state === 'suspended') {
|
||||
audioCtx.resume().catch(() => {});
|
||||
}
|
||||
const g = audioCtx.createGain(); g.gain.setValueAtTime(0.001, audioCtx.currentTime);
|
||||
const o = audioCtx.createOscillator(); o.connect(g); g.connect(audioCtx.destination);
|
||||
@@ -516,13 +541,39 @@ function activarSonido() {
|
||||
localStorage.setItem('turneroSonido', '1');
|
||||
const btn = document.getElementById('btn-sonido');
|
||||
if (btn) { btn.innerHTML = '<i class="fas fa-volume-up"></i>'; btn.classList.add('on'); }
|
||||
const banner = document.getElementById('sonido-banner');
|
||||
if (banner) banner.style.display = 'none';
|
||||
setTimeout(playBeep, 100);
|
||||
// El pito de confirmación solo cuando alguien tocó: al arrancar sola, la
|
||||
// pantalla no tiene por qué pitar cada vez que se recarga.
|
||||
if (porGesto) setTimeout(playBeep, 100);
|
||||
revisarBloqueo();
|
||||
}
|
||||
// Sin once:true para que cualquier clic reactive si la página recargó
|
||||
document.addEventListener('click', () => { if (!sonidoActivo) activarSonido(); });
|
||||
document.addEventListener('touchstart', () => { if (!sonidoActivo) activarSonido(); });
|
||||
|
||||
/**
|
||||
* El navegador puede negarse a sonar sin un gesto humano. No hay forma de
|
||||
* saberlo preguntando, solo mirando si el contexto quedó suspendido: si es así
|
||||
* se muestra el letrero para que alguien toque la pantalla una vez.
|
||||
*
|
||||
* Se evita del todo abriendo Chrome en el televisor con
|
||||
* --autoplay-policy=no-user-gesture-required
|
||||
*/
|
||||
function revisarBloqueo() {
|
||||
const banner = document.getElementById('sonido-banner');
|
||||
if (!banner) return;
|
||||
const bloqueado = !audioCtx || audioCtx.state === 'suspended';
|
||||
banner.style.display = bloqueado ? 'flex' : 'none';
|
||||
}
|
||||
|
||||
// Intento de arranque automático, en cuanto la página está lista
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', () => activarSonido(false));
|
||||
} else {
|
||||
activarSonido(false);
|
||||
}
|
||||
// Reintento: Chrome a veces deja el contexto suspendido un instante al abrir
|
||||
setTimeout(() => { if (audioCtx && audioCtx.state === 'suspended') activarSonido(false); }, 2000);
|
||||
|
||||
// Cualquier gesto sirve de respaldo si el navegador bloqueó el arranque
|
||||
document.addEventListener('click', () => activarSonido(true));
|
||||
document.addEventListener('touchstart', () => activarSonido(true));
|
||||
|
||||
function playBeep() {
|
||||
if (!sonidoActivo || !audioCtx) return;
|
||||
@@ -544,47 +595,138 @@ function playBeep() {
|
||||
|
||||
// Selección de voz española — cargada una vez, reutilizada en cada anuncio
|
||||
let _vozES = null;
|
||||
// Voces preferidas, por nombre. Sabina (Windows) y Paulina (macOS) son las
|
||||
// mexicanas locales: salen del propio equipo, así que nunca se cortan. Una
|
||||
// colombiana local no existe —la es-CO de Chrome es de Google y se baja de
|
||||
// internet en cada llamado—, y para leer letras, números y un nombre propio
|
||||
// el acento mexicano se oye natural aquí; el de España no.
|
||||
const VOCES_PREFERIDAS = ['sabina', 'paulina'];
|
||||
|
||||
function getVozES() {
|
||||
if (_vozES) return _vozES;
|
||||
const voices = window.speechSynthesis.getVoices();
|
||||
// Preferencia: es-CO → es-419 → es-MX → es-US → es-ES → cualquier es-*
|
||||
for (const lang of ['es-CO','es-419','es-MX','es-US','es-ES']) {
|
||||
const v = voices.find(v => v.lang === lang);
|
||||
if (v) { _vozES = v; return v; }
|
||||
const voces = window.speechSynthesis.getVoices().filter(v => v.lang && v.lang.toLowerCase().startsWith('es'));
|
||||
if (!voces.length) return null;
|
||||
|
||||
const locales = voces.filter(v => v.localService);
|
||||
const elegir = v => { _vozES = v; return v; };
|
||||
|
||||
// 1. Las pedidas por nombre, si están instaladas
|
||||
for (const nombre of VOCES_PREFERIDAS) {
|
||||
const v = locales.find(v => v.name.toLowerCase().includes(nombre));
|
||||
if (v) return elegir(v);
|
||||
}
|
||||
const v = voices.find(v => v.lang.startsWith('es'));
|
||||
if (v) { _vozES = v; }
|
||||
return _vozES;
|
||||
// 2. Cualquier otra local latinoamericana
|
||||
for (const lang of ['es-MX', 'es-419', 'es-US', 'es-CO']) {
|
||||
const v = locales.find(v => v.lang === lang);
|
||||
if (v) return elegir(v);
|
||||
}
|
||||
// 3. Cualquier local, aunque sea de España: peor acento, pero no se corta
|
||||
if (locales.length) return elegir(locales[0]);
|
||||
|
||||
// 4. Sin ninguna local, queda la remota. Suena mejor, pero depende del wifi:
|
||||
// es la que venía entrecortándose.
|
||||
for (const lang of ['es-CO', 'es-419', 'es-MX', 'es-US', 'es-ES']) {
|
||||
const v = voces.find(v => v.lang === lang);
|
||||
if (v) return elegir(v);
|
||||
}
|
||||
return elegir(voces[0]);
|
||||
}
|
||||
if ('speechSynthesis' in window) {
|
||||
window.speechSynthesis.onvoiceschanged = () => { _vozES = null; getVozES(); };
|
||||
// Keepalive: Chrome pausa speechSynthesis tras ~15 min — correr siempre, no solo cuando !speaking
|
||||
// Keepalive: Chrome suspende speechSynthesis tras un rato sin uso.
|
||||
// Solo debe correr con el sintetizador en reposo: pause() a mitad de una
|
||||
// frase la corta, y como el anuncio dura más de 10 segundos, antes lo
|
||||
// troceaba siempre.
|
||||
setInterval(() => {
|
||||
if (window.speechSynthesis.speaking || window.speechSynthesis.pending) return;
|
||||
window.speechSynthesis.pause();
|
||||
window.speechSynthesis.resume();
|
||||
}, 10000);
|
||||
}
|
||||
// Auto-restore sonido si estaba activo antes de un reload
|
||||
if (localStorage.getItem('turneroSonido') === '1') {
|
||||
const banner = document.getElementById('sonido-banner');
|
||||
if (banner) banner.style.display = 'flex';
|
||||
}
|
||||
// El letrero ya no depende de localStorage sino de si el navegador dejó sonar:
|
||||
// lo decide revisarBloqueo(), que corre al arrancar y en cada intento.
|
||||
|
||||
function anunciarTurno(codigo, destino, paciente) {
|
||||
// Espera antes de hablar: el pito dura 0,65 s y hablando encima tapaba
|
||||
// "Turno X", que desde lejos sonaba como si el anuncio empezara ya empezado.
|
||||
// A nivel de archivo porque showAnnouncement() también lo necesita.
|
||||
const BEEP_MS = 700;
|
||||
|
||||
/**
|
||||
* Dice el llamado en voz alta.
|
||||
*
|
||||
* Avisa por `onFin` cuando terminó de hablar, o cuando quedó claro que no va a
|
||||
* hablar. Lo segundo importa tanto como lo primero: el cartel se cierra con esa
|
||||
* señal, y si nunca llegaba se quedaba pegado en pantalla.
|
||||
*
|
||||
* Si la voz elegida no arranca —pasa cuando el equipo la lista pero no la puede
|
||||
* usar— se reintenta una vez con la del navegador, en vez de quedarse mudo.
|
||||
*
|
||||
* @return bool si se intentó hablar. Falso significa pantalla sin sonido.
|
||||
*/
|
||||
function anunciarTurno(codigo, destino, paciente, onFin) {
|
||||
playBeep();
|
||||
if (!sonidoActivo || !('speechSynthesis' in window)) return null;
|
||||
if (!sonidoActivo || !('speechSynthesis' in window)) return false;
|
||||
|
||||
const letras = codigo.split('').join(' ');
|
||||
const nomVoz = paciente ? paciente.toLowerCase().replace(/\b\w/g, c => c.toUpperCase()) : null;
|
||||
// \w no cuenta las letras acentuadas, así que trataba la tilde como
|
||||
// separador: "maría" salía "MaríA" y la voz lo pronunciaba raro.
|
||||
const nomVoz = paciente
|
||||
? paciente.toLowerCase().replace(/(^|\s)(\p{L})/gu, (_, sep, c) => sep + c.toUpperCase())
|
||||
: null;
|
||||
let texto = `Turno ${letras}`;
|
||||
if (nomVoz) texto += `, ${nomVoz}`;
|
||||
texto += `, pase a ${destino}`;
|
||||
|
||||
let avisado = false;
|
||||
const avisar = () => { if (!avisado) { avisado = true; onFin(); } };
|
||||
|
||||
const decir = (esReintento) => {
|
||||
const utt = new SpeechSynthesisUtterance(texto);
|
||||
utt.lang = 'es-CO';
|
||||
utt.rate = 0.95; utt.pitch = 1.05; utt.volume = 1;
|
||||
|
||||
let arranco = false;
|
||||
utt.addEventListener('start', () => { arranco = true; });
|
||||
utt.addEventListener('end', avisar);
|
||||
utt.addEventListener('error', () => esReintento ? avisar() : decir(true));
|
||||
|
||||
// Que speak() no lance nada no significa que vaya a sonar: si el
|
||||
// navegador bloquea el audio, o la voz no sirve, no pasa absolutamente
|
||||
// nada y 'error' tampoco llega. Solo se nota porque 'start' no ocurre.
|
||||
const margen = (esReintento ? 0 : BEEP_MS) + 2500;
|
||||
setTimeout(() => {
|
||||
if (arranco) return;
|
||||
// Antes de darla por fallida hay que preguntarle al sintetizador:
|
||||
// una voz remota tarda en arrancar porque se baja de internet, y
|
||||
// cancelarla aquí era cortarle la frase y repetirla con otra voz.
|
||||
// De ahí que a veces se oyera media frase y luego otra distinta.
|
||||
if (window.speechSynthesis.speaking || window.speechSynthesis.pending) return;
|
||||
if (esReintento) { avisar(); return; }
|
||||
window.speechSynthesis.cancel();
|
||||
decir(true); // segunda oportunidad, con la voz del navegador
|
||||
}, margen);
|
||||
|
||||
// Solo cuando hay algo en curso hace falta cancelar: Chrome ignora un
|
||||
// speak() encadenado a un cancel() en el mismo ciclo.
|
||||
if (window.speechSynthesis.speaking || window.speechSynthesis.pending) {
|
||||
window.speechSynthesis.cancel();
|
||||
}
|
||||
setTimeout(() => {
|
||||
// La voz se elige aquí y no al crear el anuncio: el navegador carga
|
||||
// la lista de forma asíncrona, y en los primeros llamados tras abrir
|
||||
// la página todavía venía vacía. Entonces no se asignaba voz y
|
||||
// hablaba la del navegador —de ahí que a veces sonara un hombre y a
|
||||
// veces una mujer—. A esta altura ya está cargada.
|
||||
if (!esReintento) {
|
||||
const voz = getVozES();
|
||||
if (voz) utt.voice = voz;
|
||||
utt.rate = 0.85; utt.pitch = 1.05; utt.volume = 1;
|
||||
setTimeout(() => { window.speechSynthesis.cancel(); window.speechSynthesis.speak(utt); }, 300);
|
||||
return utt;
|
||||
}
|
||||
window.speechSynthesis.speak(utt);
|
||||
}, esReintento ? 0 : BEEP_MS);
|
||||
};
|
||||
|
||||
decir(false);
|
||||
return true;
|
||||
}
|
||||
|
||||
/* ── Fullscreen ── */
|
||||
@@ -629,8 +771,6 @@ function showAnnouncement({ codigo, destino, paciente, color }) {
|
||||
|
||||
ov.classList.add('visible');
|
||||
|
||||
const utt = anunciarTurno(codigo, destino, paciente);
|
||||
|
||||
bar.style.transition = 'none';
|
||||
bar.style.width = '100%';
|
||||
requestAnimationFrame(() => requestAnimationFrame(() => {
|
||||
@@ -645,9 +785,28 @@ function showAnnouncement({ codigo, destino, paciente, color }) {
|
||||
ov.classList.remove('visible');
|
||||
setTimeout(processQueue, 370);
|
||||
}
|
||||
// Cierra al terminar el TTS; mínimo 5s visual
|
||||
if (utt) utt.addEventListener('end', () => setTimeout(dismiss, 500));
|
||||
setTimeout(dismiss, 5000);
|
||||
|
||||
// El cartel se cierra cuando terminó de hablar Y se cumplió el mínimo
|
||||
// visual. Cerrar a los 5 s sin esperar la voz hacía que el siguiente
|
||||
// llamado cancelara al anterior a media frase.
|
||||
const MIN_VISIBLE = 5000;
|
||||
const abiertoEn = Date.now();
|
||||
const cerrarCuandoToque = () => {
|
||||
setTimeout(dismiss, Math.max(0, MIN_VISIBLE - (Date.now() - abiertoEn)));
|
||||
};
|
||||
|
||||
// anunciarTurno avisa tanto si terminó de hablar como si quedó claro que no
|
||||
// va a hablar. Esperar solo el fin de la voz dejaba el cartel pegado en las
|
||||
// pantallas donde el navegador bloquea el audio.
|
||||
const intentoHablar = anunciarTurno(codigo, destino, paciente,
|
||||
() => setTimeout(cerrarCuandoToque, 400));
|
||||
|
||||
if (intentoHablar) {
|
||||
// Red de seguridad: Chrome a veces no emite 'end' a mitad de una frase
|
||||
setTimeout(dismiss, 20000);
|
||||
} else {
|
||||
setTimeout(dismiss, MIN_VISIBLE);
|
||||
}
|
||||
}
|
||||
|
||||
/* ── State ── */
|
||||
@@ -699,18 +858,16 @@ function renderSnapshot(snap) {
|
||||
});
|
||||
lastSeenKeys = currentKeys;
|
||||
|
||||
if (_firstLoad) {
|
||||
_firstLoad = false;
|
||||
const sorted = allTurnos.slice().sort((a, b) => new Date(b.llamado_at || 0) - new Date(a.llamado_at || 0));
|
||||
if (sorted.length > 0) {
|
||||
const t = sorted[0];
|
||||
const c = t.prioridad_color || '<?= $_labColor ?>';
|
||||
const key = t.codigo + '|' + t.destino + '|' + (t.llamado_at || '');
|
||||
const nomVoz = t.paciente_id ? t.paciente_nombre : null;
|
||||
announcedCalls.add(key);
|
||||
queueAnnouncement({ _key: key, codigo: t.codigo, destino: t.destino, paciente: nomVoz, color: c });
|
||||
}
|
||||
}
|
||||
// Al abrir la pantalla NO se anuncia nada por voz. Antes se llamaba al turno
|
||||
// que estuviera arriba, pero eso no es un llamado nuevo: es el estado en que
|
||||
// se encontró el puesto. Si allí había un turno sin cerrar de horas atrás, el
|
||||
// televisor gritaba el nombre de un paciente que ya se había ido. Y como esta
|
||||
// pantalla se recarga sola, cada recarga era una oportunidad de equivocarse.
|
||||
//
|
||||
// El recorrido de arriba ya dio por vistos los turnos presentes —registra la
|
||||
// clave sin anunciar mientras _firstLoad siga en pie—, así que aquí solo hay
|
||||
// que levantar la bandera: de la siguiente vuelta en adelante sí se anuncia.
|
||||
if (_firstLoad) _firstLoad = false;
|
||||
|
||||
if (anuncioNuevo) renderLlamados();
|
||||
|
||||
@@ -797,7 +954,8 @@ async function cargarSnapshot() {
|
||||
}
|
||||
|
||||
cargarSnapshot();
|
||||
setInterval(cargarSnapshot, 2000);
|
||||
// 1 s en vez de 2: el llamado salía con hasta 2 s de retraso desde el clic
|
||||
setInterval(cargarSnapshot, 1000);
|
||||
|
||||
document.addEventListener('visibilitychange', () => { if (!document.hidden) cargarSnapshot(); });
|
||||
window.addEventListener('focus', cargarSnapshot);
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
<?php
|
||||
/**
|
||||
* modules/turnero/views/firma.php
|
||||
* Tablet de firma del paciente — /erp.php?m=turnero&v=firma
|
||||
*
|
||||
* Pantalla de cara al público: se pone frente al paciente en el mostrador y
|
||||
* muestra, sola, el consentimiento de bienvenida cuando le toca firmarlo.
|
||||
* Antes había que girarle el monitor a la recepcionista o pasarle el mouse.
|
||||
*
|
||||
* La tablet se identifica por la cookie del dispositivo, registrada desde
|
||||
* Configuración. Sin ella no muestra nada: es lo que impide que cualquiera
|
||||
* abra esta dirección y vea el nombre del paciente de turno.
|
||||
*
|
||||
* Conviene dejarla en modo kiosco, sin barra de direcciones, para que desde
|
||||
* ella no se pueda navegar al resto del ERP.
|
||||
*/
|
||||
|
||||
$_fCfg = [];
|
||||
try {
|
||||
$__pdo = Database::getInstance()->getConnection();
|
||||
$_fCfg = $__pdo->query(
|
||||
"SELECT clave, valor FROM lab_config WHERE clave IN ('empresa_nombre','doc_logo_base64','doc_color')"
|
||||
)->fetchAll(PDO::FETCH_KEY_PAIR) ?: [];
|
||||
} catch (\Throwable $_) {}
|
||||
|
||||
$_fNombre = htmlspecialchars($_fCfg['empresa_nombre'] ?? 'Laboratorio');
|
||||
$_fLogo = $_fCfg['doc_logo_base64'] ?? '';
|
||||
$_fColor = preg_match('/^#[0-9a-fA-F]{3,8}$/', $_fCfg['doc_color'] ?? '') ? $_fCfg['doc_color'] : '#1565c0';
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no">
|
||||
<title>Firma · <?= $_fNombre ?></title>
|
||||
<style>
|
||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
:root { --brand: <?= $_fColor ?>; }
|
||||
html, body {
|
||||
height: 100%; width: 100%; overflow: hidden;
|
||||
font-family: 'Segoe UI', system-ui, sans-serif;
|
||||
background: #f8fafc; color: #1e293b;
|
||||
-webkit-user-select: none; user-select: none;
|
||||
}
|
||||
.pantalla {
|
||||
height: 100vh; display: flex; flex-direction: column;
|
||||
align-items: center; justify-content: center;
|
||||
text-align: center; padding: 4vh 5vw; gap: 2.5vh;
|
||||
}
|
||||
.pantalla.oculta { display: none; }
|
||||
.logo { max-height: 14vh; max-width: 50vw; object-fit: contain; }
|
||||
.marca { font-size: clamp(1.2rem, 3vw, 2rem); font-weight: 700; color: #64748b; }
|
||||
|
||||
.saludo { font-size: clamp(1.4rem, 4vw, 2.6rem); font-weight: 600; color: #94a3b8; }
|
||||
.etiqueta { font-size: clamp(.9rem, 2vw, 1.2rem); font-weight: 700;
|
||||
letter-spacing: 3px; text-transform: uppercase; color: #94a3b8; }
|
||||
.paciente { font-size: clamp(1.8rem, 5.5vw, 3.4rem); font-weight: 800; line-height: 1.15; }
|
||||
.codigo { font-size: clamp(1.1rem, 2.6vw, 1.6rem); font-weight: 700; color: var(--brand); }
|
||||
|
||||
/* Botón deliberadamente enorme: lo va a tocar gente mayor, de pie y de afán */
|
||||
.btn-firmar {
|
||||
margin-top: 2vh;
|
||||
background: var(--brand); color: #fff; border: 0;
|
||||
border-radius: 18px; cursor: pointer;
|
||||
font-family: inherit; font-weight: 800;
|
||||
font-size: clamp(1.5rem, 4.5vw, 2.6rem);
|
||||
padding: clamp(1rem, 3.5vh, 2.2rem) clamp(2.5rem, 12vw, 6rem);
|
||||
box-shadow: 0 10px 30px rgba(0,0,0,.18);
|
||||
transition: transform .12s ease, filter .12s ease;
|
||||
}
|
||||
.btn-firmar:active { transform: scale(.96); filter: brightness(.92); }
|
||||
|
||||
.ok-icono { font-size: clamp(3.5rem, 12vw, 7rem); color: #16a34a; line-height: 1; }
|
||||
.ok-txt { font-size: clamp(1.4rem, 4vw, 2.4rem); font-weight: 700; color: #166534; }
|
||||
|
||||
.aviso { font-size: clamp(1rem, 2.4vw, 1.4rem); color: #94a3b8; max-width: 34ch; line-height: 1.5; }
|
||||
|
||||
/* El formulario ocupa toda la pantalla al abrirse */
|
||||
#marco { position: fixed; inset: 0; z-index: 50; background: #fff; display: none; }
|
||||
#marco.abierto { display: block; }
|
||||
#marco iframe { width: 100%; height: 100%; border: 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<!-- En reposo: nadie a quien pedirle firma -->
|
||||
<div class="pantalla" id="p-reposo">
|
||||
<?php if ($_fLogo): ?><img class="logo" src="<?= htmlspecialchars($_fLogo) ?>" alt=""><?php endif; ?>
|
||||
<div class="marca"><?= $_fNombre ?></div>
|
||||
<div class="saludo">Bienvenido</div>
|
||||
</div>
|
||||
|
||||
<!-- Hay alguien y le falta firmar -->
|
||||
<div class="pantalla oculta" id="p-firmar">
|
||||
<div class="etiqueta">Turno <span id="f-codigo"></span></div>
|
||||
<div class="paciente" id="f-paciente"></div>
|
||||
<div class="aviso">Por favor lea y firme el consentimiento para continuar con su atención.</div>
|
||||
<button class="btn-firmar" id="btn-firmar">Firmar</button>
|
||||
</div>
|
||||
|
||||
<!-- Ya firmó -->
|
||||
<div class="pantalla oculta" id="p-gracias">
|
||||
<div class="ok-icono">✓</div>
|
||||
<div class="ok-txt">¡Gracias!</div>
|
||||
<div class="aviso">Su consentimiento quedó registrado. Puede continuar en el mostrador.</div>
|
||||
</div>
|
||||
|
||||
<!-- La tablet no está registrada -->
|
||||
<div class="pantalla oculta" id="p-sin-registro">
|
||||
<div class="marca"><?= $_fNombre ?></div>
|
||||
<div class="aviso">Esta tablet todavía no está asignada a un puesto.<br>
|
||||
Regístrela desde Configuración del turnero.</div>
|
||||
</div>
|
||||
|
||||
<div id="marco"><iframe id="marco-iframe" src="about:blank"></iframe></div>
|
||||
|
||||
<script>
|
||||
const API = '<?= defined('BASE_URL') ? BASE_URL : '/' ?>modules/turnero/api/';
|
||||
|
||||
let turnoEnPantalla = null; // turno que se está mostrando
|
||||
let firmando = false; // con el formulario abierto no se cambia de pantalla
|
||||
|
||||
function mostrar(id) {
|
||||
['p-reposo','p-firmar','p-gracias','p-sin-registro']
|
||||
.forEach(p => document.getElementById(p).classList.toggle('oculta', p !== id));
|
||||
}
|
||||
|
||||
function abrirFormulario(url) {
|
||||
firmando = true;
|
||||
document.getElementById('marco-iframe').src = url;
|
||||
document.getElementById('marco').classList.add('abierto');
|
||||
}
|
||||
|
||||
function cerrarFormulario() {
|
||||
firmando = false;
|
||||
document.getElementById('marco').classList.remove('abierto');
|
||||
document.getElementById('marco-iframe').src = 'about:blank';
|
||||
}
|
||||
|
||||
document.getElementById('btn-firmar').addEventListener('click', () => {
|
||||
const url = document.getElementById('btn-firmar').dataset.url;
|
||||
if (url) abrirFormulario(url);
|
||||
});
|
||||
|
||||
async function revisar() {
|
||||
// Con el formulario abierto no se toca la pantalla: el paciente está firmando
|
||||
if (firmando) return;
|
||||
|
||||
let d;
|
||||
try {
|
||||
const r = await fetch(API + 'get_firma_pendiente.php', { cache: 'no-store' });
|
||||
d = await r.json();
|
||||
} catch (_) {
|
||||
return; // Sin red se deja lo que haya puesto; ya volverá
|
||||
}
|
||||
|
||||
if (!d.ok) {
|
||||
if (d.motivo === 'sin_dispositivo') mostrar('p-sin-registro');
|
||||
return;
|
||||
}
|
||||
|
||||
if (d.estado === 'por_firmar') {
|
||||
document.getElementById('f-codigo').textContent = d.codigo || '';
|
||||
document.getElementById('f-paciente').textContent = d.paciente || '';
|
||||
document.getElementById('btn-firmar').dataset.url = d.url || '';
|
||||
turnoEnPantalla = d.turno_id;
|
||||
mostrar('p-firmar');
|
||||
return;
|
||||
}
|
||||
|
||||
if (d.estado === 'firmado') {
|
||||
// El "gracias" solo tiene sentido para quien acaba de firmar aquí.
|
||||
// Si la tablet se abre con un turno ya firmado de antes, va a reposo.
|
||||
mostrar(turnoEnPantalla === d.turno_id ? 'p-gracias' : 'p-reposo');
|
||||
return;
|
||||
}
|
||||
|
||||
// reposo: se olvida el turno anterior para no dejar datos de un paciente
|
||||
// en pantalla mientras llega el siguiente
|
||||
turnoEnPantalla = null;
|
||||
mostrar('p-reposo');
|
||||
}
|
||||
|
||||
// ver_formulario_enviado.php avisa al terminar; si no llega el aviso, el sondeo
|
||||
// se encarga igual cuando el consentimiento aparezca como firmado.
|
||||
window.addEventListener('message', (e) => {
|
||||
const t = e.data && e.data.type;
|
||||
if (t === 'turneroFirmado') {
|
||||
cerrarFormulario();
|
||||
mostrar('p-gracias');
|
||||
setTimeout(revisar, 2500);
|
||||
}
|
||||
});
|
||||
|
||||
revisar();
|
||||
setInterval(revisar, 2000);
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -100,6 +100,13 @@ Layout::open('Historial de Turnos', 'fas fa-history');
|
||||
.detail-extra .d-lbl { font-size:.67rem; text-transform:uppercase; letter-spacing:.07em; color:#94a3b8; margin-bottom:5px; }
|
||||
.detail-comment { padding:5px 0; border-bottom:1px solid #f1f5f9; font-size:.82rem; }
|
||||
.detail-comment:last-child { border-bottom:none; }
|
||||
/* ── Origen de cada nota ── */
|
||||
.com-tipo { font-size:.62rem; font-weight:700; letter-spacing:.04em; padding:1px 7px;
|
||||
border-radius:10px; text-transform:uppercase; margin-right:6px; }
|
||||
.com-tipo.recepcion { background:#dbeafe; color:#1d4ed8; }
|
||||
.com-tipo.muestras { background:#dcfce7; color:#15803d; }
|
||||
.com-tipo.general { background:#fef3c7; color:#92400e; }
|
||||
|
||||
|
||||
/* ── Documentos firmados ── */
|
||||
.docs-section { margin-top:10px; padding-top:10px; border-top:1px solid #e2e8f0; }
|
||||
@@ -567,9 +574,10 @@ function renderDetalle(t) {
|
||||
let comHtml = '';
|
||||
if (t.comentarios && t.comentarios.length) {
|
||||
comHtml = `<div class="detail-extra">
|
||||
<div class="d-lbl"><i class="fas fa-comments me-1 text-primary"></i>Comentarios</div>
|
||||
<div class="d-lbl"><i class="fas fa-comments me-1 text-primary"></i>Notas y observaciones (${t.comentarios.length})</div>
|
||||
<div>${t.comentarios.map(c => `
|
||||
<div class="detail-comment">
|
||||
<span class="com-tipo ${esc(c.tipo||'general')}">${({recepcion:'Recepción',muestras:'Toma de muestras',general:'General'})[c.tipo]||c.tipo}</span>
|
||||
<span class="fw-semibold">${esc(c.usuario_nombre)}</span>
|
||||
<span class="text-muted small ms-1">${c.creado_at ? new Date(c.creado_at.replace(' ','T')).toLocaleString('es-CO',{dateStyle:'short',timeStyle:'short'}) : ''}</span>
|
||||
<div class="text-secondary">${esc(c.comentario)}</div>
|
||||
|
||||
@@ -9,6 +9,9 @@ if (!isUserLoggedIn()) {
|
||||
exit;
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/../_acceso.php';
|
||||
turneroExigirRol(['bacteriologo', 'supervisor']);
|
||||
|
||||
// Tablet asignada → forzar su lugar por token de navegador (o IP como fallback)
|
||||
$lugarForzado = 0;
|
||||
try {
|
||||
@@ -44,9 +47,26 @@ try {
|
||||
if ($_dispRow['tipo'] === 'recepcion') {
|
||||
header('Location: ' . BASE_URL . 'erp.php?m=turnero&v=recepcion&desk_id=' . $_forzado); exit;
|
||||
}
|
||||
if ((int)($_GET['lugar_id'] ?? 0) !== $_forzado) {
|
||||
$_pedido = (int)($_GET['lugar_id'] ?? 0);
|
||||
|
||||
// Un equipo fijo queda atado a su estación, pero algunas —Pediatría,
|
||||
// Ginecología— se atienden desde cualquier puesto: se marcan como de
|
||||
// acceso libre y no se redirige.
|
||||
$_libre = false;
|
||||
if ($_pedido && $_pedido !== $_forzado) {
|
||||
$_sl = $_dispPdo->prepare(
|
||||
"SELECT acceso_libre FROM turnero_lugares WHERE id = ? AND activo = 1 LIMIT 1"
|
||||
);
|
||||
$_sl->execute([$_pedido]);
|
||||
$_libre = (bool)$_sl->fetchColumn();
|
||||
}
|
||||
|
||||
if ($_pedido !== $_forzado && !$_libre) {
|
||||
header('Location: ' . BASE_URL . 'erp.php?m=turnero&v=lugar&lugar_id=' . $_forzado); exit;
|
||||
}
|
||||
// El equipo sigue atado a su estación aunque esté atendiendo una de
|
||||
// acceso libre: la vista muestra la pedida (sale de lugar_id), pero el
|
||||
// botón de cambiar sigue oculto y no puede saltar a cualquier otra.
|
||||
$lugarForzado = $_forzado;
|
||||
}
|
||||
} catch (\Throwable $_) {}
|
||||
@@ -520,6 +540,15 @@ try {
|
||||
}
|
||||
/* Historial timeline */
|
||||
.mpac-timeline { padding: 0; }
|
||||
.mpac-lbl { font-size:.7rem; color:#64748b; font-weight:600; display:block; margin-bottom:2px; }
|
||||
.mpac-aviso { font-size:.74rem; color:#1e40af; background:#eff6ff; border:1px solid #bfdbfe;
|
||||
border-radius:8px; padding:7px 10px; margin-bottom:10px; }
|
||||
.mpac-lock { background:#fffbeb; border:1px solid #fde68a; border-radius:8px;
|
||||
padding:9px 11px; margin:4px 0 10px; }
|
||||
.mpac-lock-txt { font-size:.72rem; color:#92400e; margin-bottom:7px; line-height:1.45; }
|
||||
.mpac-aviso-firmados { font-size:.74rem; color:#7f1d1d; background:#fef2f2;
|
||||
border:1px solid #fecaca; border-radius:8px;
|
||||
padding:8px 11px; margin-bottom:10px; line-height:1.45; }
|
||||
.mtl-item {
|
||||
display:flex; gap:10px; align-items:flex-start;
|
||||
padding-bottom:12px; position:relative;
|
||||
@@ -949,6 +978,79 @@ require_once __DIR__ . '/../../../shared/components/sidebar.php';
|
||||
<div id="mpac-badge-emb" class="d-none mt-1">
|
||||
<span class="badge text-bg-danger"><i class="fas fa-baby me-1"></i>Paciente embarazada</span>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="mpacEditar(true)"
|
||||
id="mpac-btn-editar" style="font-size:.74rem">
|
||||
<i class="fas fa-pen me-1"></i>Corregir datos
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Edición de la ficha ── -->
|
||||
<div id="mpac-form" class="d-none">
|
||||
<div class="mpac-aviso">
|
||||
<i class="fas fa-info-circle me-1"></i>
|
||||
Lo que corrija aquí cambia la ficha del paciente <b>en todo el sistema</b>.
|
||||
</div>
|
||||
|
||||
<label class="mpac-lbl">Celular</label>
|
||||
<input type="text" class="form-control form-control-sm mb-2" id="mpac-in-tel" placeholder="3001234567">
|
||||
|
||||
<label class="mpac-lbl">Dirección</label>
|
||||
<input type="text" class="form-control form-control-sm mb-2" id="mpac-in-dir">
|
||||
|
||||
<label class="mpac-lbl">EPS</label>
|
||||
<input type="text" class="form-control form-control-sm mb-2" id="mpac-in-eps">
|
||||
|
||||
<!-- Datos de identidad: requieren un paso deliberado -->
|
||||
<div id="mpac-identidad-lock" class="mpac-lock">
|
||||
<div class="mpac-lock-txt">
|
||||
<i class="fas fa-lock me-1"></i>
|
||||
Nombre, documento y fecha de nacimiento identifican al paciente en toda su
|
||||
historia clínica.
|
||||
</div>
|
||||
<button class="btn btn-sm btn-outline-warning w-100" onclick="mpacDesbloquearIdentidad()"
|
||||
style="font-size:.74rem">
|
||||
<i class="fas fa-unlock me-1"></i>Corregir también estos datos
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="mpac-identidad" class="d-none">
|
||||
<label class="mpac-lbl">Nombre completo</label>
|
||||
<input type="text" class="form-control form-control-sm mb-2" id="mpac-in-nombre">
|
||||
|
||||
<div class="d-flex gap-2 mb-2">
|
||||
<div style="width:90px">
|
||||
<label class="mpac-lbl">Tipo</label>
|
||||
<select class="form-select form-select-sm" id="mpac-in-tipodoc">
|
||||
<option>CC</option><option>TI</option><option>RC</option>
|
||||
<option>CE</option><option>PA</option><option>NIT</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
<label class="mpac-lbl">Nº documento</label>
|
||||
<input type="text" class="form-control form-control-sm" id="mpac-in-doc">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label class="mpac-lbl">Fecha de nacimiento</label>
|
||||
<input type="date" class="form-control form-control-sm mb-2" id="mpac-in-fnac">
|
||||
</div>
|
||||
|
||||
<div id="mpac-aviso-firmados" class="mpac-aviso-firmados d-none">
|
||||
<i class="fas fa-exclamation-triangle me-1"></i>
|
||||
Este turno ya tiene documentos firmados. Corregir la ficha
|
||||
<b>no modifica lo que ya se firmó</b>: si un documento salió con datos
|
||||
equivocados, hay que emitirlo de nuevo.
|
||||
</div>
|
||||
|
||||
<div id="mpac-msg" class="small mb-2"></div>
|
||||
<div class="d-flex gap-2">
|
||||
<button class="btn btn-sm btn-primary flex-grow-1" onclick="mpacGuardarFicha()" id="mpac-btn-guardar">
|
||||
<i class="fas fa-save me-1"></i>Guardar
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-secondary" onclick="mpacEditar(false)">Cancelar</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- Historial -->
|
||||
<div class="mpac-sec-title"><i class="fas fa-history" style="color:#6366f1"></i>Historial de visitas</div>
|
||||
@@ -1253,9 +1355,11 @@ async function seleccionarSinLlamar(turnoId) {
|
||||
mostrarCabeceraTurno(t);
|
||||
await cargarFichaSolicitud(t.id);
|
||||
clearInterval(pollingConsentId);
|
||||
if (!_esSoloEntrega(t))
|
||||
// También para solo entrega de muestras: a esos turnos les corresponde el
|
||||
// formulario del puesto, así que la lista tiene que refrescarse igual o al
|
||||
// firmar no se vería el cambio.
|
||||
pollingConsentId = setInterval(() => actualizarConsentimientos(turnoActivo?.id), 5000);
|
||||
if (LUGAR_FORM_MODO === 'embebido' && lugarId && !_esSoloEntrega(t))
|
||||
if (LUGAR_FORM_MODO === 'embebido' && lugarId)
|
||||
cargarFormEmbebido(t.id);
|
||||
mostrarFichaMobile();
|
||||
}
|
||||
@@ -1298,9 +1402,8 @@ async function abrirFicha(turno) {
|
||||
mostrarCabeceraTurno(turno);
|
||||
await cargarFichaSolicitud(turno.id);
|
||||
clearInterval(pollingConsentId);
|
||||
if (!_esSoloEntrega(turno))
|
||||
pollingConsentId = setInterval(() => actualizarConsentimientos(turnoActivo?.id), 5000);
|
||||
if (LUGAR_FORM_MODO === 'embebido' && lugarId && !_esSoloEntrega(turno))
|
||||
if (LUGAR_FORM_MODO === 'embebido' && lugarId)
|
||||
cargarFormEmbebido(turno.id);
|
||||
mostrarFichaMobile();
|
||||
}
|
||||
@@ -1366,10 +1469,15 @@ async function cargarFichaSolicitud(turnoId) {
|
||||
const esSoloMuestras = !!(sol && sol.solo_muestras == 1);
|
||||
document.getElementById('badge-solo-muestras')?.classList.toggle('d-none', !esSoloMuestras);
|
||||
|
||||
// Ocultar sección consentimientos y formulario embebido cuando es solo entrega
|
||||
// La sección de formularios ya NO se esconde por ser solo entrega de
|
||||
// muestras. Esos turnos no traen exámenes, pero sí les corresponde el
|
||||
// formulario del puesto (F-LAB-08, Datos Toma de Muestras): el backend
|
||||
// se lo venía creando y la interfaz lo ocultaba, así que se acumulaban
|
||||
// creados y sin firmar. Quién se muestra lo decide renderConsentimientos
|
||||
// según lo que realmente haya que llenar.
|
||||
const secConsent = document.getElementById('sec-consent');
|
||||
const secForm = document.getElementById('sec-form-embebido');
|
||||
if (secConsent) secConsent.classList.toggle('d-none', esSoloMuestras);
|
||||
if (secConsent) secConsent.classList.remove('d-none');
|
||||
if (secForm) secForm.classList.add('d-none');
|
||||
|
||||
// Embarazada
|
||||
@@ -1917,6 +2025,14 @@ function _mpParentCancelCd() {
|
||||
}
|
||||
|
||||
window.addEventListener('message', function(e) {
|
||||
// El formulario embebido pide corregir la ficha del paciente: se abre el
|
||||
// modal directamente en modo edición, sin cerrar el formulario.
|
||||
if (e.data && e.data.type === 'corregirPaciente') {
|
||||
if (!_pacienteActivo) return;
|
||||
abrirModalPaciente();
|
||||
mpacEditar(true);
|
||||
return;
|
||||
}
|
||||
if (e.data && e.data.type === 'tomaProgresivaIniciada') {
|
||||
_tomaProgresivaActiva = true;
|
||||
if ('Notification' in window && Notification.permission === 'default') Notification.requestPermission();
|
||||
@@ -2120,7 +2236,7 @@ function resetFicha() {
|
||||
_muestrasActivas = [];
|
||||
const secMuestras = document.getElementById('sec-muestras');
|
||||
if (secMuestras) secMuestras.classList.add('d-none');
|
||||
// Restaurar sección consent (puede haber sido ocultada por turno solo-muestras)
|
||||
// Dejar la sección visible para la siguiente ficha
|
||||
document.getElementById('sec-consent')?.classList.remove('d-none');
|
||||
document.getElementById('badge-solo-muestras')?.classList.add('d-none');
|
||||
document.getElementById('ficha-orden').classList.add('d-none');
|
||||
@@ -2386,6 +2502,107 @@ function abrirModalPaciente() {
|
||||
|
||||
function cerrarModalPaciente() {
|
||||
document.getElementById('modal-pac-lugar').classList.remove('show');
|
||||
mpacEditar(false);
|
||||
}
|
||||
|
||||
/* ── Corrección de la ficha del paciente desde la estación ──
|
||||
Los campos del formulario son vinculados: leen de la ficha. Por eso se
|
||||
corrige la ficha y no el documento — así el dato queda bien en el turnero,
|
||||
los domicilios y las órdenes, no solo en la hoja que se está llenando. */
|
||||
|
||||
function mpacEditar(abrir) {
|
||||
document.getElementById('mpac-form').classList.toggle('d-none', !abrir);
|
||||
document.getElementById('mpac-datos').classList.toggle('d-none', abrir);
|
||||
document.getElementById('mpac-msg').textContent = '';
|
||||
|
||||
if (!abrir) { mpacBloquearIdentidad(); return; }
|
||||
|
||||
const p = _pacienteActivo || {};
|
||||
document.getElementById('mpac-in-tel').value = p.telefono || p.celular || '';
|
||||
document.getElementById('mpac-in-dir').value = p.direccion || '';
|
||||
document.getElementById('mpac-in-eps').value = p.eps || '';
|
||||
document.getElementById('mpac-in-nombre').value = p.nombre_completo || '';
|
||||
document.getElementById('mpac-in-tipodoc').value = p.tipo_documento || 'CC';
|
||||
document.getElementById('mpac-in-doc').value = p.numero_documento || p.documento || '';
|
||||
document.getElementById('mpac-in-fnac').value = (p.fecha_nacimiento || '').slice(0, 10);
|
||||
|
||||
mpacBloquearIdentidad();
|
||||
|
||||
// Si ya hay documentos firmados, advertir que corregir no los modifica.
|
||||
// El botón "Ver firmado" solo se renderiza para consentimientos ya firmados.
|
||||
const firmados = document.querySelectorAll('#lista-consent button[title="Ver firmado"]').length > 0;
|
||||
document.getElementById('mpac-aviso-firmados').classList.toggle('d-none', !firmados);
|
||||
}
|
||||
|
||||
function mpacBloquearIdentidad() {
|
||||
document.getElementById('mpac-identidad').classList.add('d-none');
|
||||
document.getElementById('mpac-identidad-lock').classList.remove('d-none');
|
||||
}
|
||||
|
||||
function mpacDesbloquearIdentidad() {
|
||||
if (!confirm('Nombre, documento y fecha de nacimiento identifican al paciente en toda su '
|
||||
+ 'historia clínica.\n\n¿Confirma que quiere corregirlos?')) return;
|
||||
document.getElementById('mpac-identidad').classList.remove('d-none');
|
||||
document.getElementById('mpac-identidad-lock').classList.add('d-none');
|
||||
}
|
||||
|
||||
async function mpacGuardarFicha() {
|
||||
if (!_pacienteActivo?.id) return;
|
||||
const msg = document.getElementById('mpac-msg');
|
||||
const btn = document.getElementById('mpac-btn-guardar');
|
||||
const val = id => document.getElementById(id).value.trim();
|
||||
|
||||
const cuerpo = {
|
||||
id: _pacienteActivo.id,
|
||||
telefono: val('mpac-in-tel'),
|
||||
direccion: val('mpac-in-dir'),
|
||||
eps: val('mpac-in-eps'),
|
||||
};
|
||||
// Los datos de identidad solo se envían si se desbloquearon
|
||||
if (!document.getElementById('mpac-identidad').classList.contains('d-none')) {
|
||||
cuerpo.nombre_completo = val('mpac-in-nombre');
|
||||
cuerpo.tipo_documento = val('mpac-in-tipodoc');
|
||||
cuerpo.numero_documento = val('mpac-in-doc');
|
||||
cuerpo.fecha_nacimiento = val('mpac-in-fnac') || null;
|
||||
}
|
||||
|
||||
btn.disabled = true;
|
||||
msg.className = 'small mb-2 text-muted';
|
||||
msg.textContent = 'Guardando…';
|
||||
|
||||
try {
|
||||
const r = await fetch(`${BASE_WA}api/lab/save_paciente.php`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(cuerpo),
|
||||
});
|
||||
const d = await r.json();
|
||||
if (!d.ok) {
|
||||
msg.className = 'small mb-2 text-danger';
|
||||
msg.textContent = d.error || 'No se pudo guardar.';
|
||||
btn.disabled = false;
|
||||
return;
|
||||
}
|
||||
// Reflejar el cambio sin recargar: ficha del modal, tarjeta del turno
|
||||
// y el formulario abierto, cuyos campos vinculados leen de la ficha.
|
||||
Object.assign(_pacienteActivo, cuerpo);
|
||||
msg.className = 'small mb-2 text-success';
|
||||
msg.textContent = 'Ficha actualizada.';
|
||||
setTimeout(() => {
|
||||
mpacEditar(false);
|
||||
abrirModalPaciente();
|
||||
if (turnoActivo?.id) actualizarConsentimientos(turnoActivo.id);
|
||||
// Recargar el formulario abierto para que sus campos vinculados
|
||||
// tomen el dato corregido. Puede estar en el modal o embebido.
|
||||
['modal-consent-iframe', 'form-embebido-iframe'].forEach(function (id) {
|
||||
const marco = document.getElementById(id);
|
||||
if (marco && marco.src) marco.src = marco.src;
|
||||
});
|
||||
}, 700);
|
||||
} catch (_) {
|
||||
msg.className = 'small mb-2 text-danger';
|
||||
msg.textContent = 'Error de conexión.';
|
||||
btn.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function cargarHistorialModal(pacienteId) {
|
||||
|
||||
@@ -9,6 +9,9 @@ if (!isUserLoggedIn()) {
|
||||
exit;
|
||||
}
|
||||
|
||||
require_once __DIR__ . '/../_acceso.php';
|
||||
turneroExigirRol(['recepcionista', 'lab_recepcion', 'supervisor']);
|
||||
|
||||
// Tablet asignada → forzar su escritorio por token de navegador (o IP como fallback)
|
||||
$_recepForzado = 0;
|
||||
try {
|
||||
@@ -736,7 +739,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
</select>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick="examTS && examTS.clear()">
|
||||
<button class="btn btn-outline-secondary btn-sm" onclick="limpiarExamenes()">
|
||||
<i class="fas fa-times me-1"></i>Limpiar selección
|
||||
</button>
|
||||
</div>
|
||||
@@ -1168,6 +1171,19 @@ function _actualizarContadorExamenes() {
|
||||
badge.classList.toggle('d-none', n === 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Limpia la selección de exámenes dejando contador y precios en sincronía.
|
||||
* examTS.clear() quita los ítems en modo silencioso, así que no dispara
|
||||
* onItemRemove: sin esto la insignia conserva el número anterior y el panel
|
||||
* de precios sigue mostrando exámenes que ya no están seleccionados.
|
||||
*/
|
||||
function limpiarExamenes() {
|
||||
if (!examTS) return;
|
||||
examTS.clear();
|
||||
_actualizarContadorExamenes();
|
||||
recalcularPrecios();
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
examTS = new TomSelect('#sel-examenes', {
|
||||
plugins: ['remove_button'],
|
||||
@@ -1711,7 +1727,10 @@ async function consultarExamenesRips(cedula) {
|
||||
|
||||
async function cargarExamenesRips() {
|
||||
if (!_ripsData?.encontrados?.length || !examTS) return;
|
||||
// Se agregan en modo silencioso para no recalcular precios en cada examen;
|
||||
// el recálculo y el contador se hacen una sola vez al terminar.
|
||||
_ripsData.encontrados.forEach(e => examTS.addItem(String(e.exam_tipo_id), true));
|
||||
_actualizarContadorExamenes();
|
||||
if (_ripsData.diagnostico_cod) {
|
||||
document.getElementById('inp-diag').value = _ripsData.diagnostico_cod;
|
||||
}
|
||||
@@ -2291,12 +2310,32 @@ async function pasarALugar() {
|
||||
// ── Ausente ───────────────────────────────────────────────────
|
||||
async function marcarAusente() {
|
||||
if (!turnoActivo) return;
|
||||
if (!confirm(`¿Marcar turno ${turnoActivo.codigo} como AUSENTE?`)) return;
|
||||
|
||||
// El motivo se guarda como comentario de recepción: así aparece en la
|
||||
// bandeja, el historial y el dashboard sin duplicar el dato.
|
||||
const motivo = prompt(
|
||||
`Marcar el turno ${turnoActivo.codigo} como AUSENTE.\n\n`
|
||||
+ '¿Por qué? (no respondió al llamado, se retiró, reprogramó…)');
|
||||
if (motivo === null) return; // canceló
|
||||
const motivoLimpio = motivo.trim();
|
||||
if (!motivoLimpio) { mostrarError('Indique el motivo para marcar como ausente.'); return; }
|
||||
|
||||
const turnoId = turnoActivo.id;
|
||||
|
||||
await fetch(API + 'comentarios.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
turno_id: turnoId,
|
||||
comentario: 'Marcado ausente: ' + motivoLimpio,
|
||||
tipo: 'recepcion',
|
||||
}),
|
||||
}).catch(() => {});
|
||||
|
||||
await fetch(API + 'cambiar_estado.php', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ turno_id: turnoActivo.id, nuevo_estado: 'ausente' }),
|
||||
body: JSON.stringify({ turno_id: turnoId, nuevo_estado: 'ausente' }),
|
||||
});
|
||||
|
||||
turnoActivo = null;
|
||||
@@ -2382,7 +2421,7 @@ function escJs(s) { return String(s||'').replace(/\\/g,'\\\\').replace(/'/g,"\\'
|
||||
function toggleSoloMuestras() {
|
||||
const solo = document.getElementById('chk-solo-muestras').checked;
|
||||
const wrap = document.getElementById('wrap-examenes');
|
||||
if (examTS) { examTS.clear(); solo ? examTS.disable() : examTS.enable(); }
|
||||
if (examTS) { limpiarExamenes(); solo ? examTS.disable() : examTS.enable(); }
|
||||
wrap.classList.toggle('disabled', solo);
|
||||
document.getElementById('bloque-medico').style.display = solo ? 'none' : '';
|
||||
if (solo) quitarMedico();
|
||||
@@ -2390,7 +2429,7 @@ function toggleSoloMuestras() {
|
||||
|
||||
function resetCheckboxes() {
|
||||
document.getElementById('chk-solo-muestras').checked = false;
|
||||
if (examTS) { examTS.clear(); examTS.enable(); }
|
||||
if (examTS) { limpiarExamenes(); examTS.enable(); }
|
||||
document.getElementById('wrap-examenes').classList.remove('disabled');
|
||||
document.getElementById('bloque-medico').style.display = '';
|
||||
descartarRips();
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
<?php
|
||||
/**
|
||||
* modules/turnero/views/voces.php
|
||||
* Prueba de voces del televisor — /erp.php?m=turnero&v=voces
|
||||
*
|
||||
* Las voces disponibles no las decide el ERP sino el equipo: dependen del
|
||||
* sistema operativo y del navegador del televisor. Por eso esta página hay que
|
||||
* abrirla EN EL TELEVISOR, no en el computador de la oficina: cada equipo
|
||||
* ofrece una lista distinta.
|
||||
*
|
||||
* Sirve para oír cada voz con el texto real de un llamado y elegir la que
|
||||
* quede. Distingue las locales de las que se bajan de internet, que son las
|
||||
* que se entrecortan cuando el wifi flaquea.
|
||||
*/
|
||||
?>
|
||||
<!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Prueba de voces · Turnero</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; }
|
||||
body {
|
||||
margin: 0; padding: 24px;
|
||||
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
||||
background: #0f172a; color: #e2e8f0;
|
||||
}
|
||||
h1 { font-size: 1.5rem; margin: 0 0 4px; }
|
||||
.sub { color: #94a3b8; font-size: .95rem; margin-bottom: 20px; }
|
||||
.aviso {
|
||||
background: #1e293b; border-left: 4px solid #38bdf8;
|
||||
padding: 12px 16px; border-radius: 6px; margin-bottom: 22px;
|
||||
font-size: .92rem; line-height: 1.5;
|
||||
}
|
||||
.fila {
|
||||
display: flex; align-items: center; gap: 14px;
|
||||
background: #1e293b; border-radius: 8px;
|
||||
padding: 14px 16px; margin-bottom: 10px;
|
||||
border: 1px solid #334155;
|
||||
}
|
||||
.fila.elegida { border-color: #38bdf8; background: #1e3a52; }
|
||||
.nom { font-weight: 600; flex: 1; min-width: 0; }
|
||||
.nom small { display: block; font-weight: 400; color: #94a3b8; font-size: .8rem; }
|
||||
.tag {
|
||||
font-size: .72rem; padding: 3px 9px; border-radius: 99px;
|
||||
white-space: nowrap; font-weight: 600;
|
||||
}
|
||||
.local { background: #14532d; color: #86efac; }
|
||||
.remota { background: #7c2d12; color: #fdba74; }
|
||||
.actual { background: #0c4a6e; color: #7dd3fc; }
|
||||
button {
|
||||
background: #0284c7; color: #fff; border: 0;
|
||||
padding: 10px 20px; border-radius: 6px; cursor: pointer;
|
||||
font-size: .95rem; font-weight: 600; white-space: nowrap;
|
||||
}
|
||||
button:hover { background: #0369a1; }
|
||||
#vacio { color: #94a3b8; padding: 20px 0; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<h1>Prueba de voces</h1>
|
||||
<div class="sub">Ábrala <strong>en el televisor</strong>: cada equipo ofrece voces distintas.</div>
|
||||
|
||||
<div class="aviso">
|
||||
Las <span class="tag local">local</span> salen del propio equipo: nunca se cortan, aunque suenen
|
||||
algo más robóticas.<br>
|
||||
Las <span class="tag remota">de internet</span> suenan más naturales, pero se bajan de los
|
||||
servidores de Google en cada llamado. Con wifi débil se entrecortan o no suenan.
|
||||
<br><br>
|
||||
Oiga las que aparezcan y dígame cuál prefiere.
|
||||
</div>
|
||||
|
||||
<div id="lista"><div id="vacio">Cargando voces…</div></div>
|
||||
|
||||
<script>
|
||||
// El mismo texto que dice la pantalla de turnos, para juzgarlas en condiciones
|
||||
const TEXTO = 'Turno A 0 1, María Fernanda Gómez, pase a Consultorio 2';
|
||||
|
||||
// Réplica de la preferencia de display_global.php, para señalar cuál está
|
||||
// sonando. Si allá se cambia el orden, hay que cambiarlo aquí también.
|
||||
const VOCES_PREFERIDAS = ['sabina', 'paulina'];
|
||||
|
||||
function vozActual(voces) {
|
||||
if (!voces.length) return null;
|
||||
const locales = voces.filter(v => v.localService);
|
||||
for (const nombre of VOCES_PREFERIDAS) {
|
||||
const v = locales.find(v => v.name.toLowerCase().includes(nombre));
|
||||
if (v) return v;
|
||||
}
|
||||
for (const lang of ['es-MX','es-419','es-US','es-CO']) {
|
||||
const v = locales.find(v => v.lang === lang);
|
||||
if (v) return v;
|
||||
}
|
||||
if (locales.length) return locales[0];
|
||||
for (const lang of ['es-CO','es-419','es-MX','es-US','es-ES']) {
|
||||
const v = voces.find(v => v.lang === lang);
|
||||
if (v) return v;
|
||||
}
|
||||
return voces[0];
|
||||
}
|
||||
|
||||
function probar(voz) {
|
||||
window.speechSynthesis.cancel();
|
||||
const u = new SpeechSynthesisUtterance(TEXTO);
|
||||
u.voice = voz;
|
||||
u.lang = voz.lang;
|
||||
u.rate = 0.95;
|
||||
u.pitch = 1.05;
|
||||
window.speechSynthesis.speak(u);
|
||||
}
|
||||
|
||||
function pintar() {
|
||||
const todas = window.speechSynthesis.getVoices();
|
||||
const es = todas.filter(v => v.lang && v.lang.toLowerCase().startsWith('es'));
|
||||
const lista = document.getElementById('lista');
|
||||
const actual = vozActual(es);
|
||||
|
||||
if (!es.length) {
|
||||
lista.innerHTML = '<div id="vacio">Este equipo no tiene ninguna voz en español instalada. '
|
||||
+ 'Habría que instalarle una desde la configuración del sistema, '
|
||||
+ 'o dejar el llamado solo con el pito y el cartel.</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
// Las locales primero: son las que interesan
|
||||
es.sort((a, b) => (b.localService - a.localService) || a.name.localeCompare(b.name));
|
||||
|
||||
lista.innerHTML = '';
|
||||
es.forEach((v, i) => {
|
||||
const fila = document.createElement('div');
|
||||
fila.className = 'fila' + (v === actual ? ' elegida' : '');
|
||||
fila.innerHTML =
|
||||
'<div class="nom">' + v.name + '<small>' + v.lang + '</small></div>'
|
||||
+ (v.localService ? '<span class="tag local">local</span>'
|
||||
: '<span class="tag remota">de internet</span>')
|
||||
+ (v === actual ? '<span class="tag actual">la de ahora</span>' : '');
|
||||
const b = document.createElement('button');
|
||||
b.textContent = 'Oír';
|
||||
b.onclick = () => probar(v);
|
||||
fila.appendChild(b);
|
||||
lista.appendChild(fila);
|
||||
});
|
||||
}
|
||||
|
||||
// getVoices() suele venir vacío en la primera llamada: el navegador las carga aparte
|
||||
window.speechSynthesis.onvoiceschanged = pintar;
|
||||
pintar();
|
||||
setTimeout(pintar, 600);
|
||||
</script>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,185 @@
|
||||
<?php
|
||||
/**
|
||||
* scripts/backfill_bsuid.php
|
||||
*
|
||||
* Rellena users.bsuid a partir del histórico de webhook_logs.
|
||||
*
|
||||
* Meta manda el BSUID en todos los webhooks de mensaje, así que en el histórico
|
||||
* están las equivalencias BSUID↔teléfono de la gente que escribió cuando aún
|
||||
* mostraba su número. Cargarlas significa que, el día que oculten el teléfono,
|
||||
* el bot siga sabiendo quiénes son en vez de tratarlos como desconocidos.
|
||||
*
|
||||
* Es idempotente: se puede correr las veces que haga falta.
|
||||
*
|
||||
* La lectura del histórico tarda más de media hora (son ~230.000 registros de
|
||||
* un servidor remoto), y mantener la conexión abierta todo ese rato la mataba
|
||||
* antes de empezar a escribir: "MySQL server has gone away". Por eso el mapa se
|
||||
* guarda en disco y la escritura usa una conexión nueva. De paso, reintentar
|
||||
* sale gratis: si el mapa ya está en caché, no se vuelve a leer la tabla.
|
||||
*
|
||||
* Uso:
|
||||
* php scripts/backfill_bsuid.php --simular (no escribe, solo informa)
|
||||
* php scripts/backfill_bsuid.php
|
||||
* php scripts/backfill_bsuid.php --releer (ignora la caché y relee el histórico)
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../config/config.php';
|
||||
|
||||
$simular = in_array('--simular', $argv, true);
|
||||
$releer = in_array('--releer', $argv, true);
|
||||
echo $simular ? "Modo simulación: no se escribe nada.\n\n" : "Aplicando cambios.\n\n";
|
||||
|
||||
$cache = sys_get_temp_dir() . '/backfill_bsuid_mapa.json';
|
||||
|
||||
// --- 1. Recorrer el histórico y armar el mapa BSUID → teléfono ---
|
||||
$mapa = [];
|
||||
$sinTelefono = [];
|
||||
|
||||
if (!$releer && is_readable($cache)) {
|
||||
$guardado = json_decode(file_get_contents($cache), true) ?: [];
|
||||
$mapa = $guardado['mapa'] ?? [];
|
||||
$sinTelefono = $guardado['sinTelefono'] ?? [];
|
||||
printf("Mapa tomado de la caché (%s, del %s).\n", basename($cache), date('Y-m-d H:i', filemtime($cache)));
|
||||
printf("Para releer el histórico: --releer\n\n");
|
||||
}
|
||||
|
||||
if (!$mapa) {
|
||||
echo "Leyendo webhook_logs (tarda; es una sola vez)...\n";
|
||||
$db = Database::getInstance();
|
||||
|
||||
// Una sola pasada sin buffer: son más de 400.000 registros, así que ni caben
|
||||
// en memoria de golpe ni conviene reconsultar la tabla por bloques (cada bloque
|
||||
// volvía a recorrerla entera y tardaba una eternidad).
|
||||
$conn = $db->getConnection();
|
||||
$bufferPrevio = $conn->getAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY);
|
||||
$conn->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, false);
|
||||
|
||||
$leidos = 0;
|
||||
|
||||
try {
|
||||
$stmt = $conn->query(
|
||||
"SELECT request_body FROM webhook_logs
|
||||
WHERE request_body LIKE '%user_id%' AND request_body LIKE '%\"messages\"%'"
|
||||
);
|
||||
|
||||
while ($fila = $stmt->fetch(PDO::FETCH_ASSOC)) {
|
||||
$leidos++;
|
||||
if ($leidos % 50000 === 0) printf(" ...%d registros\n", $leidos);
|
||||
$d = json_decode($fila['request_body'], true);
|
||||
if (!$d) continue;
|
||||
|
||||
foreach ($d['entry'] ?? [] as $entry) {
|
||||
foreach ($entry['changes'] ?? [] as $cambio) {
|
||||
$v = $cambio['value'] ?? [];
|
||||
|
||||
// El teléfono puede venir en contacts[].wa_id o en messages[].from
|
||||
foreach ($v['contacts'] ?? [] as $c) {
|
||||
if (!empty($c['user_id']) && !empty($c['wa_id'])) {
|
||||
$mapa[$c['user_id']] = $c['wa_id'];
|
||||
}
|
||||
}
|
||||
foreach ($v['messages'] ?? [] as $m) {
|
||||
$uid = $m['from_user_id'] ?? null;
|
||||
if (!$uid) continue;
|
||||
if (!empty($m['from'])) $mapa[$uid] = $m['from'];
|
||||
else $sinTelefono[$uid] = true;
|
||||
}
|
||||
|
||||
// Cuando alguien comparte su contacto, el teléfono llega aquí
|
||||
foreach ($v['messages'] ?? [] as $m) {
|
||||
if (($m['type'] ?? '') !== 'contacts') continue;
|
||||
$uid = $m['from_user_id'] ?? null;
|
||||
foreach ($m['contacts'] ?? [] as $c) {
|
||||
foreach ($c['phones'] ?? [] as $t) {
|
||||
$num = $t['wa_id'] ?? ($t['phone'] ?? null);
|
||||
if ($uid && $num) $mapa[$uid] = preg_replace('/[^0-9]/', '', $num);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
$stmt->closeCursor();
|
||||
} finally {
|
||||
// Dejar la conexión como estaba
|
||||
$conn->setAttribute(PDO::MYSQL_ATTR_USE_BUFFERED_QUERY, $bufferPrevio);
|
||||
}
|
||||
|
||||
printf("\n registros revisados: %d\n", $leidos);
|
||||
printf(" equivalencias encontradas: %d\n", count($mapa));
|
||||
file_put_contents($cache, json_encode(['mapa' => $mapa, 'sinTelefono' => $sinTelefono]));
|
||||
printf(" mapa guardado en %s\n", $cache);
|
||||
|
||||
// Se termina aquí a propósito. Tras media hora de lectura el servidor ya cerró
|
||||
// esta conexión, y el singleton de Database no sabe reconectar: escribir ahora
|
||||
// falla con "MySQL server has gone away" a mitad del recorrido, que es justo lo
|
||||
// que pasó la primera vez. La segunda ejecución toma el mapa de la caché en un
|
||||
// segundo y escribe con la conexión sana.
|
||||
echo "\nMapa listo. Ejecute otra vez el comando para aplicarlo.\n";
|
||||
exit(0);
|
||||
}
|
||||
|
||||
printf(" equivalencias encontradas: %d\n", count($mapa));
|
||||
printf(" personas que llegaron sin teléfono: %d\n", count($sinTelefono));
|
||||
|
||||
// --- 1b. Un solo BSUID por teléfono ---
|
||||
// Hay números que aparecen con dos identificadores: la línea cambió de dueño
|
||||
// (reciclada) o la persona se registró de nuevo. Si se recorre el mapa tal cual,
|
||||
// ambos escriben sobre el mismo usuario y el que queda depende del orden: cada
|
||||
// ejecución dejaba un valor distinto. Se resuelve antes de tocar la base,
|
||||
// quedándose con el visto más tarde, y se escribe una sola vez por teléfono.
|
||||
$porTelefono = [];
|
||||
$elegido = [];
|
||||
foreach ($mapa as $b => $t) {
|
||||
$porTelefono[$t][] = $b;
|
||||
$elegido[$t] = $b; // el orden del mapa es el de lectura: el último gana
|
||||
}
|
||||
$ambiguos = array_filter($porTelefono, fn($bs) => count($bs) > 1);
|
||||
printf(" teléfonos distintos: %d\n", count($porTelefono));
|
||||
printf(" teléfonos con más de un BSUID: %d (se toma el más reciente)\n\n", count($ambiguos));
|
||||
foreach (array_slice($ambiguos, 0, 10, true) as $t => $bs) {
|
||||
printf(" %s → %s\n", substr($t, 0, -4) . '****', implode(', ', $bs));
|
||||
}
|
||||
if (count($ambiguos) > 10) printf(" (y %d más)\n", count($ambiguos) - 10);
|
||||
if ($ambiguos) echo "\n";
|
||||
|
||||
// --- 2. Escribir el BSUID en el usuario que corresponde ---
|
||||
$db = Database::getInstance();
|
||||
|
||||
$marcados = 0; $noExisten = 0; $yaEstaban = 0; $conflictos = 0;
|
||||
|
||||
foreach ($elegido as $telefono => $bsuid) {
|
||||
$u = $db->fetch("SELECT id, bsuid FROM users WHERE phone_number = :t", ['t' => $telefono]);
|
||||
if (!$u) { $noExisten++; continue; }
|
||||
if ($u['bsuid'] === $bsuid) { $yaEstaban++; continue; }
|
||||
if (!empty($u['bsuid'])) {
|
||||
printf(" aviso: %s tenía %s y queda con %s\n", $telefono, $u['bsuid'], $bsuid);
|
||||
$conflictos++;
|
||||
}
|
||||
if (!$simular) {
|
||||
try {
|
||||
$db->update('users', ['bsuid' => $bsuid], 'id = ?', [$u['id']]);
|
||||
} catch (Exception $e) {
|
||||
printf(" error en %s: %s\n", $telefono, $e->getMessage());
|
||||
continue;
|
||||
}
|
||||
}
|
||||
$marcados++;
|
||||
}
|
||||
|
||||
printf("\n usuarios marcados con su BSUID: %d\n", $marcados);
|
||||
printf(" ya lo tenían: %d\n", $yaEstaban);
|
||||
printf(" sin usuario en la base: %d\n", $noExisten);
|
||||
printf(" BSUID reemplazados (cambio de número): %d\n", $conflictos);
|
||||
|
||||
// --- 3. ¿A cuántos de los perdidos rescatamos? ---
|
||||
$rescatables = array_intersect_key($mapa, $sinTelefono);
|
||||
printf("\n de los que llegaron sin teléfono, quedan identificados: %d\n", count($rescatables));
|
||||
foreach ($rescatables as $b => $t) {
|
||||
printf(" %-24s → %s\n", $b, substr($t, 0, -4) . '****');
|
||||
}
|
||||
|
||||
$pendientes = array_diff_key($sinTelefono, $mapa);
|
||||
printf("\n siguen sin identificar: %d (hay que pedirles el contacto)\n", count($pendientes));
|
||||
|
||||
echo $simular ? "\nSimulación terminada, no se escribió nada.\n" : "\nListo.\n";
|
||||
@@ -0,0 +1,162 @@
|
||||
<?php
|
||||
/**
|
||||
* Verificación de la identidad por BSUID (nombres de usuario de WhatsApp).
|
||||
* Ejecutar: php test_bsuid.php
|
||||
*/
|
||||
require_once __DIR__ . '/../services/WhatsAppService.php';
|
||||
|
||||
$fallos = 0;
|
||||
function chequear($descripcion, $condicion) {
|
||||
global $fallos;
|
||||
if ($condicion) { echo " ok $descripcion\n"; }
|
||||
else { echo " FALLA $descripcion\n"; $fallos++; }
|
||||
}
|
||||
|
||||
// --- 1. Reconocer un BSUID y no confundirlo con un teléfono ---
|
||||
chequear('reconoce el BSUID real de hoy', WhatsAppService::esBsuid('CO.1761088155094242'));
|
||||
chequear('reconoce el BSUID largo de Meta', WhatsAppService::esBsuid('US.13491208655302741918'));
|
||||
chequear('un celular NO es BSUID', !WhatsAppService::esBsuid('573214533764'));
|
||||
chequear('vacío NO es BSUID', !WhatsAppService::esBsuid(''));
|
||||
chequear('texto suelto NO es BSUID', !WhatsAppService::esBsuid('CO.abc'));
|
||||
|
||||
// --- 2. formatPhoneNumber no debe destrozar el BSUID ---
|
||||
$svc = (new ReflectionClass('WhatsAppService'))->newInstanceWithoutConstructor();
|
||||
$fmt = new ReflectionMethod('WhatsAppService', 'formatPhoneNumber');
|
||||
$fmt->setAccessible(true);
|
||||
|
||||
chequear('el BSUID sale intacto',
|
||||
$fmt->invoke($svc, 'CO.1761088155094242') === 'CO.1761088155094242');
|
||||
chequear('el celular sin indicativo sigue recibiendo el 57',
|
||||
$fmt->invoke($svc, '3214533764') === '573214533764');
|
||||
chequear('el celular con indicativo se conserva',
|
||||
$fmt->invoke($svc, '573214533764') === '573214533764');
|
||||
|
||||
// --- 3. El payload debe usar `recipient` para BSUID y `to` para teléfonos ---
|
||||
// Se replica la transformación de sendMessage(), que es privada y hace red.
|
||||
$transformar = function (array $payload) {
|
||||
if (isset($payload['to']) && WhatsAppService::esBsuid($payload['to'])) {
|
||||
$payload['recipient'] = $payload['to'];
|
||||
unset($payload['to']);
|
||||
}
|
||||
return $payload;
|
||||
};
|
||||
|
||||
$conBsuid = $transformar(['to' => 'CO.1761088155094242', 'type' => 'text']);
|
||||
chequear('con BSUID se envía en `recipient`', ($conBsuid['recipient'] ?? null) === 'CO.1761088155094242');
|
||||
chequear('con BSUID desaparece `to`', !isset($conBsuid['to']));
|
||||
|
||||
$conTel = $transformar(['to' => '573214533764', 'type' => 'text']);
|
||||
chequear('con teléfono se mantiene `to`', ($conTel['to'] ?? null) === '573214533764');
|
||||
chequear('con teléfono no aparece `recipient`', !isset($conTel['recipient']));
|
||||
|
||||
// --- 4. El webhook debe extraer el remitente del payload real que llegó hoy ---
|
||||
$payloadReal = json_decode('{"messaging_product":"whatsapp",
|
||||
"contacts":[{"profile":{"name":"con la fe puesta en Dios","username":"Luzmarytosfer"},
|
||||
"user_id":"CO.1761088155094242"}],
|
||||
"messages":[{"from_user_id":"CO.1761088155094242","id":"wamid.XXX",
|
||||
"timestamp":"1786481656","text":{"body":"Buenos dias"},"type":"text"}]}', true);
|
||||
|
||||
$m = $payloadReal['messages'][0];
|
||||
$remitente = $m['from'] ?? ($m['wa_id'] ?? ($m['from_user_id'] ?? null));
|
||||
chequear('el mensaje que se perdía ahora sí tiene remitente', $remitente === 'CO.1761088155094242');
|
||||
|
||||
$nombres = [];
|
||||
foreach ($payloadReal['contacts'] as $c) {
|
||||
$n = $c['profile']['name'] ?? null;
|
||||
if (!$n) continue;
|
||||
foreach ([$c['wa_id'] ?? null, $c['user_id'] ?? null] as $k) {
|
||||
if ($k) $nombres[$k] = $n;
|
||||
}
|
||||
}
|
||||
chequear('se rescata el nombre aunque no haya wa_id',
|
||||
($nombres['CO.1761088155094242'] ?? null) === 'con la fe puesta en Dios');
|
||||
|
||||
// --- 5. El identificador debe caber en la columna ---
|
||||
chequear('el BSUID más largo cabe en varchar(32)', strlen('US.13491208655302741918') <= 32);
|
||||
|
||||
// --- 6. Extraer el teléfono del contacto compartido (webhook `contacts` de Meta) ---
|
||||
$extraerTelefono = function (array $mensaje) {
|
||||
foreach ($mensaje['contacts'] ?? [] as $c) {
|
||||
foreach ($c['phones'] ?? [] as $t) {
|
||||
$cand = $t['wa_id'] ?? ($t['phone'] ?? null);
|
||||
if ($cand) return preg_replace('/[^0-9]/', '', $cand);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
$porBoton = ['type' => 'contacts', 'from_user_id' => 'CO.1761088155094242',
|
||||
'contacts' => [['origin' => 'contact_request',
|
||||
'phones' => [['phone' => '+57 321 4533764', 'wa_id' => '573214533764', 'type' => 'CELL']]]]];
|
||||
chequear('del botón se extrae el teléfono', $extraerTelefono($porBoton) === '573214533764');
|
||||
|
||||
// Compartido a mano: puede venir con vcard y sin wa_id
|
||||
$aMano = ['type' => 'contacts', 'from_user_id' => 'CO.1761088155094242',
|
||||
'contacts' => [['origin' => 'other', 'vcard' => 'BEGIN:VCARD...',
|
||||
'phones' => [['phone' => '+57 321 453 3764', 'type' => 'CELL']]]]];
|
||||
chequear('compartido a mano también se extrae', $extraerTelefono($aMano) === '573214533764');
|
||||
chequear('sin teléfono devuelve nulo', $extraerTelefono(['type' => 'contacts', 'contacts' => [[]]]) === null);
|
||||
|
||||
// --- 7. El botón para pedir el contacto debe armarse como exige Meta ---
|
||||
$pedir = new ReflectionMethod('WhatsAppService', 'pedirContacto');
|
||||
chequear('pedirContacto existe y es pública', $pedir->isPublic());
|
||||
|
||||
$payloadBoton = [
|
||||
'messaging_product' => 'whatsapp',
|
||||
'recipient_type' => 'individual',
|
||||
'to' => $fmt->invoke($svc, 'CO.1761088155094242'),
|
||||
'type' => 'interactive',
|
||||
'interactive' => ['type' => 'request_contact_info',
|
||||
'body' => ['text' => 'texto'],
|
||||
'action' => ['name' => 'request_contact_info']],
|
||||
];
|
||||
$enviado = $transformar($payloadBoton);
|
||||
chequear('el botón viaja con `recipient` cuando es BSUID',
|
||||
($enviado['recipient'] ?? null) === 'CO.1761088155094242' && !isset($enviado['to']));
|
||||
chequear('el tipo interactivo es el que Meta espera',
|
||||
$enviado['interactive']['type'] === 'request_contact_info'
|
||||
&& $enviado['interactive']['action']['name'] === 'request_contact_info');
|
||||
|
||||
// --- 8. La ficha del paciente no debe recibir el BSUID como teléfono ---
|
||||
// Réplica de la decisión de Paciente::obtenerOCrearDesdeWhatsapp()
|
||||
$fichaDesde = function ($phoneNumber, $nombre = null) {
|
||||
$sinTelefono = esBsuid($phoneNumber);
|
||||
return [
|
||||
'nombre_completo' => $nombre ?? ($sinTelefono ? 'Paciente sin identificar' : 'Paciente ' . $phoneNumber),
|
||||
'telefono' => $sinTelefono ? null : $phoneNumber,
|
||||
];
|
||||
};
|
||||
|
||||
$fichaOculta = $fichaDesde('CO.1761088155094242');
|
||||
chequear('la ficha queda SIN teléfono si es BSUID', $fichaOculta['telefono'] === null);
|
||||
chequear('y sin el identificador metido en el nombre',
|
||||
strpos($fichaOculta['nombre_completo'], 'CO.') === false);
|
||||
|
||||
$fichaNormal = $fichaDesde('573214533764');
|
||||
chequear('con teléfono real la ficha lo conserva', $fichaNormal['telefono'] === '573214533764');
|
||||
chequear('y el nombre por defecto no cambia', $fichaNormal['nombre_completo'] === 'Paciente 573214533764');
|
||||
|
||||
// El daño concreto que se evita: normalizarTelefono() dejaba 16 dígitos con
|
||||
// toda la pinta de un número real dentro de la historia clínica.
|
||||
$comoQuedaba = preg_replace('/[^0-9+]/', '', 'CO.1761088155094242');
|
||||
chequear('antes se guardaba un número falso creíble', $comoQuedaba === '1761088155094242');
|
||||
chequear('y ahora eso ya no llega al campo teléfono', $fichaOculta['telefono'] !== $comoQuedaba);
|
||||
|
||||
// --- 9. Solo se pide el contacto a quien hace falta, y una sola vez ---
|
||||
$decidir = function ($phoneNumber, $pedidoAt) {
|
||||
if (!esBsuid($phoneNumber)) return 'no_hace_falta';
|
||||
if (!empty($pedidoAt)) return 'ya_se_pidio';
|
||||
return 'pedido';
|
||||
};
|
||||
chequear('a quien tiene teléfono no se le pide', $decidir('573214533764', null) === 'no_hace_falta');
|
||||
chequear('a quien lo oculta sí se le pide', $decidir('CO.1761088155094242', null) === 'pedido');
|
||||
chequear('y no se le insiste una segunda vez',
|
||||
$decidir('CO.1761088155094242', '2026-08-11 16:00:00') === 'ya_se_pidio');
|
||||
|
||||
// --- 10. La comprobación vive en un solo sitio ---
|
||||
chequear('esBsuid() global y el del servicio coinciden',
|
||||
esBsuid('CO.1761088155094242') === WhatsAppService::esBsuid('CO.1761088155094242')
|
||||
&& esBsuid('573214533764') === WhatsAppService::esBsuid('573214533764'));
|
||||
|
||||
echo $fallos === 0 ? "\nTodo correcto.\n" : "\n$fallos verificaciones fallaron.\n";
|
||||
exit($fallos === 0 ? 0 : 1);
|
||||
@@ -0,0 +1,108 @@
|
||||
<?php
|
||||
/**
|
||||
* services/GeminiService.php
|
||||
* Llamada a Gemini Flash y contabilidad del presupuesto de tokens de LIA.
|
||||
*
|
||||
* La usan el asistente del turnero (contexto operativo del día) y el de la
|
||||
* documentación (base de conocimiento filtrada por rol). Ambos comparten el
|
||||
* mismo presupuesto, guardado en lab_config.lia_tokens_usados.
|
||||
*/
|
||||
|
||||
final class GeminiService
|
||||
{
|
||||
public const TOKENS_MAX = 1_000_000;
|
||||
private const MODELO = 'gemini-3.5-flash';
|
||||
private const ENDPOINT = 'https://generativelanguage.googleapis.com/v1beta/models/';
|
||||
|
||||
private PDO $pdo;
|
||||
private string $apiKey;
|
||||
private int $tokensUsados;
|
||||
|
||||
public function __construct(PDO $pdo)
|
||||
{
|
||||
$this->pdo = $pdo;
|
||||
$cfg = $pdo->query(
|
||||
"SELECT clave, valor FROM lab_config WHERE clave IN ('gemini_api_key','lia_tokens_usados')"
|
||||
)->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
|
||||
$this->apiKey = trim($cfg['gemini_api_key'] ?? '');
|
||||
$this->tokensUsados = (int)($cfg['lia_tokens_usados'] ?? 0);
|
||||
}
|
||||
|
||||
public function hayClave(): bool { return $this->apiKey !== ''; }
|
||||
public function presupuestoAgotado(): bool { return $this->tokensUsados >= self::TOKENS_MAX; }
|
||||
public function tokensUsados(): int { return $this->tokensUsados; }
|
||||
public function tokensRestantes(): int { return max(0, self::TOKENS_MAX - $this->tokensUsados); }
|
||||
|
||||
/**
|
||||
* Envía una consulta y devuelve la respuesta ya contabilizada.
|
||||
*
|
||||
* @param array $historial Turnos previos: [['role'=>'user'|'model','parts'=>[['text'=>...]]], ...]
|
||||
* @return array{respuesta:string, truncada:bool, tokens_usados:int, tokens_restantes:int, tokens_max:int}
|
||||
* @throws RuntimeException si la API falla; el mensaje es apto para mostrar
|
||||
*/
|
||||
public function preguntar(string $systemPrompt, string $pregunta, array $historial = [], int $maxTokens = 2048): array
|
||||
{
|
||||
$payload = json_encode([
|
||||
'system_instruction' => ['parts' => [['text' => $systemPrompt]]],
|
||||
'contents' => array_merge($historial, [
|
||||
['role' => 'user', 'parts' => [['text' => $pregunta]]],
|
||||
]),
|
||||
'generationConfig' => [
|
||||
'temperature' => 0.3,
|
||||
'maxOutputTokens' => $maxTokens,
|
||||
],
|
||||
], JSON_UNESCAPED_UNICODE);
|
||||
|
||||
$ch = curl_init(self::ENDPOINT . self::MODELO . ':generateContent?key=' . $this->apiKey);
|
||||
curl_setopt_array($ch, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
CURLOPT_POSTFIELDS => $payload,
|
||||
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
|
||||
CURLOPT_TIMEOUT => 25,
|
||||
CURLOPT_SSL_VERIFYPEER => true,
|
||||
]);
|
||||
$raw = curl_exec($ch);
|
||||
$code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
|
||||
curl_close($ch);
|
||||
|
||||
if (!$raw) throw new RuntimeException('Sin respuesta del servicio de IA');
|
||||
|
||||
$json = json_decode($raw, true);
|
||||
if ($code !== 200 || empty($json['candidates'][0]['content']['parts'][0]['text'])) {
|
||||
throw new RuntimeException('Error IA: ' . ($json['error']['message'] ?? 'Error desconocido'));
|
||||
}
|
||||
|
||||
$respuesta = $json['candidates'][0]['content']['parts'][0]['text'];
|
||||
|
||||
// Gemini avisa con finishReason cuando corta; sin esto el usuario recibe
|
||||
// un texto truncado a media frase sin saber que faltó contenido.
|
||||
$truncada = (($json['candidates'][0]['finishReason'] ?? null) === 'MAX_TOKENS');
|
||||
if ($truncada) {
|
||||
$respuesta .= "\n\n_(respuesta cortada por longitud — pide el detalle por partes)_";
|
||||
}
|
||||
|
||||
$this->contabilizar(
|
||||
(int)($json['usageMetadata']['totalTokenCount']
|
||||
?? (mb_strlen($pregunta) + mb_strlen($respuesta)) / 4)
|
||||
);
|
||||
|
||||
return [
|
||||
'respuesta' => $respuesta,
|
||||
'truncada' => $truncada,
|
||||
'tokens_usados' => $this->tokensUsados,
|
||||
'tokens_restantes' => $this->tokensRestantes(),
|
||||
'tokens_max' => self::TOKENS_MAX,
|
||||
];
|
||||
}
|
||||
|
||||
private function contabilizar(int $tokens): void
|
||||
{
|
||||
$this->tokensUsados += $tokens;
|
||||
$this->pdo->prepare(
|
||||
"INSERT INTO lab_config (clave, valor) VALUES ('lia_tokens_usados', ?)
|
||||
ON DUPLICATE KEY UPDATE valor = ?"
|
||||
)->execute([$this->tokensUsados, $this->tokensUsados]);
|
||||
}
|
||||
}
|
||||
@@ -238,6 +238,32 @@ class WhatsAppService
|
||||
/**
|
||||
* Enviar mensaje con botones interactivos
|
||||
*/
|
||||
/**
|
||||
* Pedirle a la persona que comparta su número de teléfono.
|
||||
*
|
||||
* Quien activó el nombre de usuario de WhatsApp llega sin teléfono, y sin él
|
||||
* no se le puede cruzar con su historia ni con el turnero. Este botón es la
|
||||
* vía que da Meta para pedírselo: la persona decide si lo comparte, y si
|
||||
* acepta llega un mensaje de tipo `contacts` con el número.
|
||||
*
|
||||
* Solo tiene sentido dentro de la ventana de 24 horas; fuera de ella hay que
|
||||
* usar una plantilla con el botón REQUEST_CONTACT_INFO.
|
||||
*/
|
||||
public function pedirContacto($to, $bodyText)
|
||||
{
|
||||
return $this->sendMessage([
|
||||
'messaging_product' => 'whatsapp',
|
||||
'recipient_type' => 'individual',
|
||||
'to' => $this->formatPhoneNumber($to),
|
||||
'type' => 'interactive',
|
||||
'interactive' => [
|
||||
'type' => 'request_contact_info',
|
||||
'body' => ['text' => $bodyText],
|
||||
'action' => ['name' => 'request_contact_info'],
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function sendInteractiveMessage($to, $bodyText, $buttons, $header = null, $footer = null)
|
||||
{
|
||||
$interactive = [
|
||||
@@ -767,6 +793,14 @@ class WhatsAppService
|
||||
unset($payload['__app_meta']);
|
||||
}
|
||||
|
||||
// A quien oculta su teléfono se le responde por BSUID, y Meta exige que vaya
|
||||
// en `recipient` en lugar de `to`. Se corrige aquí, que es por donde pasan
|
||||
// todos los envíos, en vez de en cada método que arma un payload.
|
||||
if (isset($payload['to']) && self::esBsuid($payload['to'])) {
|
||||
$payload['recipient'] = $payload['to'];
|
||||
unset($payload['to']);
|
||||
}
|
||||
|
||||
// Debug log (payload sent to WhatsApp)
|
||||
error_log("WhatsApp API URL: " . $url);
|
||||
error_log("WhatsApp Token length: " . strlen($this->token));
|
||||
@@ -881,11 +915,27 @@ class WhatsAppService
|
||||
return $decoded;
|
||||
}
|
||||
|
||||
/**
|
||||
* ¿El destinatario es un BSUID (identificador de usuario por empresa) y no un teléfono?
|
||||
* La definición vive en config.php, que es donde la ven también las clases del
|
||||
* laboratorio; aquí solo se expone para quien ya tiene el servicio a la mano.
|
||||
*/
|
||||
public static function esBsuid($destinatario)
|
||||
{
|
||||
return esBsuid($destinatario);
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatear número de teléfono
|
||||
*/
|
||||
private function formatPhoneNumber($phone)
|
||||
{
|
||||
// Un BSUID no es un teléfono: se devuelve intacto, porque quitarle
|
||||
// el punto y las letras lo convertiría en un destinatario inexistente.
|
||||
if (self::esBsuid($phone)) {
|
||||
return $phone;
|
||||
}
|
||||
|
||||
// Remover caracteres especiales
|
||||
$phone = preg_replace('/[^0-9]/', '', $phone);
|
||||
|
||||
|
||||
@@ -37,7 +37,7 @@ if ($modoTurnero) {
|
||||
p.id AS paciente_id,
|
||||
p.nombre_completo AS paciente_nombre,
|
||||
p.numero_documento, p.tipo_documento,
|
||||
p.fecha_nacimiento, p.telefono AS paciente_telefono, p.eps,
|
||||
p.fecha_nacimiento, p.telefono AS paciente_telefono, p.eps, p.direccion, p.genero,
|
||||
t.sesion_id,
|
||||
ts.numero_orden,
|
||||
med.nombres AS medico_nombres, med.apellidos AS medico_apellidos,
|
||||
@@ -155,6 +155,8 @@ if ($modoTurnero) {
|
||||
'fecha_nacimiento' => $tcRow['fecha_nacimiento'] ?? '',
|
||||
'telefono' => $tcRow['paciente_telefono'] ?? '',
|
||||
'eps' => $tcRow['eps'] ?? '',
|
||||
'direccion' => $tcRow['direccion'] ?? '',
|
||||
'genero' => $tcRow['genero'] ?? '',
|
||||
'medico_nombre' => trim(($tcRow['medico_nombres'] ?? '') . ' ' . ($tcRow['medico_apellidos'] ?? '')),
|
||||
'medico_especialidad'=> $tcRow['medico_especialidad'] ?? '',
|
||||
'medico_codigo' => $tcRow['medico_codigo'] ?? '',
|
||||
@@ -177,7 +179,7 @@ if ($modoTurnero) {
|
||||
f.esquema, f.permite_firma, f.requiere_firma,
|
||||
f.doc_encabezado, f.doc_subtitulo, f.doc_logo_base64, f.doc_color, f.doc_pie_pagina,
|
||||
p.nombre_completo AS paciente_nombre, p.numero_documento, p.tipo_documento,
|
||||
p.fecha_nacimiento, p.telefono AS paciente_telefono, p.eps,
|
||||
p.fecha_nacimiento, p.telefono AS paciente_telefono, p.eps, p.direccion, p.genero,
|
||||
u.full_name AS enviado_por_nombre, u.email AS enviado_por_email,
|
||||
ld.numero_orden
|
||||
FROM lab_form_envios e
|
||||
@@ -201,7 +203,7 @@ if ($modoTurnero) {
|
||||
f.esquema, f.permite_firma, f.requiere_firma,
|
||||
f.doc_encabezado, f.doc_subtitulo, f.doc_logo_base64, f.doc_color, f.doc_pie_pagina,
|
||||
p.nombre_completo AS paciente_nombre, p.numero_documento, p.tipo_documento,
|
||||
p.fecha_nacimiento, p.telefono AS paciente_telefono, p.eps,
|
||||
p.fecha_nacimiento, p.telefono AS paciente_telefono, p.eps, p.direccion, p.genero,
|
||||
u.full_name AS enviado_por_nombre, u.email AS enviado_por_email,
|
||||
ld.numero_orden
|
||||
FROM lab_form_envios e
|
||||
@@ -694,6 +696,9 @@ function _addExamWizardHtml(string $cid): string {
|
||||
.campo-linked { display: flex; gap: 16px; padding: 6px 0;
|
||||
border-bottom: 1px solid #f0f0f0; }
|
||||
.campo-linked-label { flex: 0 0 38%; font-size: 12px; color: #6c757d; }
|
||||
.lnk-corregir { background:none; border:none; color:#94a3b8; cursor:pointer;
|
||||
padding:0 4px; font-size:11px; flex-shrink:0; }
|
||||
.lnk-corregir:hover { color:#1565c0; }
|
||||
.campo-linked-valor { flex: 1; font-size: 13px; font-weight: 600;
|
||||
color: #1565c0; }
|
||||
|
||||
@@ -997,7 +1002,20 @@ function _addExamWizardHtml(string $cid): string {
|
||||
<div id="cargarAnteriorMsg" class="small text-muted mb-2" style="display:none"></div>
|
||||
<?php endif; ?>
|
||||
<?php
|
||||
$paciente = $datosPrefilled['__paciente'] ?? [];
|
||||
// Los campos vinculados leen de aquí. En los envíos de formulario,
|
||||
// datos_prefilled es una copia tomada al enviarlo, que puede no incluir
|
||||
// claves agregadas después; se completa con los datos vivos del paciente
|
||||
// para que un campo automático no quede vacío por eso.
|
||||
$paciente = ($datosPrefilled['__paciente'] ?? []) + array_filter([
|
||||
'nombre_completo' => $envio['paciente_nombre'] ?? null,
|
||||
'numero_documento' => $envio['numero_documento'] ?? null,
|
||||
'tipo_documento' => $envio['tipo_documento'] ?? null,
|
||||
'fecha_nacimiento' => $envio['fecha_nacimiento'] ?? null,
|
||||
'telefono' => $envio['paciente_telefono'] ?? null,
|
||||
'eps' => $envio['eps'] ?? null,
|
||||
'direccion' => $envio['direccion'] ?? null,
|
||||
'genero' => $envio['genero'] ?? null,
|
||||
], fn($v) => $v !== null && $v !== '');
|
||||
|
||||
// Firma global del profesional (un solo valor para todos los campos firma_profesional)
|
||||
$firmaSharedProfesional = $envio['firma_profesional_svg'] ?? null;
|
||||
@@ -1008,6 +1026,7 @@ function _addExamWizardHtml(string $cid): string {
|
||||
$_hayDatosPerCampoSvg = !empty(array_filter(array_keys($datosCliente ?? []), fn($k) => str_ends_with($k, '_svg')));
|
||||
|
||||
// Rastrear si ya se renderizó la firma inline (para no duplicarla al pie)
|
||||
$_lnkBtnPuesto = false; // el lápiz de corregir va una sola vez
|
||||
$_renderedFirmaProfesional = false;
|
||||
$_renderedFirmaPaciente = false;
|
||||
$_esquemaTieneFirmaPaciente = false;
|
||||
@@ -1048,6 +1067,18 @@ function _addExamWizardHtml(string $cid): string {
|
||||
$cond = $campo['condicion'] ?? null;
|
||||
if ($cond) {
|
||||
$vCtrl = $todos[$cond['campo_id']] ?? null;
|
||||
// La condición puede depender de un campo vinculado (p. ej. el
|
||||
// sexo del paciente, que gobierna la sección obstétrica). Esos
|
||||
// no están en las respuestas: su valor sale de la ficha.
|
||||
if ($vCtrl === null) {
|
||||
foreach ($esquema as $__c) {
|
||||
if (($__c['id'] ?? '') === $cond['campo_id']
|
||||
&& ($__c['tipo'] ?? '') === 'linked') {
|
||||
$vCtrl = $paciente[$__c['linked_key'] ?? ''] ?? null;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
$vCond = $cond['valores'] ?? ($cond['valor'] ? [$cond['valor']] : []);
|
||||
$_saltarSeccion = is_array($vCtrl)
|
||||
? empty(array_intersect($vCond, $vCtrl))
|
||||
@@ -1256,6 +1287,12 @@ function _addExamWizardHtml(string $cid): string {
|
||||
<div class="campo-linked" data-campo-id="<?= esc2($cid) ?>">
|
||||
<div class="campo-linked-label"><?= $label ?></div>
|
||||
<div class="campo-linked-valor"><?= esc2($lval) ?></div>
|
||||
<?php if ($modoTurnero && $embebido && $modoEditar && !$_lnkBtnPuesto): $_lnkBtnPuesto = true; ?>
|
||||
<button type="button" class="lnk-corregir no-print" onclick="pedirCorregirPaciente()"
|
||||
title="Corregir los datos del paciente">
|
||||
<i class="fas fa-pen"></i>
|
||||
</button>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
<?php endif; continue; endif; // linked
|
||||
if ($tipo === 'textarea'):
|
||||
@@ -2230,6 +2267,12 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}, 350);
|
||||
});
|
||||
<?php endif; ?>
|
||||
/* El formulario va embebido en la estación: no puede abrir el modal de
|
||||
corrección por su cuenta, le pide al contenedor que lo haga. */
|
||||
function pedirCorregirPaciente() {
|
||||
try { window.parent.postMessage({ type: 'corregirPaciente' }, '*'); } catch (e) {}
|
||||
}
|
||||
|
||||
window._fpwPreFirma = <?= json_encode($firmaProfPreguardada) ?>;
|
||||
window._profNombre = <?= json_encode($profNombre) ?>;
|
||||
window._profDocumento = <?= json_encode($profDocumento) ?>;
|
||||
@@ -3193,13 +3236,25 @@ const topaz = (() => {
|
||||
} else {
|
||||
var radio = ctrl.querySelector('input[type="radio"]:checked');
|
||||
var sel = ctrl.querySelector('select');
|
||||
var v = radio ? radio.value : (sel ? sel.value : '');
|
||||
var txt = ctrl.querySelector('input[type="text"], textarea');
|
||||
// Un campo vinculado se muestra como texto, no como control:
|
||||
// es el caso del sexo, que gobierna la sección obstétrica.
|
||||
var lnk = ctrl.classList.contains('campo-linked')
|
||||
? ctrl.querySelector('.campo-linked-valor') : null;
|
||||
var v = radio ? radio.value
|
||||
: sel ? sel.value
|
||||
: lnk ? lnk.textContent.trim()
|
||||
: txt ? txt.value : '';
|
||||
activo = valoresCond.includes(v);
|
||||
}
|
||||
var ids = [sec.sepId].concat(sec.elemIds);
|
||||
ids.forEach(function(id) {
|
||||
var el = document.querySelector('[data-campo-id="' + id + '"]');
|
||||
if (!el) return;
|
||||
// El PHP marca con display:none los campos de secciones cuya
|
||||
// condición no se cumple. Esta función anima con max-height y
|
||||
// opacity, así que sin quitar el display el campo nunca aparece.
|
||||
if (activo) el.style.removeProperty('display');
|
||||
el.style.transition = 'opacity .2s, max-height .3s';
|
||||
el.style.maxHeight = activo ? '2000px' : '0';
|
||||
el.style.opacity = activo ? '1' : '0';
|
||||
|
||||
Reference in New Issue
Block a user