Compare commits
197
Commits
f9619db8ef
...
main
+5
-518
@@ -1,522 +1,9 @@
|
|||||||
# Documentación del Sistema de Laboratorio
|
# Obsoleto
|
||||||
|
|
||||||
> **Sistema**: Laboratorio Clínico — Módulos de Agendamiento y Formularios
|
Este documento quedó desactualizado y se conserva solo por historial de git.
|
||||||
> **Última actualización**: 27/03/2026
|
|
||||||
|
|
||||||
---
|
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)
|
Ver `README_DOCS.md` para saber cómo se organiza y cómo agregar páginas.
|
||||||
- [¿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.*
|
|
||||||
|
|||||||
@@ -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
|
Ver `README_DOCS.md` para saber cómo se organiza y cómo agregar páginas.
|
||||||
|
|
||||||
```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)
|
|
||||||
|
|||||||
@@ -21,6 +21,53 @@ requireAuthentication();
|
|||||||
$adminId = (int)($_SESSION['admin_user']['id'] ?? 0);
|
$adminId = (int)($_SESSION['admin_user']['id'] ?? 0);
|
||||||
$db = Database::getInstance();
|
$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 ─────────────────────────────────────────────────────
|
// ── GET: solo_paciente ─────────────────────────────────────────────────────
|
||||||
if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['solo_paciente'])) {
|
if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['solo_paciente'])) {
|
||||||
$convId = (int)($_GET['conversation_id'] ?? 0);
|
$convId = (int)($_GET['conversation_id'] ?? 0);
|
||||||
@@ -57,7 +104,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['solo_paciente'])) {
|
|||||||
$pacienteRepo = new Paciente();
|
$pacienteRepo = new Paciente();
|
||||||
$pacienteId = $pacienteRepo->obtenerOCrearDesdeWhatsapp($conv['user_id']);
|
$pacienteId = $pacienteRepo->obtenerOCrearDesdeWhatsapp($conv['user_id']);
|
||||||
$paciente = $pacienteRepo->obtener($pacienteId);
|
$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;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -29,9 +29,11 @@ if (!isUserLoggedIn()) {
|
|||||||
$body = json_decode(file_get_contents('php://input'), true);
|
$body = json_decode(file_get_contents('php://input'), true);
|
||||||
if (!$body) $body = $_POST;
|
if (!$body) $body = $_POST;
|
||||||
|
|
||||||
$envioId = (int)($body['envio_id'] ?? 0);
|
$envioId = (int)($body['envio_id'] ?? 0);
|
||||||
$campoId = trim($body['campo_id'] ?? '');
|
$campoId = trim($body['campo_id'] ?? '');
|
||||||
$svg = $body['svg'] ?? '';
|
$svg = $body['svg'] ?? '';
|
||||||
|
$proNombre = trim($body['pro_nombre'] ?? '');
|
||||||
|
$proCedula = trim($body['pro_cedula'] ?? '');
|
||||||
|
|
||||||
if (!$envioId) err('envio_id requerido');
|
if (!$envioId) err('envio_id requerido');
|
||||||
if ($campoId === '') err('campo_id requerido');
|
if ($campoId === '') err('campo_id requerido');
|
||||||
@@ -72,6 +74,8 @@ if (!$campoDef || ($campoDef['tipo'] ?? '') !== 'firma_profesional') {
|
|||||||
// ── Actualizar datos_cliente: guardar solo en el campo firmado ──
|
// ── Actualizar datos_cliente: guardar solo en el campo firmado ──
|
||||||
$datos = json_decode($envio['datos_cliente'] ?? '{}', true) ?? [];
|
$datos = json_decode($envio['datos_cliente'] ?? '{}', true) ?? [];
|
||||||
$datos[$campoId . '_svg'] = $svg;
|
$datos[$campoId . '_svg'] = $svg;
|
||||||
|
if ($proNombre !== '') $datos['_pro_nombre'] = $proNombre;
|
||||||
|
if ($proCedula !== '') $datos['_pro_cedula'] = $proCedula;
|
||||||
|
|
||||||
$nuevosDatos = json_encode($datos, JSON_UNESCAPED_UNICODE);
|
$nuevosDatos = json_encode($datos, JSON_UNESCAPED_UNICODE);
|
||||||
$nuevoEstado = $envio['estado'] === 'firmado' ? 'firmado' : 'firmado';
|
$nuevoEstado = $envio['estado'] === 'firmado' ? 'firmado' : 'firmado';
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* GET /api/lab/get_tv_log.php — Bitácora de la voz del televisor.
|
||||||
|
* Para la pantalla de Actividad: resumen de salud + últimos fallos, en llano.
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/_helpers.php';
|
||||||
|
requireMethod('GET');
|
||||||
|
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
// Resumen de los últimos 7 días por evento
|
||||||
|
$resumen = $pdo->query(
|
||||||
|
"SELECT evento, COUNT(*) n FROM turnero_tv_log
|
||||||
|
WHERE created_at >= DATE_SUB(NOW(), INTERVAL 7 DAY)
|
||||||
|
GROUP BY evento"
|
||||||
|
)->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||||
|
|
||||||
|
// Últimos incidentes (lo normal —start/end— no se lista: solo lo que falló)
|
||||||
|
$stmt = $pdo->query(
|
||||||
|
"SELECT evento, detalle, created_at FROM turnero_tv_log
|
||||||
|
WHERE evento IN ('mudo', 'sin_start', 'error', 'lag')
|
||||||
|
ORDER BY id DESC LIMIT 30"
|
||||||
|
);
|
||||||
|
$incidentes = [];
|
||||||
|
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
||||||
|
$d = json_decode($r['detalle'] ?? '{}', true) ?: [];
|
||||||
|
$incidentes[] = [
|
||||||
|
'cuando' => $r['created_at'],
|
||||||
|
'evento' => $r['evento'],
|
||||||
|
'turno' => $d['cod'] ?? null,
|
||||||
|
'voz' => $d['voz'] ?? null,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
$anuncios = (int)($resumen['start'] ?? 0);
|
||||||
|
$mudos = (int)($resumen['mudo'] ?? 0);
|
||||||
|
$avisos = (int)($resumen['sin_start'] ?? 0) + (int)($resumen['error'] ?? 0);
|
||||||
|
|
||||||
|
jsonOk([
|
||||||
|
'anuncios_7d' => $anuncios,
|
||||||
|
'completos_7d' => (int)($resumen['end'] ?? 0),
|
||||||
|
'mudos_7d' => $mudos,
|
||||||
|
'avisos_7d' => $avisos,
|
||||||
|
'incidentes' => $incidentes,
|
||||||
|
]);
|
||||||
@@ -55,12 +55,18 @@ try {
|
|||||||
if (!empty($datos['id'])) {
|
if (!empty($datos['id'])) {
|
||||||
// Actualización general
|
// Actualización general
|
||||||
$id = (int)$datos['id'];
|
$id = (int)$datos['id'];
|
||||||
// Enfermero solo puede editar domicilios que él mismo creó
|
// Enfermero puede editar domicilios que creó o que tiene asignados
|
||||||
if (userRole() === 'enfermero') {
|
if (userRole() === 'enfermero') {
|
||||||
$dbCheck = Database::getInstance();
|
$dbCheck = Database::getInstance();
|
||||||
$chk = $dbCheck->fetchOne("SELECT creado_por FROM lab_domicilios WHERE id = ?", [$id]);
|
$chk = $dbCheck->fetchOne(
|
||||||
if (!$chk || (int)($chk['creado_por'] ?? 0) !== adminId()) {
|
"SELECT d.creado_por,
|
||||||
jsonError('Solo puedes editar domicilios que tú mismo agendaste.');
|
(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 = ?",
|
||||||
|
[enfermeraId(), $id]
|
||||||
|
);
|
||||||
|
if (!$chk || ((int)($chk['creado_por'] ?? 0) !== adminId() && !(int)$chk['asignado'])) {
|
||||||
|
jsonError('Solo puedes editar domicilios que agendaste o que tienes asignados.');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
unset($datos['id'], $datos['solo_estado']);
|
unset($datos['id'], $datos['solo_estado']);
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ $payload = json_encode([
|
|||||||
'generationConfig' => ['maxOutputTokens' => 10, 'temperature' => 0],
|
'generationConfig' => ['maxOutputTokens' => 10, 'temperature' => 0],
|
||||||
], JSON_UNESCAPED_UNICODE);
|
], JSON_UNESCAPED_UNICODE);
|
||||||
|
|
||||||
$ch = curl_init("https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={$key}");
|
$ch = curl_init("https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:generateContent?key={$key}");
|
||||||
curl_setopt_array($ch, [
|
curl_setopt_array($ch, [
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
CURLOPT_POST => true,
|
CURLOPT_POST => true,
|
||||||
@@ -37,4 +37,4 @@ if ($code !== 200) {
|
|||||||
jsonError($gemini['error']['message'] ?? "Error HTTP {$code}", 502);
|
jsonError($gemini['error']['message'] ?? "Error HTTP {$code}", 502);
|
||||||
}
|
}
|
||||||
|
|
||||||
jsonOk(['modelo' => 'gemini-2.0-flash', 'respuesta' => $gemini['candidates'][0]['content']['parts'][0]['text'] ?? '']);
|
jsonOk(['modelo' => 'gemini-3.5-flash', 'respuesta' => $gemini['candidates'][0]['content']['parts'][0]['text'] ?? '']);
|
||||||
|
|||||||
+112
-6
@@ -113,24 +113,41 @@ class WhatsAppWebhook {
|
|||||||
return;
|
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 = [];
|
$contactNames = [];
|
||||||
if (isset($value['contacts']) && is_array($value['contacts'])) {
|
if (isset($value['contacts']) && is_array($value['contacts'])) {
|
||||||
foreach ($value['contacts'] as $contact) {
|
foreach ($value['contacts'] as $contact) {
|
||||||
$waId = $contact['wa_id'] ?? null;
|
|
||||||
$name = $contact['profile']['name'] ?? null;
|
$name = $contact['profile']['name'] ?? null;
|
||||||
if ($waId && $name) {
|
if (!$name) continue;
|
||||||
$contactNames[$waId] = $name;
|
foreach ([$contact['wa_id'] ?? null, $contact['user_id'] ?? null] as $clave) {
|
||||||
|
if ($clave) $contactNames[$clave] = $name;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ($items as $message) {
|
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);
|
$phoneNumber = $message['from'] ?? ($message['wa_id'] ?? null);
|
||||||
$messageId = $message['id'] ?? ($message['message_id'] ?? null);
|
$messageId = $message['id'] ?? ($message['message_id'] ?? null);
|
||||||
$timestamp = $message['timestamp'] ?? 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
|
// Si falta lo crítico, saltar
|
||||||
if (empty($phoneNumber) || empty($messageId)) {
|
if (empty($phoneNumber) || empty($messageId)) {
|
||||||
continue;
|
continue;
|
||||||
@@ -148,7 +165,7 @@ class WhatsAppWebhook {
|
|||||||
|
|
||||||
// Obtener o crear usuario
|
// Obtener o crear usuario
|
||||||
$user = $this->getUserByPhone($phoneNumber);
|
$user = $this->getUserByPhone($phoneNumber);
|
||||||
$contactName = $contactNames[$phoneNumber] ?? null;
|
$contactName = $contactNames[$phoneNumber] ?? ($bsuid ? ($contactNames[$bsuid] ?? null) : null);
|
||||||
if (!$user) {
|
if (!$user) {
|
||||||
$userId = $this->createUser($phoneNumber);
|
$userId = $this->createUser($phoneNumber);
|
||||||
$user = $this->getUserById($userId);
|
$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
|
// Procesar diferentes tipos de mensaje
|
||||||
$messageText = '';
|
$messageText = '';
|
||||||
$messageType = 'text';
|
$messageType = 'text';
|
||||||
@@ -191,6 +222,28 @@ class WhatsAppWebhook {
|
|||||||
'reaction_emoji' => $emoji
|
'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'])) {
|
} elseif (isset($message['interactive'])) {
|
||||||
// Interactive replies (list or button) - normalize to text so bot can process
|
// Interactive replies (list or button) - normalize to text so bot can process
|
||||||
$messageType = 'text';
|
$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) {
|
private function getUserByPhone($phoneNumber) {
|
||||||
return $this->db->fetch(
|
return $this->db->fetch(
|
||||||
"SELECT * FROM users WHERE phone_number = :phone",
|
"SELECT * FROM users WHERE phone_number = :phone",
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,15 @@
|
|||||||
require_once __DIR__ . '/../../classes/Database.php';
|
require_once __DIR__ . '/../../classes/Database.php';
|
||||||
require_once __DIR__ . '/ActividadAdmin.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 {
|
class Paciente {
|
||||||
|
|
||||||
private Database $db;
|
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]);
|
$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;
|
return $ok > 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -244,10 +267,19 @@ class Paciente {
|
|||||||
[$userId]
|
[$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([
|
return $this->crear([
|
||||||
'user_id' => $userId,
|
'user_id' => $userId,
|
||||||
'nombre_completo'=> $user['name'] ?? ('Paciente ' . $user['phone_number']),
|
'nombre_completo'=> $user['name']
|
||||||
'telefono' => $user['phone_number'] ?? null,
|
?? ($esIdentificadorSinTelefono ? 'Paciente sin identificar' : 'Paciente ' . $identificador),
|
||||||
|
'telefono' => $esIdentificadorSinTelefono ? null : $identificador,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ define('SYSTEM_MODULES', [
|
|||||||
// ── Sistema ──────────────────────────────────────────────────────────────
|
// ── Sistema ──────────────────────────────────────────────────────────────
|
||||||
'usuarios' => 'Gestión de Usuarios',
|
'usuarios' => 'Gestión de Usuarios',
|
||||||
'enfermero_portal' => 'Portal Enfermero',
|
'enfermero_portal' => 'Portal Enfermero',
|
||||||
|
'soporte' => 'Soporte y Documentación',
|
||||||
// ── Oleada 1 — Turnero ───────────────────────────────────────────────────
|
// ── Oleada 1 — Turnero ───────────────────────────────────────────────────
|
||||||
'turnero' => 'Turnero',
|
'turnero' => 'Turnero',
|
||||||
// ── Oleada 2 — pendiente ─────────────────────────────────────────────────
|
// ── 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
|
* Limpia el cache estático de configuraciones
|
||||||
*/
|
*/
|
||||||
|
|||||||
+19
-14
@@ -162,25 +162,30 @@ class App
|
|||||||
$base = '/erp.php?m=turnero&v=lugar&lugar_id=';
|
$base = '/erp.php?m=turnero&v=lugar&lugar_id=';
|
||||||
try {
|
try {
|
||||||
$pdo = Database::getInstance()->getConnection();
|
$pdo = Database::getInstance()->getConnection();
|
||||||
$ip = self::clientIp();
|
// 1. Por token de navegador
|
||||||
// ¿IP registrada?
|
$tok = trim($_COOKIE['turnero_token'] ?? '');
|
||||||
$dev = $pdo->prepare(
|
if ($tok) {
|
||||||
"SELECT lugar_id FROM turnero_dispositivos WHERE ip = ? AND activo = 1 LIMIT 1"
|
$s = $pdo->prepare(
|
||||||
);
|
"SELECT lugar_id FROM turnero_dispositivos WHERE token = ? AND activo = 1 LIMIT 1"
|
||||||
$dev->execute([$ip]);
|
);
|
||||||
$row = $dev->fetch(PDO::FETCH_ASSOC);
|
$s->execute([$tok]);
|
||||||
if ($row) {
|
$row = $s->fetch(PDO::FETCH_ASSOC);
|
||||||
return $base . (int)$row['lugar_id'];
|
if ($row) return $base . (int)$row['lugar_id'];
|
||||||
}
|
}
|
||||||
// Primer lugar de toma de muestras
|
// 2. Por IP
|
||||||
|
$ip = self::clientIp();
|
||||||
|
$s = $pdo->prepare(
|
||||||
|
"SELECT lugar_id FROM turnero_dispositivos WHERE ip = ? AND token IS NULL AND activo = 1 LIMIT 1"
|
||||||
|
);
|
||||||
|
$s->execute([$ip]);
|
||||||
|
$row = $s->fetch(PDO::FETCH_ASSOC);
|
||||||
|
if ($row) return $base . (int)$row['lugar_id'];
|
||||||
|
// 3. Primer lugar de toma de muestras
|
||||||
$first = $pdo->query(
|
$first = $pdo->query(
|
||||||
"SELECT id FROM turnero_lugares WHERE activo=1 AND tipo='muestras' ORDER BY sort_order LIMIT 1"
|
"SELECT id FROM turnero_lugares WHERE activo=1 AND tipo='muestras' ORDER BY sort_order LIMIT 1"
|
||||||
)->fetch(PDO::FETCH_ASSOC);
|
)->fetch(PDO::FETCH_ASSOC);
|
||||||
if ($first) {
|
if ($first) return $base . (int)$first['id'];
|
||||||
return $base . (int)$first['id'];
|
|
||||||
}
|
|
||||||
} catch (\Throwable $_) {}
|
} catch (\Throwable $_) {}
|
||||||
// Fallback: turnero sin vista específica
|
|
||||||
return '/erp.php?m=turnero';
|
return '/erp.php?m=turnero';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,6 +23,14 @@ class Router
|
|||||||
private const PUBLIC_ROUTES = [
|
private const PUBLIC_ROUTES = [
|
||||||
'turnero/display',
|
'turnero/display',
|
||||||
'turnero/kiosko',
|
'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 */
|
/** Patrón permitido para módulo y vista: solo letras, números y guión bajo */
|
||||||
|
|||||||
+66
-6
@@ -16,11 +16,11 @@ $enfId = (int)($usuario['enfermera_id'] ?? 0);
|
|||||||
$enfermeraNombre = $usuario['full_name'] ?? $usuario['username'] ?? 'El profesional de salud';
|
$enfermeraNombre = $usuario['full_name'] ?? $usuario['username'] ?? 'El profesional de salud';
|
||||||
|
|
||||||
// Si es admin puede simular ver la agenda de otra enfermera vía ?eid=X
|
// Si es admin puede simular ver la agenda de otra enfermera vía ?eid=X
|
||||||
if ($rol === 'admin' && isset($_GET['eid'])) {
|
if (in_array($rol, ['admin', 'superadmin']) && isset($_GET['eid'])) {
|
||||||
$enfId = (int)$_GET['eid'];
|
$enfId = (int)$_GET['eid'];
|
||||||
}
|
}
|
||||||
|
|
||||||
if ($rol !== 'admin' && $rol !== 'enfermero') {
|
if (!in_array($rol, ['admin', 'superadmin', 'enfermero'])) {
|
||||||
header('Location: index.php'); exit;
|
header('Location: index.php'); exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -761,7 +761,7 @@ const portal = {
|
|||||||
? (()=>{
|
? (()=>{
|
||||||
const tel = item.paciente_telefono.replace(/\D/g,'');
|
const tel = item.paciente_telefono.replace(/\D/g,'');
|
||||||
const txt = encodeURIComponent(
|
const txt = encodeURIComponent(
|
||||||
'Hola, soy ' + ENFERMERO_NOMBRE + ', el profesional de salud asignado a su atenci\u00f3n. '+
|
'Hola, soy ' + ENFERMERO_NOMBRE + ' del Laboratorio Ximena Caicedo, el profesional de salud asignado a su atenci\u00f3n. '+
|
||||||
'Me comunico con ' + (item.paciente_nombre||'usted') + ' para confirmar la visita programada. \u00bfTiene alguna pregunta?'
|
'Me comunico con ' + (item.paciente_nombre||'usted') + ' para confirmar la visita programada. \u00bfTiene alguna pregunta?'
|
||||||
);
|
);
|
||||||
return `<a class="btn btn-outline-success btn-accion"
|
return `<a class="btn btn-outline-success btn-accion"
|
||||||
@@ -783,6 +783,11 @@ const portal = {
|
|||||||
onclick="formVer.abrir(${item.paciente_id}, '${esc(item.paciente_nombre||'')}')">
|
onclick="formVer.abrir(${item.paciente_id}, '${esc(item.paciente_nombre||'')}')">
|
||||||
<i class="fas fa-folder-open me-1"></i>Ver llenados
|
<i class="fas fa-folder-open me-1"></i>Ver llenados
|
||||||
</button>` : ''}
|
</button>` : ''}
|
||||||
|
${!['completado','cancelado'].includes(est) ? `
|
||||||
|
<button class="btn btn-accion" style="background:#0d6efd;color:#fff"
|
||||||
|
onclick="agendaNueva.abrirEditar(${item.domicilio_id})">
|
||||||
|
<i class="fas fa-pen me-1"></i>Editar
|
||||||
|
</button>` : ''}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
@@ -1452,6 +1457,7 @@ const agendaNueva = {
|
|||||||
_modal: null,
|
_modal: null,
|
||||||
_buscarTimer: null,
|
_buscarTimer: null,
|
||||||
_nuevoPac: false,
|
_nuevoPac: false,
|
||||||
|
_editId: null,
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
this._modal = new bootstrap.Modal('#modalNuevaAgenda');
|
this._modal = new bootstrap.Modal('#modalNuevaAgenda');
|
||||||
@@ -1500,12 +1506,63 @@ const agendaNueva = {
|
|||||||
document.getElementById('na-np-email').value = '';
|
document.getElementById('na-np-email').value = '';
|
||||||
document.getElementById('na-crear-toggle-txt').textContent = 'Crear nuevo paciente';
|
document.getElementById('na-crear-toggle-txt').textContent = 'Crear nuevo paciente';
|
||||||
document.getElementById('na-paciente-buscar').disabled = false;
|
document.getElementById('na-paciente-buscar').disabled = false;
|
||||||
|
this._editId = null;
|
||||||
const btn = document.getElementById('na-btn-guardar');
|
const btn = document.getElementById('na-btn-guardar');
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.innerHTML = '<i class="fas fa-calendar-check me-1"></i>Agendar';
|
btn.innerHTML = '<i class="fas fa-calendar-check me-1"></i>Agendar';
|
||||||
|
document.querySelector('#modalNuevaAgenda .modal-title').textContent = 'Nuevo domicilio';
|
||||||
this._modal.show();
|
this._modal.show();
|
||||||
},
|
},
|
||||||
|
|
||||||
|
async abrirEditar(domId) {
|
||||||
|
try {
|
||||||
|
const r = await fetch(`api/lab/get_domicilios.php?id=${domId}`);
|
||||||
|
const d = await r.json();
|
||||||
|
const dom = d.domicilio || d.data?.[0] || null;
|
||||||
|
if (!dom) { alert('No se pudo cargar el domicilio.'); return; }
|
||||||
|
|
||||||
|
this.abrir(); // resetea el form
|
||||||
|
this._editId = domId;
|
||||||
|
|
||||||
|
// Paciente
|
||||||
|
if (dom.paciente_id) {
|
||||||
|
document.getElementById('na-paciente-id').value = dom.paciente_id;
|
||||||
|
document.getElementById('na-paciente-buscar').value = dom.paciente_nombre || '';
|
||||||
|
document.getElementById('na-paciente-label').textContent = dom.paciente_nombre || '';
|
||||||
|
document.getElementById('na-paciente-elegido').classList.remove('d-none');
|
||||||
|
document.getElementById('na-sugerencias').classList.add('d-none');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Campos
|
||||||
|
document.getElementById('na-direccion').value = dom.direccion || '';
|
||||||
|
document.getElementById('na-barrio').value = dom.barrio || '';
|
||||||
|
document.getElementById('na-ciudad').value = dom.ciudad || '';
|
||||||
|
document.getElementById('na-indicaciones').value = dom.indicaciones_dir || '';
|
||||||
|
document.getElementById('na-fecha').value = dom.fecha_programada || '';
|
||||||
|
document.getElementById('na-hora').value = (dom.hora_programada || '').slice(0,5);
|
||||||
|
document.getElementById('na-notas').value = dom.notas_admin || '';
|
||||||
|
document.getElementById('na-valor-dom').value = dom.valor_domicilio || '';
|
||||||
|
document.getElementById('na-valor-cop').value = dom.valor_copago || '';
|
||||||
|
|
||||||
|
// Tipo cliente
|
||||||
|
const tc = dom.tipo_cliente === 'seguro' ? 'na-tc-seguro' : 'na-tc-particular';
|
||||||
|
const tcEl = document.getElementById(tc);
|
||||||
|
if (tcEl) { tcEl.checked = true; this._toggleSeguro(); }
|
||||||
|
if (dom.seguro_nombre) {
|
||||||
|
const sn = document.getElementById('na-seguro-nombre');
|
||||||
|
if (sn) sn.value = dom.seguro_nombre;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Botón y título
|
||||||
|
const btn = document.getElementById('na-btn-guardar');
|
||||||
|
btn.disabled = false;
|
||||||
|
btn.innerHTML = '<i class="fas fa-save me-1"></i>Guardar cambios';
|
||||||
|
document.querySelector('#modalNuevaAgenda .modal-title').textContent = 'Editar domicilio';
|
||||||
|
} catch(e) {
|
||||||
|
alert('Error al cargar domicilio: ' + e.message);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
async _buscarPacientes(q) {
|
async _buscarPacientes(q) {
|
||||||
if (q.length < 2) {
|
if (q.length < 2) {
|
||||||
document.getElementById('na-sugerencias').classList.add('d-none');
|
document.getElementById('na-sugerencias').classList.add('d-none');
|
||||||
@@ -1783,8 +1840,8 @@ const agendaNueva = {
|
|||||||
valor_domicilio: parseFloat(document.getElementById('na-valor-dom').value) || null,
|
valor_domicilio: parseFloat(document.getElementById('na-valor-dom').value) || null,
|
||||||
valor_copago: parseFloat(document.getElementById('na-valor-cop').value) || null,
|
valor_copago: parseFloat(document.getElementById('na-valor-cop').value) || null,
|
||||||
notas_admin: document.getElementById('na-notas').value.trim() || null,
|
notas_admin: document.getElementById('na-notas').value.trim() || null,
|
||||||
estado: 'programado',
|
...(this._editId ? { id: this._editId } : { estado: 'programado' }),
|
||||||
...(ENFERMERA_ID ? { enfermera_id: ENFERMERA_ID } : {}),
|
...(ENFERMERA_ID && !this._editId ? { enfermera_id: ENFERMERA_ID } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -1814,10 +1871,13 @@ const agendaNueva = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this._modal.hide();
|
this._modal.hide();
|
||||||
|
this._editId = null;
|
||||||
portal.cargar();
|
portal.cargar();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
btn.disabled = false;
|
btn.disabled = false;
|
||||||
btn.innerHTML = '<i class="fas fa-calendar-check me-1"></i>Agendar';
|
btn.innerHTML = this._editId
|
||||||
|
? '<i class="fas fa-save me-1"></i>Guardar cambios'
|
||||||
|
: '<i class="fas fa-calendar-check me-1"></i>Agendar';
|
||||||
this._mostrarError(e.message);
|
this._mostrarError(e.message);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
+49
-40
@@ -329,8 +329,9 @@ if (preg_match('/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/
|
|||||||
</div>
|
</div>
|
||||||
<div class="topaz-modal-body">
|
<div class="topaz-modal-body">
|
||||||
<div class="topaz-pad-area" id="topaz-pad-area">
|
<div class="topaz-pad-area" id="topaz-pad-area">
|
||||||
<i class="fas fa-signature fa-2x" style="color:#adb5bd" id="topaz-pad-icon"></i>
|
<canvas id="topaz-canvas" width="500" height="150"
|
||||||
<div id="topaz-status-msg" style="font-size:.9rem;color:#64748b;font-weight:600">
|
style="border:1px solid #e2e8f0;border-radius:6px;background:#fff;max-width:100%;display:block;margin:0 auto"></canvas>
|
||||||
|
<div id="topaz-status-msg" style="font-size:.9rem;color:#64748b;font-weight:600;margin-top:8px">
|
||||||
Firme en el pad biométrico
|
Firme en el pad biométrico
|
||||||
</div>
|
</div>
|
||||||
<div class="topaz-pts-badge">Trazos: <span id="topaz-pts">0</span></div>
|
<div class="topaz-pts-badge">Trazos: <span id="topaz-pts">0</span></div>
|
||||||
@@ -597,12 +598,12 @@ function renderCampos(esquema, prefilled) {
|
|||||||
</div>` + wrapClose;
|
</div>` + wrapClose;
|
||||||
} else if (c.tipo === 'radio') {
|
} else if (c.tipo === 'radio') {
|
||||||
const opts = (c.options||[]).map(o => `
|
const opts = (c.options||[]).map(o => `
|
||||||
<div class="form-check">
|
<div class="form-check form-check-inline me-3">
|
||||||
<input class="form-check-input" type="radio" name="${c.id}" value="${esc(o)}"
|
<input class="form-check-input" type="radio" name="${c.id}" value="${esc(o)}"
|
||||||
${o===val?'checked':''} ${isReadOnly?'disabled':''}>
|
${o===val?'checked':''} ${isReadOnly?'disabled':''}>
|
||||||
<label class="form-check-label">${esc(o)}</label>
|
<label class="form-check-label">${esc(o)}</label>
|
||||||
</div>`).join('');
|
</div>`).join('');
|
||||||
html += wrapOpen + `<div class="mb-3">${lbl}${opts}${linkedNote}</div>` + wrapClose;
|
html += wrapOpen + `<div class="mb-3">${lbl}<div class="d-flex flex-wrap gap-1">${opts}</div>${linkedNote}</div>` + wrapClose;
|
||||||
} else if (c.tipo === 'checkbox') {
|
} else if (c.tipo === 'checkbox') {
|
||||||
const vals = Array.isArray(val) ? val : [];
|
const vals = Array.isArray(val) ? val : [];
|
||||||
const opts = (c.options||[]).map(o => `
|
const opts = (c.options||[]).map(o => `
|
||||||
@@ -1233,7 +1234,8 @@ function iniciarCondiciones(esquema) {
|
|||||||
} else {
|
} else {
|
||||||
const radio = ctrl.querySelector(`input[type="radio"]:checked`);
|
const radio = ctrl.querySelector(`input[type="radio"]:checked`);
|
||||||
const sel = ctrl.querySelector('select');
|
const sel = ctrl.querySelector('select');
|
||||||
const v = radio ? radio.value : (sel ? sel.value : '');
|
const inp = ctrl.querySelector('input[type="text"],input[type="hidden"]');
|
||||||
|
const v = radio ? radio.value : (sel ? sel.value : (inp ? inp.value : ''));
|
||||||
activo = valoresCond.includes(v);
|
activo = valoresCond.includes(v);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1270,17 +1272,8 @@ function iniciarCondiciones(esquema) {
|
|||||||
// TOPAZ SIGWEB
|
// TOPAZ SIGWEB
|
||||||
// ══════════════════════════════════════════════════════════════════════
|
// ══════════════════════════════════════════════════════════════════════
|
||||||
const topaz = (() => {
|
const topaz = (() => {
|
||||||
const SIGWEB_URL = 'http://localhost:47289/SigWeb/SigWebTablet.js';
|
const SIGWEB_URL = '/assets/js/SigWebTablet.js';
|
||||||
let _loaded = false, _ctx = null, _fid = null, _poll = null;
|
let _loaded = false, _fid = null, _poll = null, _tmr = null;
|
||||||
|
|
||||||
function _call(fn, ...args) {
|
|
||||||
// SigWeb v1: global functions with ctx as last param
|
|
||||||
// SigWeb v2: methods on ctx object
|
|
||||||
if (_ctx && typeof _ctx[fn] === 'function') return _ctx[fn](...args);
|
|
||||||
const g = window[fn];
|
|
||||||
if (typeof g === 'function') return _ctx ? g(...args, _ctx) : g(...args);
|
|
||||||
throw new Error('SigWeb: ' + fn + ' no encontrado');
|
|
||||||
}
|
|
||||||
|
|
||||||
function _loadScript() {
|
function _loadScript() {
|
||||||
if (_loaded) return Promise.resolve(true);
|
if (_loaded) return Promise.resolve(true);
|
||||||
@@ -1312,12 +1305,14 @@ const topaz = (() => {
|
|||||||
}
|
}
|
||||||
_fid = fid;
|
_fid = fid;
|
||||||
try {
|
try {
|
||||||
_ctx = typeof SigWebTablet !== 'undefined' ? new SigWebTablet() : null;
|
const canvas = document.getElementById('topaz-canvas');
|
||||||
_call('SetImageXSize', 500);
|
const canvasCtx = canvas.getContext('2d');
|
||||||
_call('SetImageYSize', 150);
|
canvasCtx.clearRect(0, 0, canvas.width, canvas.height);
|
||||||
_call('SetImagePenWidth', 3);
|
SetImageXSize(500);
|
||||||
_call('SetTabletState', 1);
|
SetImageYSize(150);
|
||||||
_call('ClearTablet');
|
SetImagePenWidth(3);
|
||||||
|
ClearTablet();
|
||||||
|
_tmr = SetTabletState(1, canvasCtx, 50);
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
alert('Error al activar el pad: ' + e.message);
|
alert('Error al activar el pad: ' + e.message);
|
||||||
return;
|
return;
|
||||||
@@ -1327,13 +1322,30 @@ const topaz = (() => {
|
|||||||
$('topaz-pts').textContent = '0';
|
$('topaz-pts').textContent = '0';
|
||||||
_setStatus('Firme en el pad biométrico', false);
|
_setStatus('Firme en el pad biométrico', false);
|
||||||
|
|
||||||
|
let _lastPts = -1;
|
||||||
_poll = setInterval(() => {
|
_poll = setInterval(() => {
|
||||||
try {
|
try {
|
||||||
const pts = _call('GetSigTotalPoints');
|
const pts = NumberOfTabletPoints();
|
||||||
$('topaz-pts').textContent = pts;
|
$('topaz-pts').textContent = pts;
|
||||||
const hasSig = pts > 0;
|
const hasSig = pts > 0;
|
||||||
$('topaz-btn-aceptar').disabled = !hasSig;
|
$('topaz-btn-aceptar').disabled = !hasSig;
|
||||||
if (hasSig) _setStatus('✅ Firma detectada — presione Aceptar', true);
|
if (hasSig) {
|
||||||
|
_setStatus('✅ Firma detectada — presione Aceptar', true);
|
||||||
|
if (pts !== _lastPts) {
|
||||||
|
_lastPts = pts;
|
||||||
|
GetSigImageB64(function(b64) {
|
||||||
|
if (!b64) return;
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
const cv = document.getElementById('topaz-canvas');
|
||||||
|
const cx = cv.getContext('2d');
|
||||||
|
cx.clearRect(0, 0, cv.width, cv.height);
|
||||||
|
cx.drawImage(img, 0, 0, cv.width, cv.height);
|
||||||
|
};
|
||||||
|
img.src = 'data:image/png;base64,' + b64;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch(e) { _stopPoll(); }
|
} catch(e) { _stopPoll(); }
|
||||||
}, 400);
|
}, 400);
|
||||||
}
|
}
|
||||||
@@ -1342,8 +1354,8 @@ const topaz = (() => {
|
|||||||
|
|
||||||
function _cerrarOverlay() {
|
function _cerrarOverlay() {
|
||||||
_stopPoll();
|
_stopPoll();
|
||||||
try { if (_ctx) _call('SetTabletState', 0); } catch(e) {}
|
try { SetTabletState(0, _tmr); } catch(e) {}
|
||||||
_ctx = null; _fid = null;
|
_tmr = null; _fid = null;
|
||||||
$('topaz-overlay').style.display = 'none';
|
$('topaz-overlay').style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1357,18 +1369,13 @@ const topaz = (() => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function aceptar() {
|
function aceptar() {
|
||||||
let b64;
|
const fid = _fid;
|
||||||
try { b64 = _call('GetSigImageB64'); } catch(e) {
|
_cerrarOverlay();
|
||||||
alert('Error al capturar la firma: ' + e.message);
|
try {
|
||||||
_cerrarOverlay(); return;
|
GetSigImageB64(function(b64) {
|
||||||
}
|
if (!b64) { alert('No se capturó ninguna firma.'); return; }
|
||||||
if (!b64) { alert('No se capturó ninguna firma.'); return; }
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
const img = new Image();
|
|
||||||
img.onload = () => {
|
|
||||||
const fid = _fid;
|
|
||||||
_cerrarOverlay();
|
|
||||||
|
|
||||||
if (fid === '__global') {
|
if (fid === '__global') {
|
||||||
const c = $('firma-canvas');
|
const c = $('firma-canvas');
|
||||||
const cx = c.getContext('2d');
|
const cx = c.getContext('2d');
|
||||||
@@ -1391,8 +1398,10 @@ const topaz = (() => {
|
|||||||
const st = document.getElementById('fw-' + fid + '-status');
|
const st = document.getElementById('fw-' + fid + '-status');
|
||||||
if (st) { st.textContent = '✅ Firmado (tableta)'; st.className = 'small text-success align-self-center fw-semibold'; }
|
if (st) { st.textContent = '✅ Firmado (tableta)'; st.className = 'small text-success align-self-center fw-semibold'; }
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
img.src = 'data:image/png;base64,' + b64;
|
img.src = 'data:image/png;base64,' + b64;
|
||||||
|
});
|
||||||
|
} catch(e) { alert('Error al capturar la firma: ' + e.message); }
|
||||||
}
|
}
|
||||||
|
|
||||||
return { activar, cancelar, limpiarPad, aceptar };
|
return { activar, cancelar, limpiarPad, aceptar };
|
||||||
|
|||||||
@@ -19,9 +19,16 @@ if (isEnfermero()) {
|
|||||||
}
|
}
|
||||||
$_indexRole = $_SESSION['admin_user']['role'] ?? '';
|
$_indexRole = $_SESSION['admin_user']['role'] ?? '';
|
||||||
$_indexModules = $_SESSION['admin_user']['modules'] ?? [];
|
$_indexModules = $_SESSION['admin_user']['modules'] ?? [];
|
||||||
if ($_indexRole === 'recepcionista' || (in_array('turnero', $_indexModules, true) && !in_array('whatsapp', $_indexModules, true))) {
|
if ($_indexRole === 'recepcionista') {
|
||||||
header('Location: erp.php?m=turnero&v=recepcion'); exit;
|
header('Location: erp.php?m=turnero&v=recepcion'); exit;
|
||||||
}
|
}
|
||||||
|
if (in_array('turnero', $_indexModules, true) && !in_array('whatsapp', $_indexModules, true)) {
|
||||||
|
$_indexAdminRoles = ['superadmin', 'admin', 'supervisor', 'bacteriologo'];
|
||||||
|
$url = in_array($_indexRole, $_indexAdminRoles, true)
|
||||||
|
? 'erp.php?m=turnero&v=dashboard'
|
||||||
|
: 'erp.php?m=turnero&v=recepcion';
|
||||||
|
header('Location: ' . $url); exit;
|
||||||
|
}
|
||||||
if (in_array('lab_dashboard', $_indexModules, true) && !in_array('whatsapp', $_indexModules, true)) {
|
if (in_array('lab_dashboard', $_indexModules, true) && !in_array('whatsapp', $_indexModules, true)) {
|
||||||
header('Location: lab_dashboard.php'); exit;
|
header('Location: lab_dashboard.php'); exit;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -70,6 +70,8 @@ require_once __DIR__ . '/shared/components/sidebar.php';
|
|||||||
<option value="enfermeras">Enfermeras</option>
|
<option value="enfermeras">Enfermeras</option>
|
||||||
<option value="formularios">Formularios</option>
|
<option value="formularios">Formularios</option>
|
||||||
<option value="asignaciones">Asignaciones</option>
|
<option value="asignaciones">Asignaciones</option>
|
||||||
|
<option value="turnero">Turnero</option>
|
||||||
|
<option value="turnero_config">Turnero · configuración</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
@@ -85,6 +87,11 @@ require_once __DIR__ . '/shared/components/sidebar.php';
|
|||||||
<option value="enviar">enviar</option>
|
<option value="enviar">enviar</option>
|
||||||
<option value="reprogramar">reprogramar</option>
|
<option value="reprogramar">reprogramar</option>
|
||||||
<option value="cambiar_estado">cambiar_estado</option>
|
<option value="cambiar_estado">cambiar_estado</option>
|
||||||
|
<option value="quitar_consentimiento">quitar_consentimiento</option>
|
||||||
|
<option value="resetear_consentimiento">resetear_consentimiento</option>
|
||||||
|
<option value="resetear_toma">resetear_toma</option>
|
||||||
|
<option value="cancelar_toma_pendiente">cancelar_toma_pendiente</option>
|
||||||
|
<option value="vincular_paciente">vincular_paciente</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col">
|
<div class="col">
|
||||||
@@ -104,6 +111,18 @@ require_once __DIR__ . '/shared/components/sidebar.php';
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tabla -->
|
<!-- Tabla -->
|
||||||
|
<!-- Salud de la voz del televisor: alimentada por turnero_tv_log.
|
||||||
|
Responde de un vistazo «¿la pantalla está llamando bien?» sin
|
||||||
|
tener que consultar la base a mano. -->
|
||||||
|
<div class="card border-0 shadow-sm mb-3" id="card-voz-tv" style="display:none">
|
||||||
|
<div class="card-body py-3">
|
||||||
|
<h6 class="mb-2"><i class="fas fa-volume-up text-primary me-1"></i>
|
||||||
|
Voz del televisor — últimos 7 días</h6>
|
||||||
|
<div class="d-flex gap-4 flex-wrap mb-2" id="voz-tv-resumen"></div>
|
||||||
|
<div id="voz-tv-incidentes" class="small"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="card border-0 shadow-sm">
|
<div class="card border-0 shadow-sm">
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
<div id="act-spinner" class="text-center py-5 text-muted">
|
<div id="act-spinner" class="text-center py-5 text-muted">
|
||||||
@@ -270,6 +289,53 @@ function verDetalle(i) {
|
|||||||
new bootstrap.Modal(document.getElementById('modalDetalle')).show();
|
new bootstrap.Modal(document.getElementById('modalDetalle')).show();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Salud de la voz del televisor ────────────────────────────
|
||||||
|
const VOZ_EVENTO_TXT = {
|
||||||
|
mudo: ['🔇 No sonó', 'text-danger', 'El llamado salió sin voz: ni el reintento habló'],
|
||||||
|
sin_start: ['⚠️ Arrancó con reintento','text-warning','La voz elegida no arrancó; habló la del navegador'],
|
||||||
|
error: ['⚠️ Error del motor', 'text-warning', 'El motor de voz falló a mitad; se reintentó'],
|
||||||
|
lag: ['⏸ Pantalla congelada', 'text-warning', 'La pestaña estuvo oculta o el equipo suspendido: en ese lapso no ve llamados. En el televisor debe estar siempre visible'],
|
||||||
|
};
|
||||||
|
|
||||||
|
async function cargarVozTV() {
|
||||||
|
try {
|
||||||
|
const r = await fetch('api/lab/get_tv_log.php');
|
||||||
|
const d = await r.json();
|
||||||
|
if (!d.ok) return;
|
||||||
|
// Sin datos todavía (recién desplegado): la tarjeta no aparece
|
||||||
|
if (!d.anuncios_7d && !d.incidentes.length) return;
|
||||||
|
|
||||||
|
document.getElementById('card-voz-tv').style.display = '';
|
||||||
|
const pct = d.anuncios_7d ? Math.round(d.completos_7d / d.anuncios_7d * 100) : 0;
|
||||||
|
document.getElementById('voz-tv-resumen').innerHTML = `
|
||||||
|
<div><span class="fw-bold fs-5">${d.anuncios_7d}</span>
|
||||||
|
<span class="text-muted small">llamados con voz</span></div>
|
||||||
|
<div><span class="fw-bold fs-5 ${pct >= 97 ? 'text-success' : 'text-warning'}">${pct}%</span>
|
||||||
|
<span class="text-muted small">hablados completos</span></div>
|
||||||
|
<div><span class="fw-bold fs-5 ${d.mudos_7d ? 'text-danger' : 'text-success'}">${d.mudos_7d}</span>
|
||||||
|
<span class="text-muted small">salieron mudos</span></div>
|
||||||
|
<div><span class="fw-bold fs-5 ${d.avisos_7d ? 'text-warning' : 'text-success'}">${d.avisos_7d}</span>
|
||||||
|
<span class="text-muted small">se recuperaron con reintento</span></div>`;
|
||||||
|
|
||||||
|
const inc = document.getElementById('voz-tv-incidentes');
|
||||||
|
if (!d.incidentes.length) {
|
||||||
|
inc.innerHTML = '<span class="text-success"><i class="fas fa-check-circle me-1"></i>Sin fallos registrados</span>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
inc.innerHTML = '<div class="text-muted mb-1">Últimos fallos:</div>' +
|
||||||
|
d.incidentes.slice(0, 10).map(i => {
|
||||||
|
const [txt, cls, ayuda] = VOZ_EVENTO_TXT[i.evento] || [i.evento, '', ''];
|
||||||
|
return `<div class="py-1 border-bottom" title="${ayuda}">
|
||||||
|
<span class="text-muted">${(i.cuando || '').slice(0, 16)}</span>
|
||||||
|
· turno <b>${i.turno || '—'}</b>
|
||||||
|
· <span class="${cls}">${txt}</span>
|
||||||
|
${i.voz ? `<span class="text-muted"> (voz: ${i.voz})</span>` : ''}
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
cargarVozTV();
|
||||||
|
|
||||||
async function exportarCSV() {
|
async function exportarCSV() {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
desde: document.getElementById('act-desde').value,
|
desde: document.getElementById('act-desde').value,
|
||||||
|
|||||||
+9
-3
@@ -1614,10 +1614,16 @@ async function ffExportarExcel() {
|
|||||||
...fieldCols.map(([l,d])=>{ const v=todos[lmap.get(l)||d]; return v==null?'':(Array.isArray(v)?v.join('; '):String(v)); }),
|
...fieldCols.map(([l,d])=>{ const v=todos[lmap.get(l)||d]; return v==null?'':(Array.isArray(v)?v.join('; '):String(v)); }),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
const csv = '\uFEFF' + csvRows.map(r=>r.map(v=>'"'+String(v).replace(/"/g,'""')+'"').join(',')).join('\r\n');
|
const xlsEsc = v => String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||||
const blob = new Blob([csv],{type:'text/csv;charset=utf-8;'});
|
const toCell = v => `<Cell><Data ss:Type="String">${xlsEsc(v)}</Data></Cell>`;
|
||||||
|
const xml = '<' + '?xml version="1.0" encoding="UTF-8"?>\n<' + '?mso-application progid="Excel.Sheet"?>\n'
|
||||||
|
+ `<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet" xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">`
|
||||||
|
+ `<Worksheet ss:Name="Domicilios"><Table>\n`
|
||||||
|
+ csvRows.map(r => `<Row>${r.map(toCell).join('')}</Row>`).join('\n')
|
||||||
|
+ `\n</Table></Worksheet></Workbook>`;
|
||||||
|
const blob = new Blob([xml], {type:'application/vnd.ms-excel;charset=utf-8'});
|
||||||
const url = URL.createObjectURL(blob);
|
const url = URL.createObjectURL(blob);
|
||||||
const a = Object.assign(document.createElement('a'),{href:url,download:'formularios_domicilios_'+new Date().toISOString().slice(0,10)+'.csv'});
|
const a = Object.assign(document.createElement('a'),{href:url,download:'formularios_domicilios_'+new Date().toISOString().slice(0,10)+'.xls'});
|
||||||
document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url);
|
document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url);
|
||||||
mostrarToast('✅ Excel descargado — ' + rows.length + ' registro(s)', 'success');
|
mostrarToast('✅ Excel descargado — ' + rows.length + ' registro(s)', 'success');
|
||||||
}
|
}
|
||||||
|
|||||||
+35
-3
@@ -472,6 +472,28 @@ function fmtFecha(str) {
|
|||||||
return str.slice(0, 10).split('-').reverse().join('/');
|
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) {
|
async function cargarLista(pag = 1) {
|
||||||
paginaActual = pag;
|
paginaActual = pag;
|
||||||
const busq = document.getElementById('buscador').value.trim();
|
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>` : ''}
|
${p.genero ? `<small class="text-muted">${p.genero==='M'?'Masculino':p.genero==='F'?'Femenino':'Otro'}</small>` : ''}
|
||||||
</td>
|
</td>
|
||||||
<td class="small">${tipoDocLabel(p.tipo_documento)}<br><span class="fw-semibold">${esc(p.numero_documento||'—')}</span></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.email||'—')}</td>
|
||||||
<td class="small text-muted">${esc(p.ciudad||'—')}</td>
|
<td class="small text-muted">${esc(p.ciudad||'—')}</td>
|
||||||
<td class="small text-muted">${esc(p.eps||'—')}</td>
|
<td class="small text-muted">${esc(p.eps||'—')}</td>
|
||||||
@@ -546,8 +568,18 @@ async function verDetalle(id) {
|
|||||||
document.getElementById('detail-body').innerHTML = `
|
document.getElementById('detail-body').innerHTML = `
|
||||||
<dl class="row small mb-3">
|
<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">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">Teléfono</dt><dd class="col-7">${
|
||||||
<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>
|
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">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>
|
<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>` : ''}
|
${p.email ? `<dt class="col-5 text-muted">Email</dt><dd class="col-7">${esc(p.email)}</dd>` : ''}
|
||||||
|
|||||||
+14
-10
@@ -35,7 +35,7 @@ if ($tipo) {
|
|||||||
ORDER BY o.created_at DESC",
|
ORDER BY o.created_at DESC",
|
||||||
[$desde, $hasta]
|
[$desde, $hasta]
|
||||||
);
|
);
|
||||||
$filename = "ordenes_$desde\_$hasta.csv";
|
$filename = "ordenes_$desde\_$hasta.xls";
|
||||||
$headers = ['ID','Paciente','Documento','EPS','Estado','Médico','Fecha Orden','Exámenes','Ayuno','H.Ayuno','Creada','Revisada por','Autorizada por'];
|
$headers = ['ID','Paciente','Documento','EPS','Estado','Médico','Fecha Orden','Exámenes','Ayuno','H.Ayuno','Creada','Revisada por','Autorizada por'];
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -109,7 +109,7 @@ if ($tipo) {
|
|||||||
$sqlParams
|
$sqlParams
|
||||||
);
|
);
|
||||||
$rangoLabel = !empty($_GET['fecha']) ? $_GET['fecha'] : "{$desde}_{$hasta}";
|
$rangoLabel = !empty($_GET['fecha']) ? $_GET['fecha'] : "{$desde}_{$hasta}";
|
||||||
$filename = "domicilios_{$rangoLabel}.csv";
|
$filename = "domicilios_{$rangoLabel}.xls";
|
||||||
$headers = [
|
$headers = [
|
||||||
'ID','Paciente','Documento','Teléfono','EPS',
|
'ID','Paciente','Documento','Teléfono','EPS',
|
||||||
'Fecha','Hora programada','Hora llegada','Hora salida','Duración',
|
'Fecha','Hora programada','Hora llegada','Hora salida','Duración',
|
||||||
@@ -133,7 +133,7 @@ if ($tipo) {
|
|||||||
ORDER BY p.nombre_completo",
|
ORDER BY p.nombre_completo",
|
||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
$filename = "pacientes_" . date('Y-m-d') . ".csv";
|
$filename = "pacientes_" . date('Y-m-d') . ".xls";
|
||||||
$headers = ['ID','Nombre','Tipo Doc.','Documento','Teléfono','Email','EPS','Ciudad','Barrio','Activo','Total Órdenes','Registrado'];
|
$headers = ['ID','Nombre','Tipo Doc.','Documento','Teléfono','Email','EPS','Ciudad','Barrio','Activo','Total Órdenes','Registrado'];
|
||||||
break;
|
break;
|
||||||
|
|
||||||
@@ -191,7 +191,7 @@ if ($tipo) {
|
|||||||
ORDER BY d.fecha_programada ASC, d.id ASC",
|
ORDER BY d.fecha_programada ASC, d.id ASC",
|
||||||
$pParams
|
$pParams
|
||||||
);
|
);
|
||||||
$filename = "reporte_pagos_{$rangoNombre}.csv";
|
$filename = "reporte_pagos_{$rangoNombre}.xls";
|
||||||
$headers = [
|
$headers = [
|
||||||
'ID','Fecha programada','Fecha pago','Paciente','Documento',
|
'ID','Fecha programada','Fecha pago','Paciente','Documento',
|
||||||
'Enfermero/a','Tipo cliente','Seguro','Estado domicilio',
|
'Enfermero/a','Tipo cliente','Seguro','Estado domicilio',
|
||||||
@@ -204,17 +204,21 @@ if ($tipo) {
|
|||||||
exit('Tipo de exportación no válido');
|
exit('Tipo de exportación no válido');
|
||||||
}
|
}
|
||||||
|
|
||||||
header('Content-Type: text/csv; charset=utf-8');
|
header('Content-Type: application/vnd.ms-excel; charset=utf-8');
|
||||||
header("Content-Disposition: attachment; filename=\"$filename\"");
|
header("Content-Disposition: attachment; filename=\"$filename\"");
|
||||||
header('Pragma: no-cache');
|
header('Pragma: no-cache');
|
||||||
|
|
||||||
$f = fopen('php://output', 'w');
|
$esc = fn($v) => htmlspecialchars((string)$v, ENT_XML1, 'UTF-8');
|
||||||
fputs($f, "\xEF\xBB\xBF"); // BOM UTF-8 para Excel
|
echo '<' . '?xml version="1.0" encoding="UTF-8"?' . '>' . "\n";
|
||||||
fputcsv($f, $headers);
|
echo '<' . '?mso-application progid="Excel.Sheet"?' . '>' . "\n";
|
||||||
|
echo '<Workbook xmlns="urn:schemas-microsoft-com:office:spreadsheet"' . "\n";
|
||||||
|
echo ' xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet">' . "\n";
|
||||||
|
echo '<Worksheet ss:Name="Reporte"><Table>' . "\n";
|
||||||
|
echo '<Row>' . implode('', array_map(fn($h) => '<Cell><Data ss:Type="String">' . $esc($h) . '</Data></Cell>', $headers)) . '</Row>' . "\n";
|
||||||
foreach ($rows as $row) {
|
foreach ($rows as $row) {
|
||||||
fputcsv($f, array_values($row));
|
echo '<Row>' . implode('', array_map(fn($v) => '<Cell><Data ss:Type="String">' . $esc($v) . '</Data></Cell>', array_values($row))) . '</Row>' . "\n";
|
||||||
}
|
}
|
||||||
fclose($f);
|
echo '</Table></Worksheet></Workbook>';
|
||||||
exit;
|
exit;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -41,16 +41,31 @@ if ($_POST && !$loginBlocked) {
|
|||||||
$_SESSION['login_ip'] = $clientIp;
|
$_SESSION['login_ip'] = $clientIp;
|
||||||
$_SESSION['login_time'] = time();
|
$_SESSION['login_time'] = time();
|
||||||
|
|
||||||
// Tablet fija: redirigir al lugar asignado por IP y bloquear el resto
|
// Tablet fija: redirigir al lugar asignado por token o IP
|
||||||
try {
|
try {
|
||||||
$_dispStmt = Database::getInstance()->getConnection()->prepare(
|
$_pdo2 = Database::getInstance()->getConnection();
|
||||||
"SELECT td.lugar_id, td.nombre, tl.tipo
|
$_disp = null;
|
||||||
FROM turnero_dispositivos td
|
$_devToken = trim($_COOKIE['turnero_token'] ?? '');
|
||||||
JOIN turnero_lugares tl ON tl.id = td.lugar_id
|
if ($_devToken) {
|
||||||
WHERE td.ip = ? AND td.activo = 1 LIMIT 1"
|
$_s = $_pdo2->prepare(
|
||||||
);
|
"SELECT td.lugar_id, td.nombre, tl.tipo
|
||||||
$_dispStmt->execute([$clientIp]);
|
FROM turnero_dispositivos td
|
||||||
$_disp = $_dispStmt->fetch(PDO::FETCH_ASSOC);
|
JOIN turnero_lugares tl ON tl.id = td.lugar_id
|
||||||
|
WHERE td.token = ? AND td.activo = 1 LIMIT 1"
|
||||||
|
);
|
||||||
|
$_s->execute([$_devToken]);
|
||||||
|
$_disp = $_s->fetch(PDO::FETCH_ASSOC) ?: null;
|
||||||
|
}
|
||||||
|
if (!$_disp) {
|
||||||
|
$_s = $_pdo2->prepare(
|
||||||
|
"SELECT td.lugar_id, td.nombre, tl.tipo
|
||||||
|
FROM turnero_dispositivos td
|
||||||
|
JOIN turnero_lugares tl ON tl.id = td.lugar_id
|
||||||
|
WHERE td.ip = ? AND td.activo = 1 LIMIT 1"
|
||||||
|
);
|
||||||
|
$_s->execute([$clientIp]);
|
||||||
|
$_disp = $_s->fetch(PDO::FETCH_ASSOC) ?: null;
|
||||||
|
}
|
||||||
if ($_disp) {
|
if ($_disp) {
|
||||||
$_SESSION['turnero_dispositivo'] = $_disp;
|
$_SESSION['turnero_dispositivo'] = $_disp;
|
||||||
$url = $_disp['tipo'] === 'recepcion'
|
$url = $_disp['tipo'] === 'recepcion'
|
||||||
@@ -89,7 +104,13 @@ if ($_POST && !$loginBlocked) {
|
|||||||
if ($roleSlug === 'enfermero') {
|
if ($roleSlug === 'enfermero') {
|
||||||
header('Location: enfermero_portal.php');
|
header('Location: enfermero_portal.php');
|
||||||
} elseif (in_array('turnero', $modules, true)) {
|
} elseif (in_array('turnero', $modules, true)) {
|
||||||
header('Location: erp.php?m=turnero&v=recepcion');
|
if ($roleSlug === 'bacteriologo') {
|
||||||
|
header('Location: erp.php?m=turnero&v=dashboard');
|
||||||
|
} elseif (in_array($roleSlug, ['superadmin', 'admin', 'supervisor'], true)) {
|
||||||
|
header('Location: erp.php?m=turnero&v=dashboard');
|
||||||
|
} else {
|
||||||
|
header('Location: erp.php?m=turnero&v=recepcion');
|
||||||
|
}
|
||||||
} elseif (in_array('lab_dashboard', $modules, true)) {
|
} elseif (in_array('lab_dashboard', $modules, true)) {
|
||||||
header('Location: lab_dashboard.php');
|
header('Location: lab_dashboard.php');
|
||||||
} elseif (in_array('lab_formularios', $modules, true)) {
|
} elseif (in_array('lab_formularios', $modules, true)) {
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
-- 20260803 — Cambios de esquema del turnero aplicados el 2026-08-03
|
||||||
|
-- Ya ejecutados en producción; este archivo los deja registrados para
|
||||||
|
-- reconstruir el esquema desde cero o replicarlo en otro entorno.
|
||||||
|
-- Todos usan IF NOT EXISTS para ser idempotentes.
|
||||||
|
|
||||||
|
-- 1. Cédula del profesional firmante (antes solo existía para enfermeros
|
||||||
|
-- vía lab_enfermeras; bacteriólogos y recepcionistas no tenían dónde).
|
||||||
|
ALTER TABLE admin_users
|
||||||
|
ADD COLUMN IF NOT EXISTS cedula VARCHAR(30) NULL AFTER cargo;
|
||||||
|
|
||||||
|
-- Poblar desde el username cuando es numérico (así estaban registrados
|
||||||
|
-- bacteriólogos, recepcionistas y superadmin).
|
||||||
|
UPDATE admin_users SET cedula = username
|
||||||
|
WHERE cedula IS NULL AND username REGEXP '^[0-9]{5,15}$' AND is_active = 1;
|
||||||
|
|
||||||
|
-- 2. Quién generó cada consentimiento del turnero. Sin esto el pie
|
||||||
|
-- "Enviado por" mostraba al usuario que estuviera viendo el documento.
|
||||||
|
ALTER TABLE turnero_consentimientos
|
||||||
|
ADD COLUMN IF NOT EXISTS creado_por INT NULL AFTER estado;
|
||||||
|
|
||||||
|
-- 3. En qué turno se recibió una muestra que quedó pendiente de otra visita.
|
||||||
|
-- El turno original no se modifica; esto solo permite vincularlos.
|
||||||
|
ALTER TABLE turnero_muestras
|
||||||
|
ADD COLUMN IF NOT EXISTS recibida_en_turno_id INT UNSIGNED NULL AFTER recibida_at;
|
||||||
|
|
||||||
|
-- 4. Playlist de la Pantalla TV (reemplaza la clave única turnero_tv_video
|
||||||
|
-- de lab_config por una lista ordenable de videos e imágenes).
|
||||||
|
CREATE TABLE IF NOT EXISTS turnero_tv_media (
|
||||||
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
tipo ENUM('video','imagen') NOT NULL,
|
||||||
|
url VARCHAR(500) NOT NULL,
|
||||||
|
orden INT NOT NULL DEFAULT 0,
|
||||||
|
duracion_segundos INT NOT NULL DEFAULT 8,
|
||||||
|
activo TINYINT(1) NOT NULL DEFAULT 1,
|
||||||
|
creado_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_orden (activo, orden)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
|
|
||||||
|
-- Migrar el video único que existía en lab_config como primer ítem.
|
||||||
|
INSERT INTO turnero_tv_media (tipo, url, orden, duracion_segundos, activo)
|
||||||
|
SELECT 'video', valor, 0, 8, 1 FROM lab_config
|
||||||
|
WHERE clave = 'turnero_tv_video' AND valor <> ''
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM turnero_tv_media);
|
||||||
@@ -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
|
||||||
|
);
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
-- 20260823_tv_voz_log.sql
|
||||||
|
--
|
||||||
|
-- Bitácora de la voz del televisor. Los fallos son intermitentes ("a veces no
|
||||||
|
-- suena, a veces entrecortado") y nadie puede depurarlos mirando la pantalla:
|
||||||
|
-- hay que registrar cada intento de hablar CUANDO ocurre, con qué voz, si
|
||||||
|
-- arrancó, cuánto tardó y cómo terminó. Con esto, la próxima vez que reporten
|
||||||
|
-- "ayer a las 10 no sonó" se consulta esta tabla y se ve exactamente qué pasó.
|
||||||
|
--
|
||||||
|
-- La escribe modules/turnero/api/log_tv.php vía sendBeacon desde la pantalla.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS turnero_tv_log (
|
||||||
|
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||||
|
evento VARCHAR(20) NOT NULL COMMENT 'start, end, error, sin_start, mudo',
|
||||||
|
detalle VARCHAR(500) NULL COMMENT 'JSON: código del turno, voz, ms, duración',
|
||||||
|
ip VARCHAR(45) NULL,
|
||||||
|
user_agent VARCHAR(255) NULL,
|
||||||
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
PRIMARY KEY (id),
|
||||||
|
KEY idx_evento_fecha (evento, created_at)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
/**
|
||||||
|
* GET ?id=N — Detalle de un examen del catálogo con sus ítems.
|
||||||
|
*/
|
||||||
require_once __DIR__ . '/_helpers.php';
|
require_once __DIR__ . '/_helpers.php';
|
||||||
requireLogin();
|
requireLogin();
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
/**
|
||||||
|
* GET ?exam_id=N — Tarifas de un examen por empresa o convenio.
|
||||||
|
*/
|
||||||
require_once __DIR__ . '/_helpers.php';
|
require_once __DIR__ . '/_helpers.php';
|
||||||
requireLogin();
|
requireLogin();
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
/**
|
||||||
|
* GET ?q=&categoria=&page=&limit= — Catálogo de exámenes, paginado y filtrable.
|
||||||
|
*/
|
||||||
require_once __DIR__ . '/_helpers.php';
|
require_once __DIR__ . '/_helpers.php';
|
||||||
requireLogin();
|
requireLogin();
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
/**
|
||||||
|
* POST — Crea o actualiza un examen del catálogo. Requiere administrador.
|
||||||
|
*/
|
||||||
require_once __DIR__ . '/_helpers.php';
|
require_once __DIR__ . '/_helpers.php';
|
||||||
requireAdmin();
|
requireAdmin();
|
||||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') jsonError('Método no permitido', 405);
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') jsonError('Método no permitido', 405);
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
/**
|
||||||
|
* POST — Crea o actualiza un ítem (analito) de un examen. Requiere administrador.
|
||||||
|
*/
|
||||||
require_once __DIR__ . '/_helpers.php';
|
require_once __DIR__ . '/_helpers.php';
|
||||||
requireAdmin();
|
requireAdmin();
|
||||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') jsonError('Método no permitido', 405);
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') jsonError('Método no permitido', 405);
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
/**
|
||||||
|
* POST — Crea o actualiza la tarifa de un examen. Requiere administrador.
|
||||||
|
*/
|
||||||
require_once __DIR__ . '/_helpers.php';
|
require_once __DIR__ . '/_helpers.php';
|
||||||
requireAdmin();
|
requireAdmin();
|
||||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') jsonError('Método no permitido', 405);
|
if ($_SERVER['REQUEST_METHOD'] !== 'POST') jsonError('Método no permitido', 405);
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
/**
|
||||||
|
* POST { id } — Elimina un médico del catálogo.
|
||||||
|
*/
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
if (!isUserLoggedIn()) { http_response_code(401); echo json_encode(['ok'=>false,'error'=>'No autorizado']); exit; }
|
if (!isUserLoggedIn()) { http_response_code(401); echo json_encode(['ok'=>false,'error'=>'No autorizado']); exit; }
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
/**
|
||||||
|
* GET — Lista de médicos solicitantes.
|
||||||
|
*/
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
if (!isUserLoggedIn()) { http_response_code(401); echo json_encode(['ok'=>false,'error'=>'No autorizado']); exit; }
|
if (!isUserLoggedIn()) { http_response_code(401); echo json_encode(['ok'=>false,'error'=>'No autorizado']); exit; }
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
/**
|
||||||
|
* POST — Crea o actualiza un médico del catálogo.
|
||||||
|
*/
|
||||||
require_once __DIR__ . '/../../../config/config.php';
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
if (!isUserLoggedIn()) { http_response_code(401); echo json_encode(['ok'=>false,'error'=>'No autorizado']); exit; }
|
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
|
## 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* modules/turnero/api/ai_chat.php
|
* modules/turnero/api/ai_chat.php
|
||||||
* POST { pregunta: string }
|
* POST { pregunta: string, historial?: [{rol:'user'|'model', texto:string}, ...] }
|
||||||
* Llama a Gemini Flash con contexto completo del turnero del día.
|
* Llama a Gemini Flash con contexto completo del turnero del día.
|
||||||
*/
|
*/
|
||||||
require_once __DIR__ . '/_helpers.php';
|
require_once __DIR__ . '/_helpers.php';
|
||||||
@@ -14,12 +14,30 @@ $pregunta = trim($body['pregunta'] ?? '');
|
|||||||
if ($pregunta === '') jsonError('Pregunta vacía', 400);
|
if ($pregunta === '') jsonError('Pregunta vacía', 400);
|
||||||
if (mb_strlen($pregunta) > 800) jsonError('Pregunta demasiado larga', 400);
|
if (mb_strlen($pregunta) > 800) jsonError('Pregunta demasiado larga', 400);
|
||||||
|
|
||||||
|
// Historial de la conversación, para que LIA entienda preguntas de seguimiento
|
||||||
|
// ("¿y de esos cuántos fueron en la mañana?"). Se limita a los últimos turnos
|
||||||
|
// para no inflar el consumo de tokens en cada llamada.
|
||||||
|
$historial = [];
|
||||||
|
foreach (array_slice(is_array($body['historial'] ?? null) ? $body['historial'] : [], -8) as $h) {
|
||||||
|
$rol = ($h['rol'] ?? '') === 'model' ? 'model' : 'user';
|
||||||
|
$texto = trim((string)($h['texto'] ?? ''));
|
||||||
|
if ($texto === '') continue;
|
||||||
|
$historial[] = ['role' => $rol, 'parts' => [['text' => mb_substr($texto, 0, 2000)]]];
|
||||||
|
}
|
||||||
|
|
||||||
|
define('LIA_TOKENS_MAX', 1_000_000);
|
||||||
|
|
||||||
$pdo = db();
|
$pdo = db();
|
||||||
|
|
||||||
// ── Clave Gemini ──────────────────────────────────────────────
|
// ── Clave Gemini ──────────────────────────────────────────────
|
||||||
$row = $pdo->query("SELECT valor FROM lab_config WHERE clave = 'gemini_api_key' LIMIT 1")->fetch();
|
$cfg = $pdo->query("SELECT clave, valor FROM lab_config WHERE clave IN ('gemini_api_key','lia_tokens_usados')")->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||||
$apiKey = trim($row['valor'] ?? '');
|
$apiKey = trim($cfg['gemini_api_key'] ?? '');
|
||||||
|
$tokensUsados = (int)($cfg['lia_tokens_usados'] ?? 0);
|
||||||
|
|
||||||
if (!$apiKey) jsonError('Token de IA no configurado. Ve a Configuración del laboratorio.', 503);
|
if (!$apiKey) jsonError('Token de IA no configurado. Ve a Configuración del laboratorio.', 503);
|
||||||
|
if ($tokensUsados >= LIA_TOKENS_MAX) {
|
||||||
|
jsonError('⚠️ Se agotaron los tokens de LIA. Solicita tokens a soporte para continuar.', 402);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Contexto del día ──────────────────────────────────────────
|
// ── Contexto del día ──────────────────────────────────────────
|
||||||
$hoy = date('Y-m-d');
|
$hoy = date('Y-m-d');
|
||||||
@@ -164,29 +182,51 @@ 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 ────────────────────────────────────────────────────
|
// ── Prompt ────────────────────────────────────────────────────
|
||||||
$systemPrompt = <<<PROMPT
|
$systemPrompt = <<<PROMPT
|
||||||
Eres el asistente inteligente del sistema de turnero del Laboratorio Clínico.
|
Eres LIA, la asistente inteligente del sistema de turnero del Laboratorio Clínico.
|
||||||
Responde de forma concisa y útil en español. 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.
|
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.
|
||||||
Solo usa la información del contexto proporcionado. Si no tienes el dato, dilo claramente.
|
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.
|
||||||
|
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:
|
CONTEXTO DEL DÍA:
|
||||||
{$ctx}
|
{$ctx}{$bloqueDocs}
|
||||||
PROMPT;
|
PROMPT;
|
||||||
|
|
||||||
// ── Llamada a Gemini Flash ────────────────────────────────────
|
// ── Llamada a Gemini Flash ────────────────────────────────────
|
||||||
$payload = json_encode([
|
$payload = json_encode([
|
||||||
'system_instruction' => ['parts' => [['text' => $systemPrompt]]],
|
'system_instruction' => ['parts' => [['text' => $systemPrompt]]],
|
||||||
'contents' => [
|
'contents' => array_merge($historial, [
|
||||||
['role' => 'user', 'parts' => [['text' => $pregunta]]]
|
['role' => 'user', 'parts' => [['text' => $pregunta]]]
|
||||||
],
|
]),
|
||||||
'generationConfig' => [
|
'generationConfig' => [
|
||||||
'temperature' => 0.3,
|
'temperature' => 0.3,
|
||||||
'maxOutputTokens' => 600,
|
// 600 cortaba a media frase las respuestas largas (listados de turnos,
|
||||||
|
// comparativas por bacteriólogo). Gemini Flash admite hasta 8192.
|
||||||
|
'maxOutputTokens' => 2048,
|
||||||
],
|
],
|
||||||
], JSON_UNESCAPED_UNICODE);
|
], JSON_UNESCAPED_UNICODE);
|
||||||
|
|
||||||
$ch = curl_init("https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={$apiKey}");
|
$ch = curl_init("https://generativelanguage.googleapis.com/v1beta/models/gemini-3.5-flash:generateContent?key={$apiKey}");
|
||||||
curl_setopt_array($ch, [
|
curl_setopt_array($ch, [
|
||||||
CURLOPT_RETURNTRANSFER => true,
|
CURLOPT_RETURNTRANSFER => true,
|
||||||
CURLOPT_POST => true,
|
CURLOPT_POST => true,
|
||||||
@@ -207,5 +247,27 @@ if ($code !== 200 || empty($gemini['candidates'][0]['content']['parts'][0]['text
|
|||||||
jsonError("Error IA: {$detail}", 502);
|
jsonError("Error IA: {$detail}", 502);
|
||||||
}
|
}
|
||||||
|
|
||||||
$respuesta = $gemini['candidates'][0]['content']['parts'][0]['text'];
|
$respuesta = $gemini['candidates'][0]['content']['parts'][0]['text'];
|
||||||
jsonOk(['respuesta' => $respuesta]);
|
|
||||||
|
// Gemini corta la respuesta y avisa con finishReason; sin esto el usuario recibe
|
||||||
|
// un texto truncado a media frase sin saber que faltó contenido.
|
||||||
|
$finishReason = $gemini['candidates'][0]['finishReason'] ?? null;
|
||||||
|
$truncada = ($finishReason === 'MAX_TOKENS');
|
||||||
|
if ($truncada) {
|
||||||
|
$respuesta .= "\n\n_(respuesta cortada por longitud — pide el detalle por partes)_";
|
||||||
|
}
|
||||||
|
|
||||||
|
$usage = $gemini['usageMetadata'] ?? [];
|
||||||
|
$tokensEsta = (int)($usage['totalTokenCount'] ?? (int)((mb_strlen($pregunta) + mb_strlen($respuesta)) / 4));
|
||||||
|
$tokensUsados += $tokensEsta;
|
||||||
|
|
||||||
|
$pdo->prepare("INSERT INTO lab_config (clave, valor) VALUES ('lia_tokens_usados', ?)
|
||||||
|
ON DUPLICATE KEY UPDATE valor = ?")->execute([$tokensUsados, $tokensUsados]);
|
||||||
|
|
||||||
|
jsonOk([
|
||||||
|
'respuesta' => $respuesta,
|
||||||
|
'truncada' => $truncada,
|
||||||
|
'tokens_usados' => $tokensUsados,
|
||||||
|
'tokens_restantes' => max(0, LIA_TOKENS_MAX - $tokensUsados),
|
||||||
|
'tokens_max' => LIA_TOKENS_MAX,
|
||||||
|
]);
|
||||||
|
|||||||
@@ -0,0 +1,42 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* POST /modules/turnero/api/anexar_formulario.php
|
||||||
|
* Crea un consentimiento adicional para un turno activo.
|
||||||
|
* Body JSON: { turno_id: int, formulario_id: int }
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/_helpers.php';
|
||||||
|
requireMethod('POST');
|
||||||
|
requireTurnero();
|
||||||
|
|
||||||
|
$body = inputJson();
|
||||||
|
$turnoId = (int)($body['turno_id'] ?? 0);
|
||||||
|
$formularioId = (int)($body['formulario_id'] ?? 0);
|
||||||
|
|
||||||
|
if (!$turnoId) jsonError('turno_id requerido.');
|
||||||
|
if (!$formularioId) jsonError('formulario_id requerido.');
|
||||||
|
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
// Verificar que el turno existe y está activo
|
||||||
|
$turno = $pdo->prepare("SELECT id, sesion_id, estado FROM turnero_turnos WHERE id = ? LIMIT 1");
|
||||||
|
$turno->execute([$turnoId]);
|
||||||
|
$t = $turno->fetch(PDO::FETCH_ASSOC);
|
||||||
|
if (!$t) jsonError('Turno no encontrado.', 404);
|
||||||
|
if (!in_array($t['estado'], ['en_espera_lugar', 'en_servicio'])) jsonError('El turno no está activo.');
|
||||||
|
|
||||||
|
// Verificar que el formulario existe
|
||||||
|
$form = $pdo->prepare("SELECT id, nombre FROM lab_formularios WHERE id = ? AND is_active = 1 LIMIT 1");
|
||||||
|
$form->execute([$formularioId]);
|
||||||
|
if (!$form->fetch()) jsonError('Formulario no encontrado o inactivo.', 404);
|
||||||
|
|
||||||
|
// Crear el consentimiento con token UUID (mismo formato que create_solicitud)
|
||||||
|
$ins = $pdo->prepare(
|
||||||
|
"INSERT INTO turnero_consentimientos (turno_id, formulario_id, token, estado, datos_respuestas, creado_por)
|
||||||
|
VALUES (?, ?, UUID(), 'enviado', '{}', ?)"
|
||||||
|
);
|
||||||
|
$ins->execute([$turnoId, $formularioId, adminId()]);
|
||||||
|
$newId = (int)$pdo->lastInsertId();
|
||||||
|
$token = $pdo->query("SELECT token FROM turnero_consentimientos WHERE id = $newId")->fetchColumn();
|
||||||
|
|
||||||
|
notificarSSE((int)$t['sesion_id']);
|
||||||
|
jsonOk(['id' => $newId, 'token' => $token], 'Formulario agregado.');
|
||||||
@@ -1,4 +1,7 @@
|
|||||||
<?php
|
<?php
|
||||||
|
/**
|
||||||
|
* GET ?q= — Busca diagnósticos CIE-10 por código o descripción.
|
||||||
|
*/
|
||||||
require_once __DIR__ . '/_helpers.php';
|
require_once __DIR__ . '/_helpers.php';
|
||||||
requireMethod('GET');
|
requireMethod('GET');
|
||||||
requireTurnero();
|
requireTurnero();
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
require_once __DIR__ . '/_helpers.php';
|
require_once __DIR__ . '/_helpers.php';
|
||||||
|
require_once __DIR__ . '/../../../classes/lab/ActividadAdmin.php';
|
||||||
requireMethod('POST');
|
requireMethod('POST');
|
||||||
requireTurnero();
|
requireTurnero();
|
||||||
|
|
||||||
@@ -39,8 +40,8 @@ if ($nuevoEstado === '') jsonError('nuevo_estado es requerido.');
|
|||||||
const TRANSICIONES = [
|
const TRANSICIONES = [
|
||||||
'espera' => ['en_recepcion', 'ausente', 'cancelado'],
|
'espera' => ['en_recepcion', 'ausente', 'cancelado'],
|
||||||
'en_recepcion' => ['en_espera_lugar', 'espera', 'ausente', 'cancelado'],
|
'en_recepcion' => ['en_espera_lugar', 'espera', 'ausente', 'cancelado'],
|
||||||
'en_espera_lugar' => ['en_servicio', 'ausente', 'cancelado'],
|
'en_espera_lugar' => ['en_servicio', 'en_recepcion', 'ausente', 'cancelado'],
|
||||||
'en_servicio' => ['finalizado', 'ausente', 'cancelado', 'en_espera_lugar'],
|
'en_servicio' => ['finalizado', 'ausente', 'cancelado', 'en_espera_lugar', 'en_recepcion'],
|
||||||
];
|
];
|
||||||
|
|
||||||
$pdo = db();
|
$pdo = db();
|
||||||
@@ -65,10 +66,60 @@ try {
|
|||||||
|
|
||||||
$estadoActual = $turno['estado'];
|
$estadoActual = $turno['estado'];
|
||||||
|
|
||||||
// Estados finales no modificables
|
// Estados finales — solo admin (sin role_id) puede reabrir
|
||||||
if (in_array($estadoActual, ['finalizado', 'ausente', 'cancelado'], true)) {
|
if (in_array($estadoActual, ['finalizado', 'ausente', 'cancelado'], true)) {
|
||||||
$pdo->rollBack();
|
$roleId = $_SESSION['admin_user']['role_id'] ?? null;
|
||||||
jsonError("El turno ya está en estado '{$estadoActual}' y no puede modificarse.", 422);
|
if (!empty($roleId)) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
jsonError("El turno ya está en estado '{$estadoActual}' y no puede modificarse.", 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$permitidosAdmin = ['en_servicio', 'en_espera_lugar', 'en_recepcion', 'espera'];
|
||||||
|
if (!in_array($nuevoEstado, $permitidosAdmin, true)) {
|
||||||
|
$pdo->rollBack();
|
||||||
|
jsonError("Estado destino inválido para reapertura. Opciones: " . implode(', ', $permitidosAdmin), 422);
|
||||||
|
}
|
||||||
|
|
||||||
|
$sets = ['estado = ?'];
|
||||||
|
$binds = [$nuevoEstado];
|
||||||
|
|
||||||
|
if ($nuevoEstado === 'en_servicio') {
|
||||||
|
$sets[] = 'fin_lugar_at = NULL';
|
||||||
|
$sets[] = 'inicio_lugar_at = COALESCE(inicio_lugar_at, NOW())';
|
||||||
|
$sets[] = 'atendido_lugar_por = COALESCE(atendido_lugar_por, ?)';
|
||||||
|
$binds[] = adminId();
|
||||||
|
} elseif ($nuevoEstado === 'en_espera_lugar') {
|
||||||
|
$sets[] = 'fin_lugar_at = NULL';
|
||||||
|
$sets[] = 'inicio_lugar_at = NULL';
|
||||||
|
$sets[] = 'llamado_lugar_at = NULL';
|
||||||
|
} elseif ($nuevoEstado === 'espera') {
|
||||||
|
$sets[] = 'llamado_recepcion_at = NULL';
|
||||||
|
$sets[] = 'inicio_recepcion_at = NULL';
|
||||||
|
$sets[] = 'fin_recepcion_at = NULL';
|
||||||
|
$sets[] = 'llamado_lugar_at = NULL';
|
||||||
|
$sets[] = 'inicio_lugar_at = NULL';
|
||||||
|
$sets[] = 'fin_lugar_at = NULL';
|
||||||
|
$sets[] = 'atendido_recepcion_por = NULL';
|
||||||
|
$sets[] = 'atendido_lugar_por = NULL';
|
||||||
|
$sets[] = 'recepcion_desk_id = NULL';
|
||||||
|
}
|
||||||
|
|
||||||
|
$sql = 'UPDATE turnero_turnos SET ' . implode(', ', $sets) . ' WHERE id = ?';
|
||||||
|
$binds[] = $turnoId;
|
||||||
|
$pdo->prepare($sql)->execute($binds);
|
||||||
|
$pdo->commit();
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
'SELECT t.*, p.codigo AS prioridad_codigo, p.nombre AS prioridad_nombre, p.color AS prioridad_color
|
||||||
|
FROM turnero_turnos t
|
||||||
|
JOIN turnero_prioridades p ON p.id = t.prioridad_id
|
||||||
|
WHERE t.id = ?'
|
||||||
|
);
|
||||||
|
$stmt->execute([$turnoId]);
|
||||||
|
$turnoActualizado = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
notificarSSE((int) $turnoActualizado['sesion_id']);
|
||||||
|
jsonOk(['turno' => $turnoActualizado], "Turno reabierto: estado actualizado a '{$nuevoEstado}'");
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verificar que la transición sea válida
|
// Verificar que la transición sea válida
|
||||||
@@ -116,6 +167,14 @@ try {
|
|||||||
$sets[] = 'recepcion_desk_id = ?';
|
$sets[] = 'recepcion_desk_id = ?';
|
||||||
$binds[] = $deskId;
|
$binds[] = $deskId;
|
||||||
}
|
}
|
||||||
|
// Viene de un lugar: limpiar campos del lugar para que quede reasignable
|
||||||
|
if (in_array($estadoActual, ['en_espera_lugar', 'en_servicio'], true)) {
|
||||||
|
$sets[] = 'lugar_destino_id = NULL';
|
||||||
|
$sets[] = 'llamado_lugar_at = NULL';
|
||||||
|
$sets[] = 'inicio_lugar_at = NULL';
|
||||||
|
$sets[] = 'fin_recepcion_at = NULL';
|
||||||
|
$sets[] = 'atendido_lugar_por = NULL';
|
||||||
|
}
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'en_espera_lugar':
|
case 'en_espera_lugar':
|
||||||
@@ -175,6 +234,27 @@ try {
|
|||||||
|
|
||||||
notificarSSE((int) $turnoActualizado['sesion_id']);
|
notificarSSE((int) $turnoActualizado['sesion_id']);
|
||||||
|
|
||||||
|
// Queda constancia de quién cambió el estado a mano. Sin esto, en el
|
||||||
|
// historial aparecía un turno cancelado o ausente sin ninguna huella
|
||||||
|
// de quién lo hizo ni desde qué estado.
|
||||||
|
try {
|
||||||
|
(new ActividadAdmin())->registrar(
|
||||||
|
adminId(),
|
||||||
|
'turnero',
|
||||||
|
'cambiar_estado',
|
||||||
|
$turnoId,
|
||||||
|
[
|
||||||
|
'turno_id' => $turnoId,
|
||||||
|
'turno_codigo' => $turnoActualizado['codigo'] ?? null,
|
||||||
|
'estado_previo' => $estadoActual,
|
||||||
|
'estado_nuevo' => $nuevoEstado,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
// La auditoría nunca debe tumbar la operación
|
||||||
|
error_log('[cambiar_estado] auditoría: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
jsonOk(['turno' => $turnoActualizado], "Estado actualizado a '{$nuevoEstado}'");
|
jsonOk(['turno' => $turnoActualizado], "Estado actualizado a '{$nuevoEstado}'");
|
||||||
|
|
||||||
} catch (\Throwable $e) {
|
} catch (\Throwable $e) {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
* Body: { consentimiento_id }
|
* Body: { consentimiento_id }
|
||||||
*/
|
*/
|
||||||
require_once __DIR__ . '/_helpers.php';
|
require_once __DIR__ . '/_helpers.php';
|
||||||
|
require_once __DIR__ . '/../../../classes/lab/ActividadAdmin.php';
|
||||||
requireMethod('POST');
|
requireMethod('POST');
|
||||||
requireTurnero();
|
requireTurnero();
|
||||||
|
|
||||||
@@ -30,4 +31,35 @@ $stmt->execute([$turnoId, $consentId]);
|
|||||||
|
|
||||||
if ($stmt->rowCount() === 0) jsonError('Toma no encontrada o el paciente no coincide.', 404);
|
if ($stmt->rowCount() === 0) jsonError('Toma no encontrada o el paciente no coincide.', 404);
|
||||||
|
|
||||||
|
// Cancelar una toma pendiente cierra un protocolo que quedó a medias en otra
|
||||||
|
// visita: conviene saber quién lo dio por terminado.
|
||||||
|
try {
|
||||||
|
$inf = $pdo->prepare(
|
||||||
|
"SELECT tc.turno_id, t.codigo, f.nombre AS formulario
|
||||||
|
FROM turnero_consentimientos tc
|
||||||
|
JOIN turnero_turnos t ON t.id = tc.turno_id
|
||||||
|
JOIN lab_formularios f ON f.id = tc.formulario_id
|
||||||
|
WHERE tc.id = ? LIMIT 1"
|
||||||
|
);
|
||||||
|
$inf->execute([$consentId]);
|
||||||
|
$d = $inf->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||||
|
|
||||||
|
(new ActividadAdmin())->registrar(
|
||||||
|
adminId(),
|
||||||
|
'turnero',
|
||||||
|
'cancelar_toma_pendiente',
|
||||||
|
$consentId,
|
||||||
|
[
|
||||||
|
// Se registra bajo el turno donde se hizo la cancelación, que es
|
||||||
|
// donde alguien la va a buscar en el historial.
|
||||||
|
'turno_id' => $turnoId,
|
||||||
|
'turno_origen_id' => isset($d['turno_id']) ? (int)$d['turno_id'] : null,
|
||||||
|
'turno_origen' => $d['codigo'] ?? null,
|
||||||
|
'formulario_nombre' => $d['formulario'] ?? null,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
error_log('[cancelar_toma_pendiente] auditoría: ' . $e->getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
jsonOk(['cancelado' => true]);
|
jsonOk(['cancelado' => true]);
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* GET check_paciente.php?doc=NUMERO
|
||||||
|
* Endpoint público (mismo nivel que el kiosko). Solo devuelve fecha_nacimiento para validar edad.
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json');
|
||||||
|
|
||||||
|
if ($_SERVER['REQUEST_METHOD'] !== 'GET') { echo json_encode(['ok' => false]); exit; }
|
||||||
|
|
||||||
|
$doc = preg_replace('/\D/', '', trim($_GET['doc'] ?? ''));
|
||||||
|
if (strlen($doc) < 5 || strlen($doc) > 15) { echo json_encode(['ok' => false]); exit; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
$stmt = Database::getInstance()->getConnection()
|
||||||
|
->prepare("SELECT fecha_nacimiento FROM lab_pacientes WHERE numero_documento = ? LIMIT 1");
|
||||||
|
$stmt->execute([$doc]);
|
||||||
|
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
if (!$row || !$row['fecha_nacimiento']) { echo json_encode(['ok' => false]); exit; }
|
||||||
|
echo json_encode(['ok' => true, 'fecha_nacimiento' => $row['fecha_nacimiento']]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
echo json_encode(['ok' => false]);
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ $otroHora = trim($body['otro_hora'] ?? '');
|
|||||||
|
|
||||||
if (!$token) jsonError('token requerido.');
|
if (!$token) jsonError('token requerido.');
|
||||||
if (!is_array($cfg) || empty($cfg)) jsonError('tomas_config requerido.');
|
if (!is_array($cfg) || empty($cfg)) jsonError('tomas_config requerido.');
|
||||||
|
if ($examType === '') jsonError('exam_type requerido.');
|
||||||
|
|
||||||
foreach ($cfg as $grupo => $ids) {
|
foreach ($cfg as $grupo => $ids) {
|
||||||
if (!is_array($ids) || empty($ids)) jsonError("El grupo '$grupo' no tiene tomas seleccionadas.");
|
if (!is_array($ids) || empty($ids)) jsonError("El grupo '$grupo' no tiene tomas seleccionadas.");
|
||||||
@@ -41,8 +42,22 @@ if ($tc['datos_respuestas']) {
|
|||||||
if (is_array($d)) $dr = $d;
|
if (is_array($d)) $dr = $d;
|
||||||
}
|
}
|
||||||
|
|
||||||
$dr['_tomas_config'] = $cfg;
|
// Store per-exam nested: { examName: { groupKey: [firmaIds] } }
|
||||||
if ($examType !== '') $dr['_c8j2g16'] = $examType;
|
$existing = isset($dr['_tomas_config']) && is_array($dr['_tomas_config']) ? $dr['_tomas_config'] : [];
|
||||||
|
// Reset old flat format (values were arrays of strings, not arrays of arrays)
|
||||||
|
if (!empty($existing)) {
|
||||||
|
$__first = reset($existing);
|
||||||
|
if (is_array($__first) && !empty($__first) && is_string(reset($__first))) $existing = [];
|
||||||
|
}
|
||||||
|
$existing[$examType] = $cfg;
|
||||||
|
$dr['_tomas_config'] = $existing;
|
||||||
|
// Guardar selección completa de exámenes (array) para que el reload restaure todos
|
||||||
|
$examTypes = isset($body['exam_types']) && is_array($body['exam_types']) ? array_values(array_filter($body['exam_types'])) : [];
|
||||||
|
if ($examTypes) {
|
||||||
|
$dr['_c8j2g16'] = count($examTypes) === 1 ? $examTypes[0] : $examTypes;
|
||||||
|
} elseif ($examType !== '' && !array_key_exists('_c8j2g16', $dr)) {
|
||||||
|
$dr['_c8j2g16'] = $examType;
|
||||||
|
}
|
||||||
if ($otroHora !== '') $dr['_otro_hora'] = $otroHora;
|
if ($otroHora !== '') $dr['_otro_hora'] = $otroHora;
|
||||||
|
|
||||||
$pdo->prepare("UPDATE turnero_consentimientos SET datos_respuestas = ? WHERE id = ?")
|
$pdo->prepare("UPDATE turnero_consentimientos SET datos_respuestas = ? WHERE id = ?")
|
||||||
|
|||||||
@@ -68,10 +68,10 @@ if ($existente) {
|
|||||||
);
|
);
|
||||||
|
|
||||||
$stmt = $pdo->prepare(
|
$stmt = $pdo->prepare(
|
||||||
"INSERT INTO turnero_consentimientos (turno_id, formulario_id, token, estado)
|
"INSERT INTO turnero_consentimientos (turno_id, formulario_id, token, estado, creado_por)
|
||||||
VALUES (?, ?, ?, 'pendiente')"
|
VALUES (?, ?, ?, 'pendiente', ?)"
|
||||||
);
|
);
|
||||||
$stmt->execute([$turnoId, $formularioId, $token]);
|
$stmt->execute([$turnoId, $formularioId, $token, adminId()]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$url = BASE_URL . 'ver_formulario_enviado.php?token=' . urlencode($token);
|
$url = BASE_URL . 'ver_formulario_enviado.php?token=' . urlencode($token);
|
||||||
|
|||||||
@@ -187,15 +187,15 @@ try {
|
|||||||
$consentimientosRequeridos = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
$consentimientosRequeridos = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
$stmtCreateConsent = $pdo->prepare(
|
$stmtCreateConsent = $pdo->prepare(
|
||||||
"INSERT IGNORE INTO turnero_consentimientos (turno_id, formulario_id, token, estado)
|
"INSERT IGNORE INTO turnero_consentimientos (turno_id, formulario_id, token, estado, creado_por)
|
||||||
VALUES (?, ?, UUID(), 'pendiente')"
|
VALUES (?, ?, UUID(), 'pendiente', ?)"
|
||||||
);
|
);
|
||||||
$stmtRead = $pdo->prepare(
|
$stmtRead = $pdo->prepare(
|
||||||
"SELECT id, token, estado FROM turnero_consentimientos
|
"SELECT id, token, estado FROM turnero_consentimientos
|
||||||
WHERE turno_id = ? AND formulario_id = ?"
|
WHERE turno_id = ? AND formulario_id = ?"
|
||||||
);
|
);
|
||||||
foreach ($consentimientosRequeridos as &$c) {
|
foreach ($consentimientosRequeridos as &$c) {
|
||||||
$stmtCreateConsent->execute([$turnoId, $c['formulario_id']]);
|
$stmtCreateConsent->execute([$turnoId, $c['formulario_id'], adminId()]);
|
||||||
$stmtRead->execute([$turnoId, $c['formulario_id']]);
|
$stmtRead->execute([$turnoId, $c['formulario_id']]);
|
||||||
$row = $stmtRead->fetch(PDO::FETCH_ASSOC);
|
$row = $stmtRead->fetch(PDO::FETCH_ASSOC);
|
||||||
$c['id'] = $row['id'] ?? null;
|
$c['id'] = $row['id'] ?? null;
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* modules/turnero/api/delete_tv_media.php
|
||||||
|
* Elimina un ítem de la playlist de la Pantalla TV (archivo + fila).
|
||||||
|
* POST JSON: { id: int }
|
||||||
|
*/
|
||||||
|
|
||||||
|
require_once __DIR__ . '/../../../config/config.php';
|
||||||
|
|
||||||
|
header('Content-Type: application/json; charset=utf-8');
|
||||||
|
|
||||||
|
if (!isUserLoggedIn()) {
|
||||||
|
http_response_code(401);
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'No autenticado']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo = Database::getInstance()->getConnection();
|
||||||
|
$body = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||||
|
$id = (int)($body['id'] ?? 0);
|
||||||
|
|
||||||
|
if (!$id) {
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'id requerido']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("SELECT url FROM turnero_tv_media WHERE id = ?");
|
||||||
|
$stmt->execute([$id]);
|
||||||
|
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if (!$row) {
|
||||||
|
echo json_encode(['ok' => false, 'error' => 'No encontrado']);
|
||||||
|
exit;
|
||||||
|
}
|
||||||
|
|
||||||
|
$uploadDir = dirname(__DIR__, 3) . '/uploads/turnero/tv_media/';
|
||||||
|
$filename = basename(parse_url($row['url'], PHP_URL_PATH));
|
||||||
|
$filepath = $uploadDir . $filename;
|
||||||
|
if (is_file($filepath)) {
|
||||||
|
@unlink($filepath);
|
||||||
|
}
|
||||||
|
|
||||||
|
$pdo->prepare("DELETE FROM turnero_tv_media WHERE id = ?")->execute([$id]);
|
||||||
|
|
||||||
|
echo json_encode(['ok' => true]);
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* POST /modules/turnero/api/enviar_encuesta.php
|
||||||
|
* Envía la plantilla "encuesta" al paciente del turno vía WhatsApp turnero.
|
||||||
|
* Body JSON: { turno_id: int }
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/_helpers.php';
|
||||||
|
require_once __DIR__ . '/../../../services/WhatsAppService.php';
|
||||||
|
requireMethod('POST');
|
||||||
|
requireTurnero();
|
||||||
|
|
||||||
|
$body = inputJson();
|
||||||
|
$turnoId = (int)($body['turno_id'] ?? 0);
|
||||||
|
if (!$turnoId) jsonError('turno_id requerido.');
|
||||||
|
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
$row = $pdo->prepare(
|
||||||
|
"SELECT t.paciente_cel, t.paciente_nombre,
|
||||||
|
COALESCE(lp.telefono, t.paciente_cel) AS celular
|
||||||
|
FROM turnero_turnos t
|
||||||
|
LEFT JOIN lab_pacientes lp ON lp.id = t.paciente_id
|
||||||
|
WHERE t.id = ? LIMIT 1"
|
||||||
|
);
|
||||||
|
$row->execute([$turnoId]);
|
||||||
|
$pac = $row->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
if (!$pac) jsonError('Turno no encontrado.', 404);
|
||||||
|
|
||||||
|
$phone = preg_replace('/[^0-9]/', '', $pac['celular'] ?? '');
|
||||||
|
if (!$phone) jsonError('El paciente no tiene número de celular registrado.');
|
||||||
|
|
||||||
|
$langRow = $pdo->prepare("SELECT language_code FROM message_templates WHERE template_name = 'encuesta_turnero' LIMIT 1");
|
||||||
|
$langRow->execute();
|
||||||
|
$lang = $langRow->fetchColumn() ?: 'es';
|
||||||
|
|
||||||
|
try {
|
||||||
|
$wa = new WhatsAppService('turnero');
|
||||||
|
$meta = ['canal' => 'turnero', 'operator_id' => adminId()];
|
||||||
|
$wa->sendTemplateMessage($phone, 'encuesta_turnero', $lang, [], [], null, $meta);
|
||||||
|
jsonOk(['mensaje' => 'Encuesta enviada a ' . ($pac['paciente_nombre'] ?? $phone)]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
jsonError('Error al enviar: ' . $e->getMessage());
|
||||||
|
}
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* POST enviar_informe_dia.php
|
||||||
|
* Envía por WhatsApp un resumen del día al número indicado.
|
||||||
|
* Body JSON: { phone: string }
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/_helpers.php';
|
||||||
|
require_once __DIR__ . '/../../../services/WhatsAppService.php';
|
||||||
|
requireMethod('POST');
|
||||||
|
requireTurnero();
|
||||||
|
|
||||||
|
$body = inputJson();
|
||||||
|
$phone = preg_replace('/[^0-9]/', '', $body['phone'] ?? '');
|
||||||
|
if (strlen($phone) < 7) jsonError('Número de teléfono inválido.');
|
||||||
|
|
||||||
|
$pdo = db();
|
||||||
|
$hoy = date('Y-m-d');
|
||||||
|
|
||||||
|
$ses = $pdo->prepare("SELECT id, inicio_at FROM turnero_sesiones WHERE fecha = ? LIMIT 1");
|
||||||
|
$ses->execute([$hoy]);
|
||||||
|
$sesion = $ses->fetch(PDO::FETCH_ASSOC);
|
||||||
|
if (!$sesion) jsonError('No hay sesión activa hoy.');
|
||||||
|
$sesId = (int)$sesion['id'];
|
||||||
|
|
||||||
|
// Resumen de estados
|
||||||
|
$stmt = $pdo->prepare("SELECT estado, COUNT(*) AS n FROM turnero_turnos WHERE sesion_id = ? GROUP BY estado");
|
||||||
|
$stmt->execute([$sesId]);
|
||||||
|
$estados = [];
|
||||||
|
foreach ($stmt->fetchAll(PDO::FETCH_ASSOC) as $r) $estados[$r['estado']] = (int)$r['n'];
|
||||||
|
$total = array_sum($estados);
|
||||||
|
$atendidos = ($estados['finalizado'] ?? 0);
|
||||||
|
$enEspera = ($estados['espera'] ?? 0) + ($estados['en_recepcion'] ?? 0) + ($estados['en_espera_lugar'] ?? 0) + ($estados['en_servicio'] ?? 0);
|
||||||
|
$ausentes = ($estados['ausente'] ?? 0);
|
||||||
|
|
||||||
|
// Tiempos promedio
|
||||||
|
$tRow = $pdo->prepare(
|
||||||
|
"SELECT
|
||||||
|
ROUND(AVG(TIMESTAMPDIFF(SECOND, creado_at, COALESCE(inicio_recepcion_at, NOW())) / 60), 0) AS espera,
|
||||||
|
ROUND(AVG(TIMESTAMPDIFF(SECOND, inicio_lugar_at, COALESCE(fin_lugar_at, NOW())) / 60), 0) AS servicio,
|
||||||
|
ROUND(AVG(TIMESTAMPDIFF(SECOND, creado_at, fin_lugar_at) / 60), 0) AS total
|
||||||
|
FROM turnero_turnos WHERE sesion_id = ?"
|
||||||
|
);
|
||||||
|
$tRow->execute([$sesId]);
|
||||||
|
$t = $tRow->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// Facturación
|
||||||
|
$fRows = $pdo->prepare(
|
||||||
|
"SELECT ts.metodo_pago, SUM(ts.total_cobrado) AS total, COUNT(*) AS n
|
||||||
|
FROM turnero_solicitudes ts
|
||||||
|
JOIN turnero_turnos tt ON tt.id = ts.turno_id
|
||||||
|
WHERE tt.sesion_id = ? AND ts.total_cobrado > 0
|
||||||
|
GROUP BY ts.metodo_pago"
|
||||||
|
);
|
||||||
|
$fRows->execute([$sesId]);
|
||||||
|
$fact = $fRows->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
$totalFact = array_sum(array_column($fact, 'total'));
|
||||||
|
|
||||||
|
// Armar mensaje
|
||||||
|
$fecha = date('d/m/Y');
|
||||||
|
$hora = date('H:i');
|
||||||
|
$msg = "📊 *Informe del día - Lab. Ximena Caicedo*\n";
|
||||||
|
$msg .= "📅 {$fecha} · {$hora}\n\n";
|
||||||
|
$msg .= "👥 *Turnos:*\n";
|
||||||
|
$msg .= "• Total: {$total}\n";
|
||||||
|
$msg .= "• Finalizados: {$atendidos}\n";
|
||||||
|
if ($enEspera) $msg .= "• En atención/espera: {$enEspera}\n";
|
||||||
|
if ($ausentes) $msg .= "• Ausentes: {$ausentes}\n";
|
||||||
|
$msg .= "\n";
|
||||||
|
|
||||||
|
if ($fact) {
|
||||||
|
$msg .= "💰 *Facturación:*\n";
|
||||||
|
foreach ($fact as $f) {
|
||||||
|
$msg .= "• {$f['metodo_pago']}: $" . number_format((float)$f['total'], 0, ',', '.') . " ({$f['n']} pac.)\n";
|
||||||
|
}
|
||||||
|
$msg .= "• *Total: $" . number_format($totalFact, 0, ',', '.') . "*\n\n";
|
||||||
|
}
|
||||||
|
|
||||||
|
$msg .= "⏱ *Tiempos promedio:*\n";
|
||||||
|
$msg .= "• Espera hasta recepción: " . ($t['espera'] ?? '—') . " min\n";
|
||||||
|
$msg .= "• Servicio en muestras: " . ($t['servicio'] ?? '—') . " min\n";
|
||||||
|
$msg .= "• Total puerta a puerta: " . ($t['total'] ?? '—') . " min\n";
|
||||||
|
|
||||||
|
try {
|
||||||
|
$wa = new WhatsAppService('turnero');
|
||||||
|
$wa->sendMessage($phone, $msg);
|
||||||
|
jsonOk(['mensaje' => "Informe enviado a {$phone}"]);
|
||||||
|
} catch (\Throwable $e) {
|
||||||
|
jsonError('Error al enviar: ' . $e->getMessage());
|
||||||
|
}
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* GET export_excel.php
|
||||||
|
* Exporta el resumen del día como .xls (HTML table, abre en Excel).
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/_helpers.php';
|
||||||
|
requireTurnero();
|
||||||
|
requireMethod('GET');
|
||||||
|
|
||||||
|
$pdo = db();
|
||||||
|
$hoy = trim($_GET['fecha'] ?? date('Y-m-d'));
|
||||||
|
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $hoy)) $hoy = date('Y-m-d');
|
||||||
|
|
||||||
|
$ses = $pdo->prepare("SELECT id FROM turnero_sesiones WHERE fecha = ? LIMIT 1");
|
||||||
|
$ses->execute([$hoy]);
|
||||||
|
$sesion = $ses->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$turnos = [];
|
||||||
|
if ($sesion) {
|
||||||
|
$sesId = (int)$sesion['id'];
|
||||||
|
$stmt = $pdo->prepare(
|
||||||
|
"SELECT t.codigo, p.codigo AS prioridad, t.estado,
|
||||||
|
COALESCE(sp.nombre_completo, t.paciente_nombre) AS paciente,
|
||||||
|
sp.tipo_documento AS tipo_doc, sp.numero_documento AS num_doc,
|
||||||
|
t.paciente_cel AS celular,
|
||||||
|
l.nombre AS lugar,
|
||||||
|
ur.full_name AS recepcionista,
|
||||||
|
ul.full_name AS bacteriologo,
|
||||||
|
ts.total_cobrado AS cobrado, ts.metodo_pago,
|
||||||
|
GROUP_CONCAT(DISTINCT et.nombre ORDER BY et.nombre SEPARATOR ', ') AS examenes,
|
||||||
|
ROUND(TIMESTAMPDIFF(SECOND, t.creado_at, COALESCE(t.inicio_recepcion_at, t.fin_recepcion_at)) / 60, 1) AS espera_min,
|
||||||
|
ROUND(TIMESTAMPDIFF(SECOND, t.inicio_lugar_at, COALESCE(t.fin_lugar_at, NOW())) / 60, 1) AS servicio_min,
|
||||||
|
ROUND(TIMESTAMPDIFF(SECOND, t.creado_at, t.fin_lugar_at) / 60, 1) AS total_min,
|
||||||
|
t.creado_at
|
||||||
|
FROM turnero_turnos t
|
||||||
|
JOIN turnero_prioridades p ON p.id = t.prioridad_id
|
||||||
|
LEFT JOIN turnero_lugares l ON l.id = t.lugar_destino_id
|
||||||
|
LEFT JOIN turnero_solicitudes ts ON ts.turno_id = t.id
|
||||||
|
LEFT JOIN turnero_examen_items tei ON tei.solicitud_id = ts.id
|
||||||
|
LEFT JOIN exam_tipos et ON et.id = tei.exam_tipo_id
|
||||||
|
LEFT JOIN lab_pacientes sp ON sp.id = ts.paciente_id
|
||||||
|
LEFT JOIN admin_users ur ON ur.id = t.atendido_recepcion_por
|
||||||
|
LEFT JOIN admin_users ul ON ul.id = t.atendido_lugar_por
|
||||||
|
WHERE t.sesion_id = ?
|
||||||
|
GROUP BY t.id ORDER BY t.numero ASC"
|
||||||
|
);
|
||||||
|
$stmt->execute([$sesId]);
|
||||||
|
$turnos = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
}
|
||||||
|
|
||||||
|
$fecha = date('d/m/Y', strtotime($hoy));
|
||||||
|
$archivo = 'informe-turnero-' . $hoy . '.xls';
|
||||||
|
|
||||||
|
header('Content-Type: application/vnd.ms-excel; charset=utf-8');
|
||||||
|
header('Content-Disposition: attachment; filename="' . $archivo . '"');
|
||||||
|
header('Cache-Control: no-cache');
|
||||||
|
|
||||||
|
$estados = [
|
||||||
|
'finalizado'=>'Finalizado','ausente'=>'Ausente','cancelado'=>'Cancelado',
|
||||||
|
'en_servicio'=>'En servicio','en_espera_lugar'=>'Esp. lugar',
|
||||||
|
'en_recepcion'=>'Recepción','espera'=>'Espera',
|
||||||
|
];
|
||||||
|
|
||||||
|
echo '<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel">';
|
||||||
|
echo '<head><meta charset="UTF-8">
|
||||||
|
<style>
|
||||||
|
th { background:#1565c0; color:#fff; font-weight:bold; }
|
||||||
|
td,th { border:1px solid #ccc; padding:4px 8px; font-size:11px; }
|
||||||
|
.f { color:green; font-weight:bold; }
|
||||||
|
.a { color:red; }
|
||||||
|
</style></head><body>';
|
||||||
|
echo "<h2>Informe Turnero - {$fecha}</h2>";
|
||||||
|
echo '<table>';
|
||||||
|
echo '<tr>
|
||||||
|
<th>Código</th><th>Prioridad</th><th>Estado</th>
|
||||||
|
<th>Paciente</th><th>Tipo Doc</th><th>Documento</th><th>Celular</th>
|
||||||
|
<th>Lugar</th><th>Recepcionista</th><th>Bacteriólogo</th>
|
||||||
|
<th>Exámenes</th><th>Método Pago</th><th>Cobrado</th>
|
||||||
|
<th>Espera (min)</th><th>Servicio (min)</th><th>Total (min)</th>
|
||||||
|
<th>Hora entrada</th>
|
||||||
|
</tr>';
|
||||||
|
|
||||||
|
foreach ($turnos as $t) {
|
||||||
|
$cls = $t['estado'] === 'finalizado' ? ' class="f"' : ($t['estado'] === 'ausente' ? ' class="a"' : '');
|
||||||
|
$hora = $t['creado_at'] ? date('H:i', strtotime($t['creado_at'])) : '';
|
||||||
|
$cobrado = $t['cobrado'] ? number_format((float)$t['cobrado'], 0, ',', '.') : '';
|
||||||
|
$estadoLbl = $estados[$t['estado']] ?? $t['estado'];
|
||||||
|
echo "<tr{$cls}>
|
||||||
|
<td>{$t['codigo']}</td>
|
||||||
|
<td>{$t['prioridad']}</td>
|
||||||
|
<td>{$estadoLbl}</td>
|
||||||
|
<td>" . htmlspecialchars($t['paciente'] ?? '', ENT_QUOTES) . "</td>
|
||||||
|
<td>{$t['tipo_doc']}</td>
|
||||||
|
<td>{$t['num_doc']}</td>
|
||||||
|
<td>{$t['celular']}</td>
|
||||||
|
<td>" . htmlspecialchars($t['lugar'] ?? '', ENT_QUOTES) . "</td>
|
||||||
|
<td>" . htmlspecialchars($t['recepcionista'] ?? '', ENT_QUOTES) . "</td>
|
||||||
|
<td>" . htmlspecialchars($t['bacteriologo'] ?? '', ENT_QUOTES) . "</td>
|
||||||
|
<td>" . htmlspecialchars($t['examenes'] ?? '', ENT_QUOTES) . "</td>
|
||||||
|
<td>{$t['metodo_pago']}</td>
|
||||||
|
<td>{$cobrado}</td>
|
||||||
|
<td>{$t['espera_min']}</td>
|
||||||
|
<td>{$t['servicio_min']}</td>
|
||||||
|
<td>{$t['total_min']}</td>
|
||||||
|
<td>{$hora}</td>
|
||||||
|
</tr>\n";
|
||||||
|
}
|
||||||
|
echo '</table></body></html>';
|
||||||
@@ -17,8 +17,10 @@ $turnoId = (int)($body['turno_id'] ?? 0);
|
|||||||
$formularioId = (int)($body['formulario_id'] ?? 0);
|
$formularioId = (int)($body['formulario_id'] ?? 0);
|
||||||
$svg = $body['svg'] ?? '';
|
$svg = $body['svg'] ?? '';
|
||||||
$soloPro = !empty($body['solo_profesional']);
|
$soloPro = !empty($body['solo_profesional']);
|
||||||
$datosResp = (isset($body['datos_respuestas']) && is_array($body['datos_respuestas']))
|
// Siempre mergeamos datos_respuestas (incluye _pro_nombre/_pro_cedula)
|
||||||
? json_encode($body['datos_respuestas'], JSON_UNESCAPED_UNICODE) : null;
|
$datosResp = null;
|
||||||
|
$datosArr = (isset($body['datos_respuestas']) && is_array($body['datos_respuestas']))
|
||||||
|
? $body['datos_respuestas'] : [];
|
||||||
|
|
||||||
if (!$turnoId) jsonError('turno_id requerido.');
|
if (!$turnoId) jsonError('turno_id requerido.');
|
||||||
if (!$formularioId) jsonError('formulario_id requerido.');
|
if (!$formularioId) jsonError('formulario_id requerido.');
|
||||||
@@ -29,6 +31,32 @@ if (!preg_match('/^data:image\/(svg\+xml|png|jpeg|webp);base64,/i', $svg)) {
|
|||||||
|
|
||||||
$pdo = db();
|
$pdo = db();
|
||||||
|
|
||||||
|
// Quién firma se resuelve aquí y no se toma del navegador. El cliente manda el
|
||||||
|
// nombre que cargó al abrir la página, y si el personal cambia de turno sin
|
||||||
|
// recargar, la firma quedaba a nombre de quien abrió el formulario en vez de
|
||||||
|
// quien la está haciendo. La sesión del servidor es la única fuente confiable.
|
||||||
|
// (guardar_toma.php ya lo hacía así para cada toma; esto alinea la firma final.)
|
||||||
|
$__uid = adminId();
|
||||||
|
if ($__uid) {
|
||||||
|
$stmtPro = $pdo->prepare(
|
||||||
|
"SELECT au.full_name, au.cedula AS au_cedula,
|
||||||
|
le.nombre_completo AS pro_nombre, le.numero_documento AS pro_documento
|
||||||
|
FROM admin_users au
|
||||||
|
LEFT JOIN lab_enfermeras le ON le.id = au.enfermera_id
|
||||||
|
WHERE au.id = ? LIMIT 1"
|
||||||
|
);
|
||||||
|
$stmtPro->execute([$__uid]);
|
||||||
|
$pro = $stmtPro->fetch(PDO::FETCH_ASSOC) ?: [];
|
||||||
|
$proNombre = ($pro['pro_nombre'] ?? null) ?: ($pro['full_name'] ?? null);
|
||||||
|
$proCedula = ($pro['au_cedula'] ?? null) ?: ($pro['pro_documento'] ?? null);
|
||||||
|
if ($proNombre) $datosArr['_pro_nombre'] = $proNombre;
|
||||||
|
if ($proCedula) $datosArr['_pro_cedula'] = $proCedula;
|
||||||
|
$datosArr['_pro_id'] = $__uid;
|
||||||
|
}
|
||||||
|
if (!empty($datosArr)) {
|
||||||
|
$datosResp = json_encode($datosArr, JSON_UNESCAPED_UNICODE);
|
||||||
|
}
|
||||||
|
|
||||||
$stmt = $pdo->prepare(
|
$stmt = $pdo->prepare(
|
||||||
"SELECT tc.id, tc.estado, t.sesion_id
|
"SELECT tc.id, tc.estado, t.sesion_id
|
||||||
FROM turnero_consentimientos tc
|
FROM turnero_consentimientos tc
|
||||||
@@ -52,9 +80,12 @@ if ($soloPro) {
|
|||||||
} else {
|
} else {
|
||||||
$pdo->prepare(
|
$pdo->prepare(
|
||||||
"UPDATE turnero_consentimientos
|
"UPDATE turnero_consentimientos
|
||||||
SET firma_profesional_svg = ?, firmado_profesional_at = NOW()
|
SET firma_profesional_svg = ?, firmado_profesional_at = NOW(),
|
||||||
|
datos_respuestas = CASE WHEN ? IS NOT NULL
|
||||||
|
THEN JSON_MERGE_PATCH(COALESCE(datos_respuestas,'{}'), ?)
|
||||||
|
ELSE datos_respuestas END
|
||||||
WHERE turno_id = ? AND formulario_id = ?"
|
WHERE turno_id = ? AND formulario_id = ?"
|
||||||
)->execute([$svg, $turnoId, $formularioId]);
|
)->execute([$svg, $datosResp, $datosResp, $turnoId, $formularioId]);
|
||||||
}
|
}
|
||||||
|
|
||||||
notificarSSE((int)$tc['sesion_id']);
|
notificarSSE((int)$tc['sesion_id']);
|
||||||
|
|||||||
@@ -0,0 +1,171 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* modules/turnero/api/get_bandeja.php
|
||||||
|
* GET → lista + stats del día (sesion activa)
|
||||||
|
* GET ?turno_id=X → detalle completo de un turno
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/_helpers.php';
|
||||||
|
requireTurnero();
|
||||||
|
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
$sesionId = (int)($pdo->query(
|
||||||
|
"SELECT id FROM turnero_sesiones ORDER BY id DESC LIMIT 1"
|
||||||
|
)->fetchColumn() ?: 0);
|
||||||
|
|
||||||
|
// ── DETALLE ───────────────────────────────────────────────────
|
||||||
|
if (isset($_GET['turno_id'])) {
|
||||||
|
$tid = (int)$_GET['turno_id'];
|
||||||
|
|
||||||
|
$turno = $pdo->prepare("
|
||||||
|
SELECT t.id, t.codigo, t.estado, t.paciente_nombre, t.inicio_lugar_at, t.fin_lugar_at,
|
||||||
|
t.bandeja_visto_at,
|
||||||
|
ts.id AS sol_id, ts.numero_orden,
|
||||||
|
p.tipo_documento, p.numero_documento, p.fecha_nacimiento, p.telefono,
|
||||||
|
l.nombre AS lugar_nombre
|
||||||
|
FROM turnero_turnos t
|
||||||
|
JOIN turnero_solicitudes ts ON ts.turno_id = t.id
|
||||||
|
LEFT JOIN lab_pacientes p ON p.id = ts.paciente_id
|
||||||
|
LEFT JOIN turnero_lugares l ON l.id = t.lugar_destino_id
|
||||||
|
WHERE t.id = ? LIMIT 1
|
||||||
|
");
|
||||||
|
$turno->execute([$tid]);
|
||||||
|
$data = $turno->fetch(PDO::FETCH_ASSOC);
|
||||||
|
if (!$data) jsonError('Turno no encontrado', 404);
|
||||||
|
|
||||||
|
$solId = (int)$data['sol_id'];
|
||||||
|
|
||||||
|
// Exámenes agrupados por categoría
|
||||||
|
$stmtEx = $pdo->prepare("
|
||||||
|
SELECT et.codigo, et.nombre, COALESCE(NULLIF(et.categoria,''),'Otros') AS categoria
|
||||||
|
FROM turnero_examen_items tei
|
||||||
|
JOIN exam_tipos et ON et.id = tei.exam_tipo_id
|
||||||
|
WHERE tei.solicitud_id = ?
|
||||||
|
ORDER BY categoria, et.nombre
|
||||||
|
");
|
||||||
|
$stmtEx->execute([$solId]);
|
||||||
|
$examenesPorCat = [];
|
||||||
|
foreach ($stmtEx->fetchAll(PDO::FETCH_ASSOC) as $e) {
|
||||||
|
$examenesPorCat[$e['categoria']][] = $e['codigo'];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Muestras
|
||||||
|
$stmtM = $pdo->prepare("
|
||||||
|
SELECT tm.id, tm.tipo_muestra, tm.estado, tm.motivo_rechazo, tm.recibida_at
|
||||||
|
FROM turnero_muestras tm
|
||||||
|
WHERE tm.solicitud_id = ?
|
||||||
|
ORDER BY tm.id
|
||||||
|
");
|
||||||
|
$stmtM->execute([$solId]);
|
||||||
|
$muestras = $stmtM->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// Consentimientos
|
||||||
|
$stmtC = $pdo->prepare("
|
||||||
|
SELECT tc.id, tc.estado, tc.token, tc.firmado_at,
|
||||||
|
lf.nombre AS formulario_nombre
|
||||||
|
FROM turnero_consentimientos tc
|
||||||
|
JOIN lab_formularios lf ON lf.id = tc.formulario_id
|
||||||
|
WHERE tc.turno_id = ?
|
||||||
|
ORDER BY tc.id
|
||||||
|
");
|
||||||
|
$stmtC->execute([$tid]);
|
||||||
|
$consentimientos = $stmtC->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// Comentarios (todos los tipos)
|
||||||
|
$stmtCom = $pdo->prepare("
|
||||||
|
SELECT tipo, usuario_nombre, comentario, creado_at
|
||||||
|
FROM turnero_comentarios
|
||||||
|
WHERE turno_id = ?
|
||||||
|
ORDER BY creado_at ASC
|
||||||
|
");
|
||||||
|
$stmtCom->execute([$tid]);
|
||||||
|
$comentarios = $stmtCom->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// Turnos vinculados por muestras pendientes completadas en otra visita (ambos sentidos)
|
||||||
|
$relacionados = [];
|
||||||
|
$stmtRelFwd = $pdo->prepare("
|
||||||
|
SELECT t2.id AS rel_id, t2.codigo AS rel_codigo, s2.fecha AS rel_fecha
|
||||||
|
FROM turnero_muestras tm
|
||||||
|
JOIN turnero_turnos t2 ON t2.id = tm.recibida_en_turno_id
|
||||||
|
JOIN turnero_sesiones s2 ON s2.id = t2.sesion_id
|
||||||
|
WHERE tm.solicitud_id = ? AND tm.recibida_en_turno_id IS NOT NULL
|
||||||
|
");
|
||||||
|
$stmtRelFwd->execute([$solId]);
|
||||||
|
foreach ($stmtRelFwd->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
||||||
|
$relacionados[] = ['turno_id' => (int)$r['rel_id'], 'codigo' => $r['rel_codigo'], 'fecha' => $r['rel_fecha'], 'direccion' => 'seguimiento'];
|
||||||
|
}
|
||||||
|
$stmtRelBack = $pdo->prepare("
|
||||||
|
SELECT t1.id AS rel_id, t1.codigo AS rel_codigo, s1.fecha AS rel_fecha
|
||||||
|
FROM turnero_muestras tm
|
||||||
|
JOIN turnero_solicitudes ts1 ON ts1.id = tm.solicitud_id
|
||||||
|
JOIN turnero_turnos t1 ON t1.id = ts1.turno_id
|
||||||
|
JOIN turnero_sesiones s1 ON s1.id = t1.sesion_id
|
||||||
|
WHERE tm.recibida_en_turno_id = ?
|
||||||
|
");
|
||||||
|
$stmtRelBack->execute([$tid]);
|
||||||
|
foreach ($stmtRelBack->fetchAll(PDO::FETCH_ASSOC) as $r) {
|
||||||
|
$relacionados[] = ['turno_id' => (int)$r['rel_id'], 'codigo' => $r['rel_codigo'], 'fecha' => $r['rel_fecha'], 'direccion' => 'origen'];
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonOk([
|
||||||
|
'turno' => $data,
|
||||||
|
'examenes_por_cat'=> $examenesPorCat,
|
||||||
|
'muestras' => $muestras,
|
||||||
|
'consentimientos' => $consentimientos,
|
||||||
|
'comentarios' => $comentarios,
|
||||||
|
'relacionados' => $relacionados,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── LISTA ─────────────────────────────────────────────────────
|
||||||
|
$desde = $_GET['desde'] ?? null;
|
||||||
|
$hasta = $_GET['hasta'] ?? null;
|
||||||
|
|
||||||
|
if ($desde && $hasta) {
|
||||||
|
$whereClause = "DATE(t.inicio_lugar_at) BETWEEN ? AND ?";
|
||||||
|
$params = [$desde, $hasta];
|
||||||
|
} else {
|
||||||
|
$whereClause = "t.sesion_id = ?";
|
||||||
|
$params = [$sesionId];
|
||||||
|
}
|
||||||
|
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
SELECT t.id, t.codigo, t.paciente_nombre, t.estado,
|
||||||
|
t.inicio_lugar_at, t.fin_lugar_at, t.bandeja_visto_at, t.muestra_espera_at,
|
||||||
|
ts.numero_orden,
|
||||||
|
l.nombre AS lugar_nombre,
|
||||||
|
GROUP_CONCAT(DISTINCT et.codigo ORDER BY et.codigo SEPARATOR ' ') AS examenes_txt,
|
||||||
|
SUM(tc.tipo = 'recepcion') AS com_recepcion,
|
||||||
|
SUM(tc.tipo = 'muestras') AS com_muestras,
|
||||||
|
SUM(tc.tipo = 'general') AS com_general,
|
||||||
|
(SELECT COUNT(*) FROM turnero_muestras tm2 WHERE tm2.solicitud_id = ts.id AND tm2.estado = 'rechazada') AS muestras_pendientes
|
||||||
|
FROM turnero_turnos t
|
||||||
|
JOIN turnero_solicitudes ts ON ts.turno_id = t.id
|
||||||
|
JOIN turnero_lugares l ON l.id = t.lugar_destino_id
|
||||||
|
LEFT JOIN turnero_examen_items tei ON tei.solicitud_id = ts.id
|
||||||
|
LEFT JOIN exam_tipos et ON et.id = tei.exam_tipo_id
|
||||||
|
LEFT JOIN turnero_comentarios tc ON tc.turno_id = t.id
|
||||||
|
WHERE $whereClause AND t.inicio_lugar_at IS NOT NULL
|
||||||
|
GROUP BY t.id
|
||||||
|
ORDER BY ts.numero_orden ASC
|
||||||
|
");
|
||||||
|
$stmt->execute($params);
|
||||||
|
$todos = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$pendientes = array_values(array_filter($todos, fn($t) => $t['bandeja_visto_at'] === null));
|
||||||
|
$vistos = array_values(array_filter($todos, fn($t) => $t['bandeja_visto_at'] !== null));
|
||||||
|
|
||||||
|
$muestrasPendientes = count(array_filter($todos, fn($t) => (int)$t['muestras_pendientes'] > 0));
|
||||||
|
$prolongadasPendientes = count(array_filter($todos, fn($t) => !empty($t['muestra_espera_at']) && empty($t['fin_lugar_at'])));
|
||||||
|
|
||||||
|
jsonOk([
|
||||||
|
'pendientes' => $pendientes,
|
||||||
|
'vistos' => $vistos,
|
||||||
|
'stats' => [
|
||||||
|
'total' => count($todos),
|
||||||
|
'pendientes' => count($pendientes),
|
||||||
|
'vistos' => count($vistos),
|
||||||
|
'muestras_pendientes' => $muestrasPendientes,
|
||||||
|
'prolongadas_pendientes'=> $prolongadasPendientes,
|
||||||
|
],
|
||||||
|
]);
|
||||||
@@ -83,7 +83,8 @@ if ($area === 'recepcion') {
|
|||||||
LEFT JOIN turnero_solicitudes s ON s.turno_id = t.id
|
LEFT JOIN turnero_solicitudes s ON s.turno_id = t.id
|
||||||
WHERE t.sesion_id = ?
|
WHERE t.sesion_id = ?
|
||||||
AND t.estado = 'en_espera_lugar'
|
AND t.estado = 'en_espera_lugar'
|
||||||
AND t.lugar_destino_id IN ($inLugares))
|
AND t.lugar_destino_id IN ($inLugares)
|
||||||
|
AND t.muestra_espera_at IS NULL)
|
||||||
UNION ALL
|
UNION ALL
|
||||||
(SELECT $cols
|
(SELECT $cols
|
||||||
FROM turnero_turnos t
|
FROM turnero_turnos t
|
||||||
@@ -91,7 +92,8 @@ if ($area === 'recepcion') {
|
|||||||
LEFT JOIN turnero_solicitudes s ON s.turno_id = t.id
|
LEFT JOIN turnero_solicitudes s ON s.turno_id = t.id
|
||||||
WHERE t.sesion_id = ?
|
WHERE t.sesion_id = ?
|
||||||
AND t.estado = 'en_servicio'
|
AND t.estado = 'en_servicio'
|
||||||
AND t.lugar_destino_id = ?)
|
AND t.lugar_destino_id = ?
|
||||||
|
AND t.muestra_espera_at IS NULL)
|
||||||
) AS cola_union
|
) AS cola_union
|
||||||
ORDER BY solo_muestras DESC, orden_peso ASC, creado_at ASC"
|
ORDER BY solo_muestras DESC, orden_peso ASC, creado_at ASC"
|
||||||
);
|
);
|
||||||
@@ -126,7 +128,7 @@ if (!empty($cola)) {
|
|||||||
$campoLlamado = $area === 'recepcion' ? 'llamado_recepcion_at' : 'llamado_lugar_at';
|
$campoLlamado = $area === 'recepcion' ? 'llamado_recepcion_at' : 'llamado_lugar_at';
|
||||||
$estadoActivo = $area === 'recepcion' ? "'en_recepcion'" : "'en_servicio'";
|
$estadoActivo = $area === 'recepcion' ? "'en_recepcion'" : "'en_servicio'";
|
||||||
// Activo: para lugar filtrar SOLO por esta estación (ya fue asignada al llamar)
|
// Activo: para lugar filtrar SOLO por esta estación (ya fue asignada al llamar)
|
||||||
$filtroActivo = $area === 'lugar' ? 'AND t.lugar_destino_id = ?' : '';
|
$filtroActivo = $area === 'lugar' ? 'AND t.lugar_destino_id = ? AND t.muestra_espera_at IS NULL' : '';
|
||||||
$bindActivo = $area === 'lugar' ? [$sesionId, $lugarId] : [$sesionId];
|
$bindActivo = $area === 'lugar' ? [$sesionId, $lugarId] : [$sesionId];
|
||||||
|
|
||||||
$stmt = $pdo->prepare(
|
$stmt = $pdo->prepare(
|
||||||
@@ -173,12 +175,33 @@ $stmt = $pdo->prepare(
|
|||||||
$stmt->execute([$sesionId]);
|
$stmt->execute([$sesionId]);
|
||||||
$stats = $stmt->fetch(PDO::FETCH_ASSOC);
|
$stats = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// ── Pacientes en espera entre muestras (toma progresiva) ─────
|
||||||
|
$enEsperaMuestra = [];
|
||||||
|
if ($area === 'lugar') {
|
||||||
|
$stmtEsp = $pdo->prepare("
|
||||||
|
SELECT t.id, t.paciente_nombre, ts.numero_orden, t.muestra_espera_at,
|
||||||
|
(SELECT tc.siguiente_toma_at
|
||||||
|
FROM turnero_consentimientos tc
|
||||||
|
JOIN lab_formularios f ON f.id = tc.formulario_id AND f.es_toma_progresiva = 1
|
||||||
|
WHERE tc.turno_id = t.id AND tc.estado = 'en_progreso'
|
||||||
|
ORDER BY tc.siguiente_toma_at ASC LIMIT 1) AS siguiente_toma_at
|
||||||
|
FROM turnero_turnos t
|
||||||
|
JOIN turnero_solicitudes ts ON ts.turno_id = t.id
|
||||||
|
WHERE t.sesion_id = ? AND t.lugar_destino_id IN ($inLugares)
|
||||||
|
AND t.muestra_espera_at IS NOT NULL AND t.fin_lugar_at IS NULL
|
||||||
|
ORDER BY t.muestra_espera_at ASC
|
||||||
|
");
|
||||||
|
$stmtEsp->execute([$sesionId]);
|
||||||
|
$enEsperaMuestra = $stmtEsp->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
}
|
||||||
|
|
||||||
jsonOk([
|
jsonOk([
|
||||||
'sesion_id' => $sesionId,
|
'sesion_id' => $sesionId,
|
||||||
'area' => $area,
|
'area' => $area,
|
||||||
'lugar_id' => $lugarId,
|
'lugar_id' => $lugarId,
|
||||||
'activo' => $activo,
|
'activo' => $activo,
|
||||||
'cola' => $cola,
|
'cola' => $cola,
|
||||||
'stats' => $stats,
|
'en_espera_muestra' => $enEsperaMuestra,
|
||||||
'timestamp' => date('c'),
|
'stats' => $stats,
|
||||||
|
'timestamp' => date('c'),
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
<?php
|
<?php
|
||||||
/**
|
/**
|
||||||
* GET modules/turnero/api/get_consecutivo.php
|
* GET modules/turnero/api/get_consecutivo.php
|
||||||
* Devuelve el siguiente número de orden del día.
|
* Devuelve el siguiente número de orden del día — atómico, sin race condition.
|
||||||
*
|
*
|
||||||
* Parámetros:
|
* Parámetros:
|
||||||
* tipo string 'F' = Físico/turnero (default) | 'D' = Domicilio
|
* tipo string 'F' = Físico/turnero (default) | 'D' = Domicilio
|
||||||
*
|
*
|
||||||
* Formato resultante:
|
* Formato: F-YYYYMMDD-NNN | D-YYYYMMDD-NNN
|
||||||
* F-YYYYMMDD-NNN para tipo=F
|
*
|
||||||
* D-YYYYMMDD-NNN para tipo=D
|
* Usa turnero_consecutivos con UPDATE ... LAST_INSERT_ID() para garantizar
|
||||||
|
* que dos peticiones simultáneas nunca retornen el mismo número.
|
||||||
*/
|
*/
|
||||||
require_once __DIR__ . '/_helpers.php';
|
require_once __DIR__ . '/_helpers.php';
|
||||||
requireTurnero();
|
requireTurnero();
|
||||||
@@ -18,23 +19,17 @@ $tipo = strtoupper(trim($_GET['tipo'] ?? 'F'));
|
|||||||
if (!in_array($tipo, ['F', 'D'], true)) $tipo = 'F';
|
if (!in_array($tipo, ['F', 'D'], true)) $tipo = 'F';
|
||||||
|
|
||||||
$pdo = db();
|
$pdo = db();
|
||||||
$prefix = $tipo . '-' . date('Ymd') . '-';
|
$prefijo = $tipo . '-' . date('Ymd'); // e.g. 'F-20260727'
|
||||||
|
$prefix = $prefijo . '-';
|
||||||
|
|
||||||
if ($tipo === 'D') {
|
// Asegurar que exista la fila del día (INSERT IGNORE no toca si ya existe)
|
||||||
$stmt = $pdo->prepare(
|
$pdo->prepare("INSERT IGNORE INTO turnero_consecutivos (prefijo, ultimo) VALUES (?, 0)")
|
||||||
"SELECT MAX(CAST(SUBSTRING_INDEX(numero_orden, '-', -1) AS UNSIGNED)) AS ultimo
|
->execute([$prefijo]);
|
||||||
FROM lab_domicilios
|
|
||||||
WHERE numero_orden LIKE ?"
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
$stmt = $pdo->prepare(
|
|
||||||
"SELECT MAX(CAST(SUBSTRING_INDEX(numero_orden, '-', -1) AS UNSIGNED)) AS ultimo
|
|
||||||
FROM turnero_solicitudes
|
|
||||||
WHERE numero_orden LIKE ?"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
$stmt->execute([$prefix . '%']);
|
|
||||||
$ultimo = (int)($stmt->fetch(PDO::FETCH_ASSOC)['ultimo'] ?? 0);
|
|
||||||
|
|
||||||
$siguiente = $prefix . str_pad($ultimo + 1, 3, '0', STR_PAD_LEFT);
|
// Incremento atómico: UPDATE toma lock de fila → dos hilos nunca leen el mismo valor
|
||||||
jsonOk(['consecutivo' => $siguiente]);
|
$pdo->prepare("UPDATE turnero_consecutivos SET ultimo = LAST_INSERT_ID(ultimo + 1) WHERE prefijo = ?")
|
||||||
|
->execute([$prefijo]);
|
||||||
|
|
||||||
|
$siguiente = (int)$pdo->query("SELECT LAST_INSERT_ID()")->fetchColumn();
|
||||||
|
|
||||||
|
jsonOk(['consecutivo' => $prefix . str_pad($siguiente, 3, '0', STR_PAD_LEFT)]);
|
||||||
|
|||||||
@@ -46,7 +46,7 @@ $token = sprintf(
|
|||||||
mt_rand(0,0xffff), mt_rand(0,0xffff), mt_rand(0,0xffff)
|
mt_rand(0,0xffff), mt_rand(0,0xffff), mt_rand(0,0xffff)
|
||||||
);
|
);
|
||||||
$pdo->prepare(
|
$pdo->prepare(
|
||||||
"INSERT IGNORE INTO turnero_consentimientos (turno_id, formulario_id, token, estado) VALUES (?, ?, ?, 'pendiente')"
|
"INSERT IGNORE INTO turnero_consentimientos (turno_id, formulario_id, token, estado, creado_por) VALUES (?, ?, ?, 'pendiente', ?)"
|
||||||
)->execute([$turnoId, $formularioId, $token]);
|
)->execute([$turnoId, $formularioId, $token, adminId()]);
|
||||||
|
|
||||||
jsonOk(['token' => $token, 'nuevo' => true]);
|
jsonOk(['token' => $token, 'nuevo' => true]);
|
||||||
|
|||||||
@@ -56,7 +56,8 @@ $stmt = $pdo->prepare(
|
|||||||
tc.datos_respuestas,
|
tc.datos_respuestas,
|
||||||
f.nombre AS formulario_nombre,
|
f.nombre AS formulario_nombre,
|
||||||
f.esquema AS formulario_esquema,
|
f.esquema AS formulario_esquema,
|
||||||
f.es_toma_progresiva AS formulario_es_toma_prog
|
f.es_toma_progresiva AS formulario_es_toma_prog,
|
||||||
|
f.solo_profesional AS formulario_solo_profesional
|
||||||
FROM turnero_consentimientos tc
|
FROM turnero_consentimientos tc
|
||||||
LEFT JOIN lab_formularios f ON f.id = tc.formulario_id
|
LEFT JOIN lab_formularios f ON f.id = tc.formulario_id
|
||||||
WHERE tc.turno_id = ?
|
WHERE tc.turno_id = ?
|
||||||
@@ -80,15 +81,19 @@ foreach ($consentimientos as &$c) {
|
|||||||
// Toma progresiva: override explícito en DB gana; si es NULL, usar heurística (>1 campo firma_profesional)
|
// Toma progresiva: override explícito en DB gana; si es NULL, usar heurística (>1 campo firma_profesional)
|
||||||
$overrideProg = $c['formulario_es_toma_prog'] ?? null;
|
$overrideProg = $c['formulario_es_toma_prog'] ?? null;
|
||||||
$esTomaProg = $overrideProg !== null ? (bool)(int)$overrideProg : count($camposFirmaPro) > 1;
|
$esTomaProg = $overrideProg !== null ? (bool)(int)$overrideProg : count($camposFirmaPro) > 1;
|
||||||
$tomasTotal = 0;
|
$dr = $c['datos_respuestas'] ? (json_decode($c['datos_respuestas'], true) ?? []) : [];
|
||||||
$tomasFirm = 0;
|
$tomasTotal = 0;
|
||||||
|
$tomasFirm = 0;
|
||||||
|
$tomasDetalle = []; // reiniciar cada vuelta: si no, un formulario sin tomas hereda las del anterior
|
||||||
if ($esTomaProg) {
|
if ($esTomaProg) {
|
||||||
$dr = $c['datos_respuestas'] ? (json_decode($c['datos_respuestas'], true) ?? []) : [];
|
|
||||||
|
|
||||||
// Mapear cada firma_profesional a los valores de examen de su sección condicional
|
// Mapear cada firma_profesional a los valores de examen de su sección condicional
|
||||||
|
// (y a su etiqueta + campo hora, para poder mostrar el detalle por toma)
|
||||||
$firmaCondMap = [];
|
$firmaCondMap = [];
|
||||||
$curCondCampo = null;
|
$curCondCampo = null;
|
||||||
$curCondVals = [];
|
$curCondVals = [];
|
||||||
|
$curLabel = null;
|
||||||
|
$curHoraId = null;
|
||||||
foreach ($campos as $campo) {
|
foreach ($campos as $campo) {
|
||||||
$ft = $campo['tipo'] ?? '';
|
$ft = $campo['tipo'] ?? '';
|
||||||
if ($ft === 'separador') {
|
if ($ft === 'separador') {
|
||||||
@@ -97,8 +102,15 @@ foreach ($consentimientos as &$c) {
|
|||||||
$curCondVals = $cond
|
$curCondVals = $cond
|
||||||
? ($cond['valores'] ?? ($cond['valor'] ? [$cond['valor']] : []))
|
? ($cond['valores'] ?? ($cond['valor'] ? [$cond['valor']] : []))
|
||||||
: [];
|
: [];
|
||||||
|
$curLabel = $campo['label'] ?? '';
|
||||||
|
$curHoraId = null;
|
||||||
|
} elseif ($ft === 'hora') {
|
||||||
|
$curHoraId = $campo['id'] ?? null;
|
||||||
} elseif ($ft === 'firma_profesional' && !empty($campo['id'])) {
|
} elseif ($ft === 'firma_profesional' && !empty($campo['id'])) {
|
||||||
$firmaCondMap[$campo['id']] = ['campo' => $curCondCampo, 'valores' => $curCondVals];
|
$firmaCondMap[$campo['id']] = [
|
||||||
|
'campo' => $curCondCampo, 'valores' => $curCondVals,
|
||||||
|
'label' => $curLabel, 'hora_id' => $curHoraId,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,27 +131,48 @@ foreach ($consentimientos as &$c) {
|
|||||||
// Si hay config de tomas guardada, restringir al subconjunto seleccionado
|
// Si hay config de tomas guardada, restringir al subconjunto seleccionado
|
||||||
$tomasConfig = is_array($dr['_tomas_config'] ?? null) ? $dr['_tomas_config'] : null;
|
$tomasConfig = is_array($dr['_tomas_config'] ?? null) ? $dr['_tomas_config'] : null;
|
||||||
if ($tomasConfig !== null) {
|
if ($tomasConfig !== null) {
|
||||||
$allowedFids = array_merge(...array_values($tomasConfig));
|
$allowedFids = [];
|
||||||
|
foreach ($tomasConfig as $__v) {
|
||||||
|
if (!is_array($__v)) continue;
|
||||||
|
$__inner = reset($__v);
|
||||||
|
// nested: exam => { group => [fids] }; flat: group => [fids]
|
||||||
|
if (is_array($__inner)) { foreach ($__v as $__fids) foreach ($__fids as $__f) $allowedFids[] = $__f; }
|
||||||
|
else { foreach ($__v as $__f) $allowedFids[] = $__f; }
|
||||||
|
}
|
||||||
|
$allowedFids = array_unique($allowedFids);
|
||||||
$relevantIds = array_values(array_filter($relevantIds, fn($id) => in_array($id, $allowedFids, true)));
|
$relevantIds = array_values(array_filter($relevantIds, fn($id) => in_array($id, $allowedFids, true)));
|
||||||
}
|
}
|
||||||
|
|
||||||
$tomasTotal = count($relevantIds);
|
$tomasTotal = count($relevantIds);
|
||||||
|
$tomasDetalle = [];
|
||||||
foreach ($relevantIds as $fid) {
|
foreach ($relevantIds as $fid) {
|
||||||
if ((!empty($dr[$fid]) && strlen($dr[$fid]) > 10)
|
$firmado = (!empty($dr[$fid]) && strlen($dr[$fid]) > 10)
|
||||||
|| (!empty($dr[$fid.'_svg']) && strlen($dr[$fid.'_svg']) > 10)) $tomasFirm++;
|
|| (!empty($dr[$fid.'_svg']) && strlen($dr[$fid.'_svg']) > 10);
|
||||||
|
if ($firmado) $tomasFirm++;
|
||||||
|
$info = $firmaCondMap[$fid] ?? [];
|
||||||
|
$horaId = $info['hora_id'] ?? null;
|
||||||
|
$tomasDetalle[] = [
|
||||||
|
'label' => $info['label'] ?? '',
|
||||||
|
'hora' => $horaId ? ($dr[$horaId] ?? null) : null,
|
||||||
|
'firmado' => $firmado,
|
||||||
|
'firmante' => $firmado ? ($dr[$fid . '_pro_nombre'] ?? $dr['_pro_nombre'] ?? null) : null,
|
||||||
|
];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
$soloPro = !empty($c['formulario_solo_profesional']);
|
||||||
$c['requiere_firma_profesional'] = $tieneFirmaPro;
|
$c['requiere_firma_profesional'] = $tieneFirmaPro;
|
||||||
$c['requiere_firma_paciente'] = !($tieneFirmaPro && !$tieneFirmaPac);
|
$c['requiere_firma_paciente'] = $soloPro ? false : !($tieneFirmaPro && !$tieneFirmaPac);
|
||||||
$c['origen_lugar_id'] = in_array((int)$c['formulario_id'], $fidsDeLugar, true)
|
$c['origen_lugar_id'] = in_array((int)$c['formulario_id'], $fidsDeLugar, true)
|
||||||
? ($lugarDestinoId ?: -1) : null;
|
? ($lugarDestinoId ?: -1) : null;
|
||||||
$c['es_toma_progresiva'] = $esTomaProg;
|
$c['es_toma_progresiva'] = $esTomaProg;
|
||||||
$c['tomas_total'] = $tomasTotal;
|
$c['tomas_total'] = $tomasTotal;
|
||||||
$c['tomas_firmadas'] = $tomasFirm;
|
$c['tomas_firmadas'] = $tomasFirm;
|
||||||
|
$c['tomas_detalle'] = $tomasDetalle ?? [];
|
||||||
|
$c['cierre_anticipado'] = $dr['_cierre_anticipado'] ?? null;
|
||||||
// Exponer campos firma_profesional (ids) para el frontend
|
// Exponer campos firma_profesional (ids) para el frontend
|
||||||
$c['campos_firma_pro'] = array_column($camposFirmaPro, 'id');
|
$c['campos_firma_pro'] = array_column($camposFirmaPro, 'id');
|
||||||
unset($c['formulario_esquema'], $c['formulario_es_toma_prog'], $c['datos_respuestas']);
|
unset($c['formulario_esquema'], $c['formulario_es_toma_prog'], $c['formulario_solo_profesional'], $c['datos_respuestas']);
|
||||||
}
|
}
|
||||||
unset($c);
|
unset($c);
|
||||||
|
|
||||||
@@ -193,34 +226,9 @@ if ($incluirSolicitud) {
|
|||||||
$stmt->execute([$solicitud['id']]);
|
$stmt->execute([$solicitud['id']]);
|
||||||
$examenes = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
$examenes = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
// Visita de solo entrega de muestras: no requiere consentimientos ni formularios
|
// Nota: en visitas de "solo entrega de muestras" no hay exámenes, así que la
|
||||||
if (!empty($solicitud['solo_muestras'])) {
|
// Fuente 1 (por examen) no aplica, pero la Fuente 2 (por lugar, ej. F-LAB-08
|
||||||
$respuesta['consentimientos'] = [];
|
// Datos Toma de Muestras) sigue siendo obligatoria — se procesa más abajo igual.
|
||||||
$respuesta['solicitud'] = $solicitud;
|
|
||||||
$respuesta['paciente'] = $paciente;
|
|
||||||
$respuesta['examenes'] = $examenes;
|
|
||||||
// Muestras pendientes de visitas anteriores
|
|
||||||
$muestras = [];
|
|
||||||
try {
|
|
||||||
$stmtPrev = $pdo->prepare(
|
|
||||||
"SELECT tm.id, tm.tipo_muestra, tm.estado, tm.motivo_rechazo, tm.recibida_at,
|
|
||||||
COALESCE(lt.nombre, tm.tipo_muestra) AS label,
|
|
||||||
1 AS es_pendiente_anterior,
|
|
||||||
ts.numero_orden AS solicitud_orden_anterior
|
|
||||||
FROM turnero_muestras tm
|
|
||||||
JOIN turnero_solicitudes ts ON ts.id = tm.solicitud_id
|
|
||||||
LEFT JOIN lab_tipos_muestra lt ON lt.codigo = tm.tipo_muestra
|
|
||||||
WHERE ts.paciente_id = ?
|
|
||||||
AND tm.solicitud_id != ?
|
|
||||||
AND tm.estado = 'pendiente'
|
|
||||||
ORDER BY tm.id ASC"
|
|
||||||
);
|
|
||||||
$stmtPrev->execute([$solicitud['paciente_id'], $solicitud['id']]);
|
|
||||||
$muestras = $stmtPrev->fetchAll(PDO::FETCH_ASSOC);
|
|
||||||
} catch (\Throwable $_) {}
|
|
||||||
$respuesta['muestras'] = $muestras;
|
|
||||||
jsonOk($respuesta);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Auto-crear consentimientos requeridos desde DOS fuentes:
|
// Auto-crear consentimientos requeridos desde DOS fuentes:
|
||||||
// 1. Por examen → exam_tipo_consentimientos
|
// 1. Por examen → exam_tipo_consentimientos
|
||||||
@@ -271,8 +279,8 @@ if ($incluirSolicitud) {
|
|||||||
$existFormIds = array_map('intval', array_column($consentimientos, 'formulario_id'));
|
$existFormIds = array_map('intval', array_column($consentimientos, 'formulario_id'));
|
||||||
$stmtIns = $pdo->prepare(
|
$stmtIns = $pdo->prepare(
|
||||||
"INSERT IGNORE INTO turnero_consentimientos
|
"INSERT IGNORE INTO turnero_consentimientos
|
||||||
(turno_id, formulario_id, token, estado)
|
(turno_id, formulario_id, token, estado, creado_por)
|
||||||
VALUES (?, ?, ?, 'pendiente')"
|
VALUES (?, ?, ?, 'pendiente', ?)"
|
||||||
);
|
);
|
||||||
$creados = 0;
|
$creados = 0;
|
||||||
foreach ($requeridos as $r) {
|
foreach ($requeridos as $r) {
|
||||||
@@ -285,7 +293,7 @@ if ($incluirSolicitud) {
|
|||||||
mt_rand(0,0x3fff)|0x8000,
|
mt_rand(0,0x3fff)|0x8000,
|
||||||
mt_rand(0,0xffff), mt_rand(0,0xffff), mt_rand(0,0xffff)
|
mt_rand(0,0xffff), mt_rand(0,0xffff), mt_rand(0,0xffff)
|
||||||
);
|
);
|
||||||
$stmtIns->execute([$turnoId, (int)$r['formulario_id'], $token]);
|
$stmtIns->execute([$turnoId, (int)$r['formulario_id'], $token, adminId()]);
|
||||||
$creados++;
|
$creados++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -302,7 +310,8 @@ if ($incluirSolicitud) {
|
|||||||
tc.datos_respuestas,
|
tc.datos_respuestas,
|
||||||
f.nombre AS formulario_nombre,
|
f.nombre AS formulario_nombre,
|
||||||
f.esquema AS formulario_esquema,
|
f.esquema AS formulario_esquema,
|
||||||
f.es_toma_progresiva AS formulario_es_toma_prog
|
f.es_toma_progresiva AS formulario_es_toma_prog,
|
||||||
|
f.solo_profesional AS formulario_solo_profesional
|
||||||
FROM turnero_consentimientos tc
|
FROM turnero_consentimientos tc
|
||||||
LEFT JOIN lab_formularios f ON f.id = tc.formulario_id
|
LEFT JOIN lab_formularios f ON f.id = tc.formulario_id
|
||||||
WHERE tc.turno_id = ?
|
WHERE tc.turno_id = ?
|
||||||
@@ -322,10 +331,10 @@ if ($incluirSolicitud) {
|
|||||||
$tieneFirmaPac = !empty(array_filter($campos, fn($f) => ($f['tipo'] ?? '') === 'firma'));
|
$tieneFirmaPac = !empty(array_filter($campos, fn($f) => ($f['tipo'] ?? '') === 'firma'));
|
||||||
$overrideProg = $c['formulario_es_toma_prog'] ?? null;
|
$overrideProg = $c['formulario_es_toma_prog'] ?? null;
|
||||||
$esTomaProg = $overrideProg !== null ? (bool)(int)$overrideProg : count($camposFirmaPro) > 1;
|
$esTomaProg = $overrideProg !== null ? (bool)(int)$overrideProg : count($camposFirmaPro) > 1;
|
||||||
|
$dr2 = $c['datos_respuestas'] ? (json_decode($c['datos_respuestas'], true) ?? []) : [];
|
||||||
$tomasTotal2 = 0;
|
$tomasTotal2 = 0;
|
||||||
$tomasFirm = 0;
|
$tomasFirm = 0;
|
||||||
if ($esTomaProg) {
|
if ($esTomaProg) {
|
||||||
$dr2 = $c['datos_respuestas'] ? (json_decode($c['datos_respuestas'], true) ?? []) : [];
|
|
||||||
$firmaCondMap2 = [];
|
$firmaCondMap2 = [];
|
||||||
$curC2 = null; $curV2 = [];
|
$curC2 = null; $curV2 = [];
|
||||||
foreach ($campos as $campo2) {
|
foreach ($campos as $campo2) {
|
||||||
@@ -350,7 +359,14 @@ if ($incluirSolicitud) {
|
|||||||
}
|
}
|
||||||
$cfg2 = is_array($dr2['_tomas_config'] ?? null) ? $dr2['_tomas_config'] : null;
|
$cfg2 = is_array($dr2['_tomas_config'] ?? null) ? $dr2['_tomas_config'] : null;
|
||||||
if ($cfg2 !== null) {
|
if ($cfg2 !== null) {
|
||||||
$allowed2 = array_merge(...array_values($cfg2));
|
$allowed2 = [];
|
||||||
|
foreach ($cfg2 as $__v2) {
|
||||||
|
if (!is_array($__v2)) continue;
|
||||||
|
$__i2 = reset($__v2);
|
||||||
|
if (is_array($__i2)) { foreach ($__v2 as $__fids2) foreach ($__fids2 as $__f2) $allowed2[] = $__f2; }
|
||||||
|
else { foreach ($__v2 as $__f2) $allowed2[] = $__f2; }
|
||||||
|
}
|
||||||
|
$allowed2 = array_unique($allowed2);
|
||||||
$relevantIds2 = array_values(array_filter($relevantIds2, fn($id) => in_array($id, $allowed2, true)));
|
$relevantIds2 = array_values(array_filter($relevantIds2, fn($id) => in_array($id, $allowed2, true)));
|
||||||
}
|
}
|
||||||
$tomasTotal2 = count($relevantIds2);
|
$tomasTotal2 = count($relevantIds2);
|
||||||
@@ -358,15 +374,17 @@ if ($incluirSolicitud) {
|
|||||||
if (!empty($dr2[$fid2]) && strlen($dr2[$fid2]) > 10) $tomasFirm++;
|
if (!empty($dr2[$fid2]) && strlen($dr2[$fid2]) > 10) $tomasFirm++;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
$soloPro2 = !empty($c['formulario_solo_profesional']);
|
||||||
$c['requiere_firma_profesional'] = $tieneFirmaPro;
|
$c['requiere_firma_profesional'] = $tieneFirmaPro;
|
||||||
$c['requiere_firma_paciente'] = !($tieneFirmaPro && !$tieneFirmaPac);
|
$c['requiere_firma_paciente'] = $soloPro2 ? false : !($tieneFirmaPro && !$tieneFirmaPac);
|
||||||
$c['origen_lugar_id'] = in_array((int)$c['formulario_id'], $fidsDeLugar, true)
|
$c['origen_lugar_id'] = in_array((int)$c['formulario_id'], $fidsDeLugar, true)
|
||||||
? $lugarDestinoId : null;
|
? $lugarDestinoId : null;
|
||||||
$c['es_toma_progresiva'] = $esTomaProg;
|
$c['es_toma_progresiva'] = $esTomaProg;
|
||||||
$c['tomas_total'] = $tomasTotal2;
|
$c['tomas_total'] = $tomasTotal2;
|
||||||
$c['tomas_firmadas'] = $tomasFirm;
|
$c['tomas_firmadas'] = $tomasFirm;
|
||||||
|
$c['cierre_anticipado'] = $dr2['_cierre_anticipado'] ?? null;
|
||||||
$c['campos_firma_pro'] = array_column($camposFirmaPro, 'id');
|
$c['campos_firma_pro'] = array_column($camposFirmaPro, 'id');
|
||||||
unset($c['formulario_esquema'], $c['formulario_es_toma_prog'], $c['datos_respuestas']);
|
unset($c['formulario_esquema'], $c['formulario_es_toma_prog'], $c['formulario_solo_profesional'], $c['datos_respuestas']);
|
||||||
}
|
}
|
||||||
unset($c);
|
unset($c);
|
||||||
$respuesta['consentimientos'] = $consentimientos;
|
$respuesta['consentimientos'] = $consentimientos;
|
||||||
@@ -396,13 +414,14 @@ if ($incluirSolicitud) {
|
|||||||
"SELECT tm.id, tm.tipo_muestra, tm.estado, tm.motivo_rechazo, tm.recibida_at,
|
"SELECT tm.id, tm.tipo_muestra, tm.estado, tm.motivo_rechazo, tm.recibida_at,
|
||||||
COALESCE(lt.nombre, tm.tipo_muestra) AS label,
|
COALESCE(lt.nombre, tm.tipo_muestra) AS label,
|
||||||
1 AS es_pendiente_anterior,
|
1 AS es_pendiente_anterior,
|
||||||
|
ts.id AS solicitud_id_anterior,
|
||||||
ts.numero_orden AS solicitud_orden_anterior
|
ts.numero_orden AS solicitud_orden_anterior
|
||||||
FROM turnero_muestras tm
|
FROM turnero_muestras tm
|
||||||
JOIN turnero_solicitudes ts ON ts.id = tm.solicitud_id
|
JOIN turnero_solicitudes ts ON ts.id = tm.solicitud_id
|
||||||
LEFT JOIN lab_tipos_muestra lt ON lt.codigo = tm.tipo_muestra
|
LEFT JOIN lab_tipos_muestra lt ON lt.codigo = tm.tipo_muestra
|
||||||
WHERE ts.paciente_id = ?
|
WHERE ts.paciente_id = ?
|
||||||
AND tm.solicitud_id != ?
|
AND tm.solicitud_id != ?
|
||||||
AND tm.estado = 'pendiente'
|
AND tm.estado IN ('pendiente','rechazada')
|
||||||
ORDER BY tm.id ASC"
|
ORDER BY tm.id ASC"
|
||||||
);
|
);
|
||||||
$stmtPrev->execute([$solicitud['paciente_id'], $solicitud['id']]);
|
$stmtPrev->execute([$solicitud['paciente_id'], $solicitud['id']]);
|
||||||
@@ -429,12 +448,13 @@ if ($incluirSolicitud) {
|
|||||||
"SELECT tm.id, tm.tipo_muestra, tm.estado, tm.motivo_rechazo, tm.recibida_at,
|
"SELECT tm.id, tm.tipo_muestra, tm.estado, tm.motivo_rechazo, tm.recibida_at,
|
||||||
COALESCE(lt.nombre, tm.tipo_muestra) AS label,
|
COALESCE(lt.nombre, tm.tipo_muestra) AS label,
|
||||||
1 AS es_pendiente_anterior,
|
1 AS es_pendiente_anterior,
|
||||||
|
ts.id AS solicitud_id_anterior,
|
||||||
ts.numero_orden AS solicitud_orden_anterior
|
ts.numero_orden AS solicitud_orden_anterior
|
||||||
FROM turnero_muestras tm
|
FROM turnero_muestras tm
|
||||||
JOIN turnero_solicitudes ts ON ts.id = tm.solicitud_id
|
JOIN turnero_solicitudes ts ON ts.id = tm.solicitud_id
|
||||||
LEFT JOIN lab_tipos_muestra lt ON lt.codigo = tm.tipo_muestra
|
LEFT JOIN lab_tipos_muestra lt ON lt.codigo = tm.tipo_muestra
|
||||||
WHERE ts.paciente_id = ?
|
WHERE ts.paciente_id = ?
|
||||||
AND tm.estado = 'pendiente'
|
AND tm.estado IN ('pendiente','rechazada')
|
||||||
ORDER BY tm.id ASC"
|
ORDER BY tm.id ASC"
|
||||||
);
|
);
|
||||||
$stmtPrev->execute([$pacId]);
|
$stmtPrev->execute([$pacId]);
|
||||||
@@ -442,6 +462,33 @@ if ($incluirSolicitud) {
|
|||||||
} catch (\Throwable $_) {}
|
} catch (\Throwable $_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cargar exámenes de la solicitud original para cada muestra pendiente de visita anterior,
|
||||||
|
// para que al recibirla se vea el contexto completo del servicio (no solo el tipo de muestra)
|
||||||
|
$solIdsAnteriores = array_values(array_unique(array_filter(
|
||||||
|
array_column($muestras, 'solicitud_id_anterior')
|
||||||
|
)));
|
||||||
|
if ($solIdsAnteriores) {
|
||||||
|
$ph = implode(',', array_fill(0, count($solIdsAnteriores), '?'));
|
||||||
|
$stmtExAnt = $pdo->prepare(
|
||||||
|
"SELECT tei.solicitud_id, et.nombre, et.codigo
|
||||||
|
FROM turnero_examen_items tei
|
||||||
|
JOIN exam_tipos et ON et.id = tei.exam_tipo_id
|
||||||
|
WHERE tei.solicitud_id IN ($ph)
|
||||||
|
ORDER BY et.nombre ASC"
|
||||||
|
);
|
||||||
|
$stmtExAnt->execute($solIdsAnteriores);
|
||||||
|
$examPorSolicitud = [];
|
||||||
|
foreach ($stmtExAnt->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||||
|
$examPorSolicitud[(int)$row['solicitud_id']][] = ['nombre' => $row['nombre'], 'codigo' => $row['codigo']];
|
||||||
|
}
|
||||||
|
foreach ($muestras as &$m) {
|
||||||
|
if (!empty($m['solicitud_id_anterior'])) {
|
||||||
|
$m['examenes_anteriores'] = $examPorSolicitud[(int)$m['solicitud_id_anterior']] ?? [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
unset($m);
|
||||||
|
}
|
||||||
|
|
||||||
$respuesta['solicitud'] = $solicitud;
|
$respuesta['solicitud'] = $solicitud;
|
||||||
$respuesta['paciente'] = $paciente;
|
$respuesta['paciente'] = $paciente;
|
||||||
$respuesta['examenes'] = $examenes;
|
$respuesta['examenes'] = $examenes;
|
||||||
|
|||||||
@@ -7,6 +7,7 @@
|
|||||||
* - resumen global (total, atendidos, pendientes, ausentes, tiempo promedio)
|
* - resumen global (total, atendidos, pendientes, ausentes, tiempo promedio)
|
||||||
* - desglose por prioridad
|
* - desglose por prioridad
|
||||||
* - desglose por lugar
|
* - desglose por lugar
|
||||||
|
* - desglose por recepcionista y por bacteriólogo (quién atendió a cuántos)
|
||||||
* - lista de turnos del día (para tabla detalle)
|
* - lista de turnos del día (para tabla detalle)
|
||||||
*/
|
*/
|
||||||
require_once __DIR__ . '/_helpers.php';
|
require_once __DIR__ . '/_helpers.php';
|
||||||
@@ -41,10 +42,12 @@ if (!$sesion) {
|
|||||||
'tiempo_espera_promedio_min' => null,
|
'tiempo_espera_promedio_min' => null,
|
||||||
'tiempo_servicio_promedio_min' => null,
|
'tiempo_servicio_promedio_min' => null,
|
||||||
],
|
],
|
||||||
'por_prioridad' => [],
|
'por_prioridad' => [],
|
||||||
'por_lugar' => [],
|
'por_lugar' => [],
|
||||||
'consent_stats' => [],
|
'por_recepcionista' => [],
|
||||||
'turnos' => [],
|
'por_bacteriologo' => [],
|
||||||
|
'consent_stats' => [],
|
||||||
|
'turnos' => [],
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,28 +57,51 @@ $sesionId = (int)$sesion['id'];
|
|||||||
$stmtRes = $pdo->prepare(
|
$stmtRes = $pdo->prepare(
|
||||||
"SELECT
|
"SELECT
|
||||||
COUNT(*) AS total,
|
COUNT(*) AS total,
|
||||||
SUM(estado IN ('finalizado','en_servicio')) AS atendidos,
|
SUM(t.estado IN ('finalizado','en_servicio')) AS atendidos,
|
||||||
SUM(estado IN ('espera','en_recepcion','en_espera_lugar')) AS en_espera,
|
SUM(t.estado IN ('espera','en_recepcion','en_espera_lugar')) AS en_espera,
|
||||||
SUM(estado = 'ausente') AS ausentes,
|
SUM(t.estado = 'ausente') AS ausentes,
|
||||||
SUM(estado = 'cancelado') AS cancelados,
|
SUM(t.estado = 'cancelado') AS cancelados,
|
||||||
ROUND(
|
ROUND(
|
||||||
AVG(
|
AVG(
|
||||||
CASE
|
CASE
|
||||||
WHEN inicio_recepcion_at IS NOT NULL AND creado_at IS NOT NULL
|
WHEN t.inicio_recepcion_at IS NOT NULL AND t.creado_at IS NOT NULL
|
||||||
THEN TIMESTAMPDIFF(SECOND, creado_at, inicio_recepcion_at) / 60.0
|
THEN TIMESTAMPDIFF(SECOND, t.creado_at, t.inicio_recepcion_at) / 60.0
|
||||||
END
|
END
|
||||||
), 1
|
), 1
|
||||||
) AS tiempo_espera_promedio_min,
|
) AS tiempo_espera_promedio_min,
|
||||||
|
-- En los protocolos prolongados la atención se corta en la PRIMERA toma:
|
||||||
|
-- lo que sigue son horas de espera del examen (una curva de glucosa son
|
||||||
|
-- 2 horas), no tiempo de trabajo. Contarlas triplicaba el promedio —21,9
|
||||||
|
-- min contra 7,4— y hacía ver los puestos mucho más lentos de lo que son.
|
||||||
ROUND(
|
ROUND(
|
||||||
AVG(
|
AVG(
|
||||||
CASE
|
CASE
|
||||||
WHEN fin_lugar_at IS NOT NULL AND inicio_lugar_at IS NOT NULL
|
WHEN t.inicio_lugar_at IS NOT NULL
|
||||||
THEN TIMESTAMPDIFF(SECOND, inicio_lugar_at, fin_lugar_at) / 60.0
|
AND COALESCE(prot.toma_inicio_at, t.fin_lugar_at) IS NOT NULL
|
||||||
|
THEN TIMESTAMPDIFF(SECOND, t.inicio_lugar_at,
|
||||||
|
COALESCE(prot.toma_inicio_at, t.fin_lugar_at)) / 60.0
|
||||||
END
|
END
|
||||||
), 1
|
), 1
|
||||||
) AS tiempo_servicio_promedio_min
|
) AS tiempo_servicio_promedio_min,
|
||||||
FROM turnero_turnos
|
ROUND(
|
||||||
WHERE sesion_id = ?"
|
AVG(
|
||||||
|
CASE
|
||||||
|
WHEN t.creado_at IS NOT NULL
|
||||||
|
AND COALESCE(prot.toma_inicio_at, t.fin_lugar_at) IS NOT NULL
|
||||||
|
THEN TIMESTAMPDIFF(SECOND, t.creado_at,
|
||||||
|
COALESCE(prot.toma_inicio_at, t.fin_lugar_at)) / 60.0
|
||||||
|
END
|
||||||
|
), 1
|
||||||
|
) AS tiempo_total_promedio_min
|
||||||
|
FROM turnero_turnos t
|
||||||
|
-- Un turno tiene a lo sumo un protocolo prolongado, así que el join no duplica
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT tc.turno_id, MIN(tc.toma_inicio_at) AS toma_inicio_at
|
||||||
|
FROM turnero_consentimientos tc
|
||||||
|
JOIN lab_formularios f ON f.id = tc.formulario_id AND f.es_toma_progresiva = 1
|
||||||
|
GROUP BY tc.turno_id
|
||||||
|
) prot ON prot.turno_id = t.id
|
||||||
|
WHERE t.sesion_id = ?"
|
||||||
);
|
);
|
||||||
$stmtRes->execute([$sesionId]);
|
$stmtRes->execute([$sesionId]);
|
||||||
$resumen = $stmtRes->fetch(PDO::FETCH_ASSOC);
|
$resumen = $stmtRes->fetch(PDO::FETCH_ASSOC);
|
||||||
@@ -104,12 +130,20 @@ $stmtLug = $pdo->prepare(
|
|||||||
SUM(t.estado IN ('en_espera_lugar')) AS en_espera,
|
SUM(t.estado IN ('en_espera_lugar')) AS en_espera,
|
||||||
ROUND(AVG(
|
ROUND(AVG(
|
||||||
CASE
|
CASE
|
||||||
WHEN t.fin_lugar_at IS NOT NULL AND t.inicio_lugar_at IS NOT NULL
|
WHEN t.inicio_lugar_at IS NOT NULL
|
||||||
THEN TIMESTAMPDIFF(SECOND, t.inicio_lugar_at, t.fin_lugar_at) / 60.0
|
AND COALESCE(prot.toma_inicio_at, t.fin_lugar_at) IS NOT NULL
|
||||||
|
THEN TIMESTAMPDIFF(SECOND, t.inicio_lugar_at,
|
||||||
|
COALESCE(prot.toma_inicio_at, t.fin_lugar_at)) / 60.0
|
||||||
END
|
END
|
||||||
), 1) AS tiempo_servicio_promedio_min
|
), 1) AS tiempo_servicio_promedio_min
|
||||||
FROM turnero_turnos t
|
FROM turnero_turnos t
|
||||||
JOIN turnero_lugares l ON l.id = t.lugar_destino_id
|
JOIN turnero_lugares l ON l.id = t.lugar_destino_id
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT tc.turno_id, MIN(tc.toma_inicio_at) AS toma_inicio_at
|
||||||
|
FROM turnero_consentimientos tc
|
||||||
|
JOIN lab_formularios f ON f.id = tc.formulario_id AND f.es_toma_progresiva = 1
|
||||||
|
GROUP BY tc.turno_id
|
||||||
|
) prot ON prot.turno_id = t.id
|
||||||
WHERE t.sesion_id = ?
|
WHERE t.sesion_id = ?
|
||||||
GROUP BY l.id
|
GROUP BY l.id
|
||||||
ORDER BY l.sort_order ASC, l.nombre ASC"
|
ORDER BY l.sort_order ASC, l.nombre ASC"
|
||||||
@@ -117,6 +151,63 @@ $stmtLug = $pdo->prepare(
|
|||||||
$stmtLug->execute([$sesionId]);
|
$stmtLug->execute([$sesionId]);
|
||||||
$porLugar = $stmtLug->fetchAll(PDO::FETCH_ASSOC);
|
$porLugar = $stmtLug->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// ── 4b. Desglose por recepcionista ────────────────────────────
|
||||||
|
// Mismo molde que el ranking de enfermeras de lab_domicilios
|
||||||
|
// (api/lab/get_metricas.php). atendido_recepcion_por guarda al PRIMER
|
||||||
|
// recepcionista que tomó el turno (COALESCE en cambiar_estado.php).
|
||||||
|
$stmtRec = $pdo->prepare(
|
||||||
|
"SELECT u.id,
|
||||||
|
COALESCE(NULLIF(u.full_name, ''), u.username) AS nombre,
|
||||||
|
COUNT(t.id) AS total,
|
||||||
|
SUM(t.estado IN ('finalizado','en_servicio','en_espera_lugar')) AS atendidos,
|
||||||
|
SUM(t.estado = 'ausente') AS ausentes,
|
||||||
|
SUM(t.estado = 'cancelado') AS cancelados,
|
||||||
|
ROUND(AVG(
|
||||||
|
CASE
|
||||||
|
WHEN t.fin_recepcion_at IS NOT NULL AND t.inicio_recepcion_at IS NOT NULL
|
||||||
|
THEN TIMESTAMPDIFF(SECOND, t.inicio_recepcion_at, t.fin_recepcion_at) / 60.0
|
||||||
|
END
|
||||||
|
), 1) AS tiempo_promedio_min
|
||||||
|
FROM turnero_turnos t
|
||||||
|
JOIN admin_users u ON u.id = t.atendido_recepcion_por
|
||||||
|
WHERE t.sesion_id = ?
|
||||||
|
GROUP BY u.id, u.full_name, u.username
|
||||||
|
ORDER BY atendidos DESC, total DESC"
|
||||||
|
);
|
||||||
|
$stmtRec->execute([$sesionId]);
|
||||||
|
$porRecepcionista = $stmtRec->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
// ── 4c. Desglose por bacteriólogo (quien atendió en el lugar) ──
|
||||||
|
$stmtBac = $pdo->prepare(
|
||||||
|
"SELECT u.id,
|
||||||
|
COALESCE(NULLIF(u.full_name, ''), u.username) AS nombre,
|
||||||
|
COUNT(t.id) AS total,
|
||||||
|
SUM(t.estado = 'finalizado') AS finalizados,
|
||||||
|
SUM(t.estado = 'en_servicio') AS en_servicio,
|
||||||
|
SUM(t.estado = 'ausente') AS ausentes,
|
||||||
|
ROUND(AVG(
|
||||||
|
CASE
|
||||||
|
WHEN t.inicio_lugar_at IS NOT NULL
|
||||||
|
AND COALESCE(prot.toma_inicio_at, t.fin_lugar_at) IS NOT NULL
|
||||||
|
THEN TIMESTAMPDIFF(SECOND, t.inicio_lugar_at,
|
||||||
|
COALESCE(prot.toma_inicio_at, t.fin_lugar_at)) / 60.0
|
||||||
|
END
|
||||||
|
), 1) AS tiempo_promedio_min
|
||||||
|
FROM turnero_turnos t
|
||||||
|
JOIN admin_users u ON u.id = t.atendido_lugar_por
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT tc.turno_id, MIN(tc.toma_inicio_at) AS toma_inicio_at
|
||||||
|
FROM turnero_consentimientos tc
|
||||||
|
JOIN lab_formularios f ON f.id = tc.formulario_id AND f.es_toma_progresiva = 1
|
||||||
|
GROUP BY tc.turno_id
|
||||||
|
) prot ON prot.turno_id = t.id
|
||||||
|
WHERE t.sesion_id = ?
|
||||||
|
GROUP BY u.id, u.full_name, u.username
|
||||||
|
ORDER BY finalizados DESC, total DESC"
|
||||||
|
);
|
||||||
|
$stmtBac->execute([$sesionId]);
|
||||||
|
$porBacteriologo = $stmtBac->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
// ── 5. Lista de turnos del día ────────────────────────────────
|
// ── 5. Lista de turnos del día ────────────────────────────────
|
||||||
$stmtTurnos = $pdo->prepare(
|
$stmtTurnos = $pdo->prepare(
|
||||||
"SELECT t.id, t.codigo, t.numero, t.estado,
|
"SELECT t.id, t.codigo, t.numero, t.estado,
|
||||||
@@ -133,7 +224,8 @@ $stmtTurnos = $pdo->prepare(
|
|||||||
ul.full_name AS atendido_lugar_nombre,
|
ul.full_name AS atendido_lugar_nombre,
|
||||||
ts.observaciones AS notas,
|
ts.observaciones AS notas,
|
||||||
ROUND(TIMESTAMPDIFF(SECOND, t.creado_at, COALESCE(t.inicio_recepcion_at, NOW())) / 60.0, 1) AS espera_min,
|
ROUND(TIMESTAMPDIFF(SECOND, t.creado_at, COALESCE(t.inicio_recepcion_at, NOW())) / 60.0, 1) AS espera_min,
|
||||||
ROUND(TIMESTAMPDIFF(SECOND, t.inicio_lugar_at, COALESCE(t.fin_lugar_at, NOW())) / 60.0, 1) AS servicio_min
|
ROUND(TIMESTAMPDIFF(SECOND, t.inicio_lugar_at, COALESCE(t.fin_lugar_at, NOW())) / 60.0, 1) AS servicio_min,
|
||||||
|
ROUND(TIMESTAMPDIFF(SECOND, t.creado_at, t.fin_lugar_at) / 60.0, 1) AS total_min
|
||||||
FROM turnero_turnos t
|
FROM turnero_turnos t
|
||||||
JOIN turnero_prioridades p ON p.id = t.prioridad_id
|
JOIN turnero_prioridades p ON p.id = t.prioridad_id
|
||||||
LEFT JOIN turnero_lugares l ON l.id = t.lugar_destino_id
|
LEFT JOIN turnero_lugares l ON l.id = t.lugar_destino_id
|
||||||
@@ -148,22 +240,47 @@ $stmtTurnos = $pdo->prepare(
|
|||||||
$stmtTurnos->execute([$sesionId]);
|
$stmtTurnos->execute([$sesionId]);
|
||||||
$turnos = $stmtTurnos->fetchAll(PDO::FETCH_ASSOC);
|
$turnos = $stmtTurnos->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
// Enriquecer con exámenes y comentarios
|
// Enriquecer con exámenes y comentarios — en lote.
|
||||||
foreach ($turnos as &$turno) {
|
// Antes eran 2 consultas por turno dentro del foreach (N+1): con 80 turnos en
|
||||||
|
// el día eran 160 consultas evitables. Mismo patrón que get_historial.php.
|
||||||
|
$turnoIds = array_column($turnos, 'id');
|
||||||
|
$examenesPorTurno = [];
|
||||||
|
$comentariosPorTurno = [];
|
||||||
|
|
||||||
|
if ($turnoIds) {
|
||||||
|
$ph = implode(',', array_fill(0, count($turnoIds), '?'));
|
||||||
|
|
||||||
$stmtEx = $pdo->prepare(
|
$stmtEx = $pdo->prepare(
|
||||||
"SELECT DISTINCT et.nombre FROM turnero_examen_items tei
|
"SELECT DISTINCT ts.turno_id, et.nombre
|
||||||
JOIN exam_tipos et ON et.id = tei.exam_tipo_id
|
FROM turnero_solicitudes ts
|
||||||
WHERE tei.solicitud_id = (SELECT id FROM turnero_solicitudes WHERE turno_id = ? LIMIT 1)"
|
JOIN turnero_examen_items tei ON tei.solicitud_id = ts.id
|
||||||
|
JOIN exam_tipos et ON et.id = tei.exam_tipo_id
|
||||||
|
WHERE ts.turno_id IN ($ph)
|
||||||
|
ORDER BY et.nombre ASC"
|
||||||
);
|
);
|
||||||
$stmtEx->execute([$turno['id']]);
|
$stmtEx->execute($turnoIds);
|
||||||
$turno['examenes'] = $stmtEx->fetchAll(PDO::FETCH_ASSOC);
|
foreach ($stmtEx->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||||
|
$examenesPorTurno[(int)$row['turno_id']][] = ['nombre' => $row['nombre']];
|
||||||
|
}
|
||||||
|
|
||||||
$stmtCom = $pdo->prepare(
|
$stmtCom = $pdo->prepare(
|
||||||
"SELECT usuario_nombre, comentario, tipo, creado_at
|
"SELECT turno_id, usuario_nombre, comentario, tipo, creado_at
|
||||||
FROM turnero_comentarios WHERE turno_id = ? ORDER BY creado_at ASC"
|
FROM turnero_comentarios
|
||||||
|
WHERE turno_id IN ($ph)
|
||||||
|
ORDER BY turno_id, creado_at ASC"
|
||||||
);
|
);
|
||||||
$stmtCom->execute([$turno['id']]);
|
$stmtCom->execute($turnoIds);
|
||||||
$turno['comentarios'] = $stmtCom->fetchAll(PDO::FETCH_ASSOC);
|
foreach ($stmtCom->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||||
|
$tid = (int)$row['turno_id'];
|
||||||
|
unset($row['turno_id']);
|
||||||
|
$comentariosPorTurno[$tid][] = $row;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($turnos as &$turno) {
|
||||||
|
$tid = (int)$turno['id'];
|
||||||
|
$turno['examenes'] = $examenesPorTurno[$tid] ?? [];
|
||||||
|
$turno['comentarios'] = $comentariosPorTurno[$tid] ?? [];
|
||||||
}
|
}
|
||||||
unset($turno);
|
unset($turno);
|
||||||
|
|
||||||
@@ -192,6 +309,8 @@ jsonOk([
|
|||||||
'resumen' => $resumen,
|
'resumen' => $resumen,
|
||||||
'por_prioridad' => $porPrioridad,
|
'por_prioridad' => $porPrioridad,
|
||||||
'por_lugar' => $porLugar,
|
'por_lugar' => $porLugar,
|
||||||
|
'por_recepcionista' => $porRecepcionista,
|
||||||
|
'por_bacteriologo' => $porBacteriologo,
|
||||||
'consent_stats' => $consentStats,
|
'consent_stats' => $consentStats,
|
||||||
'turnos' => $turnos,
|
'turnos' => $turnos,
|
||||||
]);
|
]);
|
||||||
|
|||||||
@@ -76,6 +76,8 @@ if (empty($escritoriosRec)) {
|
|||||||
JOIN turnero_prioridades p ON p.id = t.prioridad_id
|
JOIN turnero_prioridades p ON p.id = t.prioridad_id
|
||||||
WHERE t.sesion_id = ?
|
WHERE t.sesion_id = ?
|
||||||
AND t.estado = 'en_recepcion'
|
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"
|
ORDER BY t.llamado_recepcion_at DESC"
|
||||||
);
|
);
|
||||||
$stmtRec->execute([$sesionId]);
|
$stmtRec->execute([$sesionId]);
|
||||||
@@ -120,6 +122,10 @@ foreach ($lugares as $lugar) {
|
|||||||
WHERE t.sesion_id = ?
|
WHERE t.sesion_id = ?
|
||||||
AND t.estado = 'en_servicio'
|
AND t.estado = 'en_servicio'
|
||||||
AND t.lugar_destino_id = ?
|
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
|
ORDER BY t.llamado_lugar_at DESC
|
||||||
LIMIT 1"
|
LIMIT 1"
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,46 @@
|
|||||||
|
<?php
|
||||||
|
/**
|
||||||
|
* GET get_especialidades.php?ids[]=20&ids[]=21
|
||||||
|
* Devuelve pacientes esperando en lugares de especialidad (ginecología, pediatría).
|
||||||
|
* "Esperando" = inicio_lugar_at IS NOT NULL AND fin_lugar_at IS NULL
|
||||||
|
*/
|
||||||
|
require_once __DIR__ . '/_helpers.php';
|
||||||
|
requireTurnero();
|
||||||
|
requireMethod('GET');
|
||||||
|
|
||||||
|
$pdo = db();
|
||||||
|
|
||||||
|
$ids = array_filter(array_map('intval', (array)($_GET['ids'] ?? [])));
|
||||||
|
if (!$ids) jsonOk(['grupos' => []]);
|
||||||
|
|
||||||
|
$sesionId = (int)($pdo->query(
|
||||||
|
"SELECT id FROM turnero_sesiones ORDER BY id DESC LIMIT 1"
|
||||||
|
)->fetchColumn() ?: 0);
|
||||||
|
|
||||||
|
if (!$sesionId) jsonOk(['grupos' => []]);
|
||||||
|
|
||||||
|
$in = implode(',', $ids);
|
||||||
|
$lugares = $pdo->query(
|
||||||
|
"SELECT id, nombre FROM turnero_lugares WHERE id IN ($in) AND activo=1 ORDER BY sort_order"
|
||||||
|
)->fetchAll(PDO::FETCH_ASSOC);
|
||||||
|
|
||||||
|
$grupos = [];
|
||||||
|
foreach ($lugares as $l) {
|
||||||
|
$lid = (int)$l['id'];
|
||||||
|
$stmt = $pdo->prepare("
|
||||||
|
SELECT t.id, t.paciente_nombre, t.inicio_lugar_at, ts.numero_orden
|
||||||
|
FROM turnero_turnos t
|
||||||
|
JOIN turnero_solicitudes ts ON ts.turno_id = t.id
|
||||||
|
WHERE t.sesion_id = ? AND t.lugar_destino_id = ?
|
||||||
|
AND t.estado IN ('en_espera_lugar','en_servicio') AND t.fin_lugar_at IS NULL
|
||||||
|
ORDER BY ts.numero_orden ASC
|
||||||
|
");
|
||||||
|
$stmt->execute([$sesionId, $lid]);
|
||||||
|
$grupos[] = [
|
||||||
|
'lugar_id' => $lid,
|
||||||
|
'lugar_nombre' => $l['nombre'],
|
||||||
|
'pacientes' => $stmt->fetchAll(PDO::FETCH_ASSOC),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
jsonOk(['grupos' => $grupos]);
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user