Compare commits
177
Commits
+5
-518
@@ -1,522 +1,9 @@
|
||||
# Documentación del Sistema de Laboratorio
|
||||
# Obsoleto
|
||||
|
||||
> **Sistema**: Laboratorio Clínico — Módulos de Agendamiento y Formularios
|
||||
> **Última actualización**: 27/03/2026
|
||||
Este documento quedó desactualizado y se conserva solo por historial de git.
|
||||
|
||||
---
|
||||
La documentación vigente está **dentro del sistema**, en el módulo Soporte:
|
||||
|
||||
## Tabla de contenidos
|
||||
/erp.php?m=soporte&v=documentacion
|
||||
|
||||
1. [Módulo de Agendamiento (Domicilios)](#1-módulo-de-agendamiento-domicilios)
|
||||
- [¿Qué es?](#qué-es)
|
||||
- [Archivos involucrados](#archivos-involucrados)
|
||||
- [Base de datos](#base-de-datos)
|
||||
- [Roles y permisos](#roles-y-permisos)
|
||||
- [Flujo de estados](#flujo-de-estados)
|
||||
- [Funcionalidades](#funcionalidades)
|
||||
- [API Endpoints](#api-endpoints)
|
||||
2. [Módulo de Formularios](#2-módulo-de-formularios)
|
||||
- [¿Qué es?](#qué-es-1)
|
||||
- [Archivos involucrados](#archivos-involucrados-1)
|
||||
- [Base de datos](#base-de-datos-1)
|
||||
- [Roles y permisos](#roles-y-permisos-1)
|
||||
- [Flujo completo](#flujo-completo)
|
||||
- [Tipos de campos del Builder](#tipos-de-campos-del-builder)
|
||||
- [Firma digital](#firma-digital)
|
||||
- [Enlace público y vigencia](#enlace-público-y-vigencia)
|
||||
- [PDF y visualización del documento](#pdf-y-visualización-del-documento)
|
||||
- [Sello de integridad SHA-256](#sello-de-integridad-sha-256)
|
||||
- [Firma del profesional](#firma-del-profesional)
|
||||
- [API Endpoints](#api-endpoints-1)
|
||||
|
||||
---
|
||||
|
||||
## 1. Módulo de Agendamiento (Domicilios)
|
||||
|
||||
### ¿Qué es?
|
||||
|
||||
El módulo de agendamiento gestiona los **servicios de toma de muestras a domicilio**. Permite crear, asignar, seguir y completar visitas médicas domiciliarias. Los administradores gestionan la agenda desde `lab_domicilios.php`; los enfermeros gestionan su propia agenda desde `enfermero_portal.php`.
|
||||
|
||||
---
|
||||
|
||||
### Archivos involucrados
|
||||
|
||||
| Archivo | Descripción |
|
||||
|---|---|
|
||||
| `lab_domicilios.php` | Vista principal del admin — tabla con filtros, detalle del domicilio, formularios recibidos, exportar CSV |
|
||||
| `enfermero_portal.php` | Portal exclusivo del enfermero — su agenda personal del día, ordenada por hora, con tarjetas colapsables |
|
||||
| `lab_enfermeras.php` | CRUD del personal de enfermería |
|
||||
| `classes/lab/Domicilio.php` | Clase ORM — crear, editar, cambiar estado, estadísticas |
|
||||
| `classes/lab/Enfermera.php` | Clase ORM — CRUD, agenda por enfermero, carga de trabajo |
|
||||
| `classes/lab/Asignacion.php` | Clase ORM — asignar / reasignar / liberar enfermero a domicilio |
|
||||
| `api/lab/get_domicilios.php` | GET — lista de domicilios con filtros y estadísticas del día |
|
||||
| `api/lab/save_domicilio.php` | POST — crear, actualizar o cambiar estado |
|
||||
| `api/lab/update_domicilio_enfermero.php` | POST — el enfermero avanza el estado desde su portal |
|
||||
| `api/lab/my_agenda.php` | GET — agenda del enfermero actualmente autenticado |
|
||||
| `api/lab/save_asignacion.php` | POST — asignar o reasignar enfermero |
|
||||
| `api/lab/get_asignaciones.php` | GET — asignaciones por fecha |
|
||||
| `api/lab/registrar_pago.php` | POST — registrar pago de un domicilio |
|
||||
| `api/lab/save_servicio_extra.php` | POST — agregar servicio realizado durante la visita |
|
||||
| `api/lab/get_notas_domicilio.php` | GET — notas clínicas y libres del domicilio |
|
||||
| `api/lab/upload_nota_imagen.php` | POST — subir imagen adjunta a una nota |
|
||||
| `api/lab/crear_desde_whatsapp.php` | GET/POST — crear paciente u orden desde una conversación de WhatsApp activa |
|
||||
|
||||
---
|
||||
|
||||
### Base de datos
|
||||
|
||||
#### Tabla `lab_domicilios`
|
||||
|
||||
Tabla principal. Cada fila es un servicio a domicilio.
|
||||
|
||||
| Campo | Tipo | Descripción |
|
||||
|---|---|---|
|
||||
| `id` | INT PK | Identificador único |
|
||||
| `paciente_id` | INT FK | Paciente al que se le realiza el servicio |
|
||||
| `orden_id` | INT FK NULL | Orden médica adjunta (opcional) |
|
||||
| `direccion` | TEXT | Dirección completa de la visita |
|
||||
| `ciudad` | VARCHAR(100) | Ciudad |
|
||||
| `barrio` | VARCHAR(100) | Barrio |
|
||||
| `indicaciones_dir` | TEXT | Referencias o indicaciones adicionales ("apto 302, tocar campanilla") |
|
||||
| `fecha_programada` | DATE | Fecha de la visita |
|
||||
| `hora_programada` | TIME | Hora de la visita |
|
||||
| `tipo_servicio` | VARCHAR(100) | Tipo de servicio (toma de muestra, etc.) |
|
||||
| `tipo_cliente` | ENUM | `particular` / `seguro` / `eps` |
|
||||
| `examenes_solicitados` | TEXT | Lista de exámenes (cuando no hay orden médica) |
|
||||
| `seguro_nombre` | VARCHAR | Nombre del seguro o EPS |
|
||||
| `autorizacion` | VARCHAR | Número de autorización |
|
||||
| `valor_domicilio` | DECIMAL | Valor del servicio |
|
||||
| `valor_copago` | DECIMAL | Copago a cargo del cliente |
|
||||
| `copago_laboratorio` | DECIMAL | Copago al laboratorio |
|
||||
| `pago_estado` | ENUM | `pending` / `pagado` / `exento` |
|
||||
| `pago_modo` | ENUM | `efectivo` / `transferencia` / `otro` |
|
||||
| `pago_monto` | DECIMAL | Monto pagado |
|
||||
| `pago_fecha` | DATETIME | Fecha del pago |
|
||||
| `pago_notas` | TEXT | Notas sobre el pago |
|
||||
| `estado` | ENUM | Ver [Flujo de estados](#flujo-de-estados) |
|
||||
| `motivo_cancelacion` | TEXT | Motivo si fue cancelado (obligatorio) |
|
||||
| `fecha_reprogramada` | DATE | Nueva fecha si fue reprogramado |
|
||||
| `hora_llegada` | TIME | Registrada automáticamente al iniciar la visita |
|
||||
| `hora_salida` | TIME | Registrada automáticamente al completar |
|
||||
| `observaciones` | TEXT | Observaciones del resultado de la visita |
|
||||
| `muestras_tomadas` | TEXT | Lista de muestras obtenidas |
|
||||
| `notas_admin` | TEXT | Notas internas del equipo administrativo |
|
||||
| `creado_por` | INT FK | Usuario que creó el registro |
|
||||
|
||||
#### Tabla `lab_enfermeras`
|
||||
|
||||
Personal de enfermería disponible para asignación.
|
||||
|
||||
Campos: `id`, `numero_documento`, `tipo_documento` (CC/CE/TI/PA), `nombre_completo`, `telefono`, `telefono_alt`, `email`, `zona`, `notas`, `is_active`.
|
||||
|
||||
#### Tabla `lab_asignaciones`
|
||||
|
||||
Asignación de enfermero a domicilio. Máximo un enfermero activo por domicilio (`UNIQUE KEY` en `domicilio_id`).
|
||||
|
||||
Campos: `id`, `domicilio_id`, `enfermera_id`, `asignada_por`, `estado` (`asignada` / `confirmada` / `liberada` / `completada`), `notas`.
|
||||
|
||||
#### Tabla `lab_servicios_extra`
|
||||
|
||||
Servicios realizados por el enfermero durante la visita, adicionales a la orden original.
|
||||
|
||||
Tipos disponibles: `inyeccion`, `cura`, `nebulizacion`, `toma_muestra`, `tension_arterial`, `glucometria`, `otro`.
|
||||
|
||||
Campos: `id`, `domicilio_id`, `descripcion`, `tipo`, `notas`, `requiere_pago`, `valor`, `realizado_por`.
|
||||
|
||||
#### Tabla `lab_domicilio_notas`
|
||||
|
||||
Notas registradas por el enfermero durante la visita.
|
||||
|
||||
Dos tipos:
|
||||
- **`clinica`**: datos de la ficha clínica — antecedentes, medicamentos, acudiente (si el paciente es menor de edad).
|
||||
- **`libre`**: nota libre con título, cuerpo de texto enriquecido e imagen adjunta.
|
||||
|
||||
Campos: `id`, `domicilio_id`, `enfermera_id`, `tipo`, `antecedentes`, `medicamentos`, `acudiente_nombre`, `acudiente_documento`, `titulo`, `cuerpo`, `imagen_path`.
|
||||
|
||||
---
|
||||
|
||||
### Roles y permisos
|
||||
|
||||
| Rol | Acceso |
|
||||
|---|---|
|
||||
| **Admin** | Crear, editar y ver todos los domicilios. Asignar/reasignar enfermeros. Registrar pagos. Exportar Excel. Ver informe completo con notas. Ver agenda de cualquier enfermero usando `?eid=X`. |
|
||||
| **Enfermero** | Solo ve su propia agenda (`enfermero_portal.php`). Avanza el estado de sus domicilios asignados. Agrega servicios extra. Registra notas clínicas y libres. Visualiza órdenes médicas adjuntas. |
|
||||
|
||||
> **Redirección automática**: si el usuario autenticado tiene rol `enfermero`, `lab_domicilios.php` lo redirige inmediatamente a `enfermero_portal.php`.
|
||||
|
||||
Los roles se definen en `admin_users.role` (ENUM `admin` / `enfermero`) y `admin_users.enfermera_id` (FK a `lab_enfermeras`).
|
||||
|
||||
---
|
||||
|
||||
### Flujo de estados
|
||||
|
||||
```
|
||||
[programado]
|
||||
│
|
||||
│ El enfermero confirma que realizará la visita
|
||||
▼
|
||||
[confirmado]
|
||||
│
|
||||
│ El enfermero sale hacia el domicilio
|
||||
▼
|
||||
[en_camino]
|
||||
│
|
||||
│ El enfermero llega → hora_llegada se registra automáticamente
|
||||
▼
|
||||
[en_domicilio]
|
||||
│
|
||||
│ El enfermero finaliza → hora_salida se registra automáticamente
|
||||
▼
|
||||
[completado]
|
||||
|
||||
Desde cualquier estado:
|
||||
→ [cancelado] (requiere motivo_cancelacion como campo obligatorio)
|
||||
→ [reprogramado] (requiere fecha_reprogramada)
|
||||
```
|
||||
|
||||
**Transiciones permitidas al enfermero** (validadas en `update_domicilio_enfermero.php`):
|
||||
|
||||
| Estado actual | Estados posibles |
|
||||
|---|---|
|
||||
| `programado` | `confirmado` |
|
||||
| `confirmado` | `en_camino`, `cancelado` |
|
||||
| `en_camino` | `en_domicilio`, `cancelado` |
|
||||
| `en_domicilio` | `completado`, `cancelado` |
|
||||
|
||||
El **admin** puede cambiar a cualquier estado directamente, incluyendo cancelar desde cualquier punto.
|
||||
|
||||
---
|
||||
|
||||
### Funcionalidades
|
||||
|
||||
- **Filtros**: por fecha, estado, enfermero asignado. Botón "Hoy" para filtro rápido.
|
||||
- **Resumen del día**: conteo de domicilios por estado en la parte superior.
|
||||
- **Panel de detalle**: al hacer clic en un domicilio se abre el panel lateral con toda la información, notas clínicas, notas libres e informe imprimible.
|
||||
- **Asignar / Reasignar enfermero**: modal con lista del personal disponible.
|
||||
- **Sin asignar**: badge con el conteo de domicilios que aún no tienen enfermero.
|
||||
- **Registrar pago**: modal para marcar el cobro con modalidad y monto.
|
||||
- **Servicios extra**: el enfermero los agrega desde su portal durante la visita.
|
||||
- **Notas del enfermero**: ficha clínica con antecedentes, medicamentos, acudiente (si menor) y notas libres con imagen adjunta.
|
||||
- **Informe de domicilio**: vista imprimible del domicilio con datos del paciente, ficha clínica y notas del enfermero.
|
||||
- **Exportar CSV (Excel)**: exporta todos los domicilios filtrados, incluyendo las columnas de notas del enfermero (antecedentes, medicamentos, acudiente, notas libres).
|
||||
- **Formularios recibidos**: pestaña dentro de `lab_domicilios.php` que muestra formularios enviados con filtro por plantilla y estado.
|
||||
- **Portal del enfermero**: tarjetas colapsables ordenadas por hora, separadas en "Activos" y "Finalizados". Permite avanzar estados, agregar notas y ver órdenes.
|
||||
|
||||
---
|
||||
|
||||
### API Endpoints
|
||||
|
||||
| Endpoint | Método | Descripción |
|
||||
|---|---|---|
|
||||
| `api/lab/get_domicilios.php` | GET | Lista con filtros. `?id=X` para uno solo con detalle completo. |
|
||||
| `api/lab/save_domicilio.php` | POST | Crear, editar o cambiar estado. `?solo_estado=true` para solo cambiar estado. |
|
||||
| `api/lab/update_domicilio_enfermero.php` | POST | El enfermero avanza el estado de su domicilio. |
|
||||
| `api/lab/my_agenda.php` | GET | Agenda del enfermero autenticado con servicios extra. |
|
||||
| `api/lab/save_asignacion.php` | POST | Asignar o reasignar enfermero a domicilio. |
|
||||
| `api/lab/get_asignaciones.php` | GET | Asignaciones por fecha. |
|
||||
| `api/lab/registrar_pago.php` | POST | Registrar pago con monto y modalidad. |
|
||||
| `api/lab/save_servicio_extra.php` | POST | Agregar servicio realizado durante la visita. |
|
||||
| `api/lab/get_notas_domicilio.php` | GET | Notas del domicilio (clínicas y libres). |
|
||||
| `api/lab/upload_nota_imagen.php` | POST | Subir imagen adjunta a una nota libre. |
|
||||
| `api/lab/crear_desde_whatsapp.php` | GET/POST | Crear paciente u orden desde una conversación de WhatsApp activa. |
|
||||
|
||||
---
|
||||
|
||||
## 2. Módulo de Formularios
|
||||
|
||||
### ¿Qué es?
|
||||
|
||||
El módulo de formularios permite crear **plantillas de documentos** (consentimientos, historias clínicas, autorizaciones, encuestas) mediante un builder visual, enviarlas a los pacientes por WhatsApp y recopilar sus respuestas con firma digital. El documento firmado genera un **sello de integridad SHA-256** que puede verificarse públicamente.
|
||||
|
||||
---
|
||||
|
||||
### Archivos involucrados
|
||||
|
||||
| Archivo | Descripción |
|
||||
|---|---|
|
||||
| `lab_formularios.php` | Vista principal — lista de plantillas y registro de envíos |
|
||||
| `lab_formulario_builder.php` | Editor visual drag & drop (ventana separada, solo admin) |
|
||||
| `form_cliente.php` | Página pública — el paciente llena y firma sin iniciar sesión |
|
||||
| `ver_formulario_enviado.php` | Vista del documento firmado — acceso por ID (admin/enfermero) o token (cliente) |
|
||||
| `verificar_formulario.php` | Verificación pública de autenticidad por hash SHA-256 |
|
||||
| `classes/lab/Formulario.php` | Clase ORM — CRUD de plantillas, crear envíos, guardar respuestas, generar hash |
|
||||
| `api/lab/get_formularios.php` | GET — lista plantillas o envíos |
|
||||
| `api/lab/save_formulario.php` | POST — crear, editar y eliminar plantillas (solo admin) |
|
||||
| `api/lab/send_formulario.php` | POST — crear instancia de envío, devolver URL pública y mensaje WhatsApp |
|
||||
| `api/lab/submit_formulario.php` | GET/POST — cargar el formulario por token / guardar la respuesta del cliente |
|
||||
| `api/lab/firmar_profesional.php` | POST — guardar firma del profesional (requiere sesión activa) |
|
||||
|
||||
---
|
||||
|
||||
### Base de datos
|
||||
|
||||
#### Tabla `lab_formularios`
|
||||
|
||||
Plantillas de documentos creadas desde el builder.
|
||||
|
||||
| Campo | Tipo | Descripción |
|
||||
|---|---|---|
|
||||
| `id` | INT PK | Identificador único |
|
||||
| `nombre` | VARCHAR(150) | Nombre de la plantilla |
|
||||
| `descripcion` | TEXT | Descripción visible al cliente |
|
||||
| `categoria` | ENUM | `consentimiento` / `historia_clinica` / `autorizacion` / `encuesta` / `otro` |
|
||||
| `esquema` | LONGTEXT | JSON con el array de campos del formulario |
|
||||
| `permite_firma` | TINYINT(1) | El formulario tiene sección de firma global |
|
||||
| `requiere_firma` | TINYINT(1) | La firma global es obligatoria |
|
||||
| `firma_modos` | VARCHAR(50) | `canvas`, `foto` o `canvas,foto` (separados por coma) |
|
||||
| `version` | SMALLINT | Se incrementa automáticamente al editar el esquema |
|
||||
| `is_active` | TINYINT(1) | Soft-delete |
|
||||
| `creado_por` | INT FK | Usuario que creó la plantilla |
|
||||
| `doc_encabezado` | VARCHAR | Override del nombre de empresa en el documento |
|
||||
| `doc_subtitulo` | VARCHAR | Override del subtítulo en el documento |
|
||||
| `doc_logo_base64` | LONGTEXT | Override del logo en el documento |
|
||||
| `doc_color` | VARCHAR(20) | Override del color del encabezado del documento |
|
||||
| `doc_pie_pagina` | TEXT | Override del pie de página |
|
||||
|
||||
#### Tabla `lab_form_envios`
|
||||
|
||||
Cada fila es una instancia enviada a un paciente.
|
||||
|
||||
| Campo | Tipo | Descripción |
|
||||
|---|---|---|
|
||||
| `id` | INT PK | Identificador único |
|
||||
| `formulario_id` | INT FK | Plantilla enviada |
|
||||
| `paciente_id` | INT FK NULL | Paciente asociado |
|
||||
| `domicilio_id` | INT FK NULL | Domicilio asociado (opcional) |
|
||||
| `token` | CHAR(64) UNIQUE | Token público de 64 caracteres hex (acceso sin sesión) |
|
||||
| `datos_prefilled` | LONGTEXT | JSON con datos pre-llenados al enviar (incluye `__paciente.*`) |
|
||||
| `datos_cliente` | LONGTEXT | JSON con las respuestas completadas por el cliente |
|
||||
| `firma_svg` | LONGTEXT | Firma del paciente (PNG base64 — canvas o foto) |
|
||||
| `ip_cliente` | VARCHAR(45) | IP del cliente al enviar el formulario |
|
||||
| `user_agent` | VARCHAR(512) | Navegador del cliente |
|
||||
| `estado` | ENUM | `pendiente` / `completado` / `firmado` / `expirado` |
|
||||
| `enviado_por` | INT FK | Usuario que generó el enlace |
|
||||
| `enviado_via` | ENUM | `whatsapp` / `email` / `link` |
|
||||
| `expira_en` | DATETIME NULL | Siempre `NULL` — el enlace no expira |
|
||||
| `completado_en` | DATETIME | Fecha y hora en que el cliente completó el formulario |
|
||||
| `hash_verificacion` | CHAR(64) | Sello de integridad SHA-256 del documento |
|
||||
|
||||
---
|
||||
|
||||
### Roles y permisos
|
||||
|
||||
| Rol | Acceso |
|
||||
|---|---|
|
||||
| **Admin** | Crear, editar y eliminar plantillas desde el builder. Enviar formularios a cualquier paciente. Ver todos los envíos. Ver y descargar el PDF de cualquier formulario. |
|
||||
| **Enfermero** | Enviar formularios existentes a sus pacientes. Ver solo sus propios envíos (`enviado_por = su user_id`). No puede crear ni editar plantillas. Puede firmar como profesional en los formularios que él mismo envió. |
|
||||
| **Cliente (público)** | Accede a `form_cliente.php?t=TOKEN` sin ninguna autenticación. Llena y firma el formulario. Puede volver al mismo enlace en cualquier momento para ver el documento firmado y descargarlo como PDF. |
|
||||
|
||||
---
|
||||
|
||||
### Flujo completo
|
||||
|
||||
```
|
||||
1. ADMIN crea la plantilla
|
||||
├─ Abre lab_formulario_builder.php (se abre en ventana nueva)
|
||||
├─ Arrastra campos al canvas y los configura
|
||||
├─ Configura el diseño del documento (logo, color, encabezado, pie de página)
|
||||
└─ Guarda → POST api/lab/save_formulario.php → lab_formularios
|
||||
|
||||
2. ADMIN o ENFERMERO envía el formulario
|
||||
├─ lab_formularios.php → botón "Enviar" → modal
|
||||
├─ Busca y selecciona el paciente
|
||||
├─ Previsualiza los campos que llegarán pre-llenados
|
||||
├─ Selecciona el canal: WhatsApp o "solo link"
|
||||
└─ POST api/lab/send_formulario.php
|
||||
├─ Genera token de 64 hex chars: bin2hex(random_bytes(32))
|
||||
├─ Crea fila en lab_form_envios (estado=pendiente, expira_en=NULL)
|
||||
└─ Devuelve URL pública y mensaje preformateado para WhatsApp
|
||||
|
||||
3. CLIENTE recibe el enlace (por WhatsApp u otro medio)
|
||||
├─ Abre form_cliente.php?t=TOKEN
|
||||
├─ GET api/lab/submit_formulario.php?t=TOKEN → carga datos del formulario
|
||||
└─ Si ya fue firmado antes → muestra pantalla de solo lectura con link al PDF
|
||||
|
||||
4. CLIENTE llena el formulario
|
||||
├─ Campos "linked" llegan pre-llenados con datos del paciente (readonly si tienen valor)
|
||||
├─ Campos vacíos linked son editables para que el cliente los complete
|
||||
├─ Campos firma_profesional muestran aviso "uso exclusivo del profesional"
|
||||
└─ Dibuja su firma (canvas) o adjunta una foto de firma
|
||||
|
||||
5. CLIENTE envía
|
||||
├─ POST api/lab/submit_formulario.php
|
||||
├─ Se genera hash SHA-256 (contenido + firma + ID + token + timestamp)
|
||||
├─ Estado → "firmado" (si hay firma) o "completado" (sin firma)
|
||||
└─ Pantalla de éxito con hash visible y botón para descargar el PDF
|
||||
|
||||
6. PROFESIONAL firma (si el formulario lo requiere)
|
||||
├─ Admin/Enfermero abre ver_formulario_enviado.php?id=X con sesión activa
|
||||
├─ Aparece canvas de firma en la posición del campo firma_profesional
|
||||
├─ Dibuja su firma y hace clic en "Guardar firma"
|
||||
└─ POST api/lab/firmar_profesional.php → guarda campo_id_svg en datos_cliente
|
||||
|
||||
7. ADMIN/ENFERMERO revisa el resultado
|
||||
├─ lab_formularios.php → pestaña "Envíos" → icono "Ver respuesta"
|
||||
└─ ver_formulario_enviado.php?id=X → documento HTML imprimible
|
||||
|
||||
8. VERIFICACIÓN pública de integridad
|
||||
└─ verificar_formulario.php?h=HASH_SHA256
|
||||
├─ Busca en lab_form_envios.hash_verificacion
|
||||
└─ Muestra: nombre del formulario, paciente, fecha, estado y si el sello es válido
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Tipos de campos del Builder
|
||||
|
||||
#### Campos de entrada
|
||||
|
||||
| Tipo | Descripción |
|
||||
|---|---|
|
||||
| `texto` | Campo de texto corto de una sola línea |
|
||||
| `textarea` | Área de texto largo (varias líneas) |
|
||||
| `numero` | Campo numérico |
|
||||
| `fecha` | Selector de fecha |
|
||||
| `hora` | Selector de hora |
|
||||
| `select` | Lista desplegable con opciones configurables |
|
||||
| `radio` | Selección única con opciones configurables |
|
||||
| `checkbox` | Selección múltiple con opciones configurables |
|
||||
| `lista_marcable` | Lista de ítems numerados con checkboxes |
|
||||
|
||||
#### Campos de firma
|
||||
|
||||
| Tipo | Descripción |
|
||||
|---|---|
|
||||
| `firma` | Firma del **paciente** — visible y editable en `form_cliente.php` |
|
||||
| `firma_profesional` | Firma del **profesional** — bloqueada para el cliente; solo editable desde el panel admin/enfermero |
|
||||
|
||||
#### Campos de contenido
|
||||
|
||||
| Tipo | Descripción |
|
||||
|---|---|
|
||||
| `separador` | Separador visual o título de sección |
|
||||
| `parrafo` | Bloque de texto estático (pre-formatado o flujo libre) |
|
||||
| `parrafo_inline` | Párrafo con marcadores `{nombre_completo}`, `{telefono}`, etc. que se convierten en espacios editables si el valor está vacío |
|
||||
|
||||
#### Campos vinculados al paciente (`tipo: linked`)
|
||||
|
||||
Se auto-rellenan con los datos del paciente al momento de enviar. Si el valor existe → campo de solo lectura. Si está vacío → el cliente puede completarlo.
|
||||
|
||||
| `linked_key` | Dato que extrae |
|
||||
|---|---|
|
||||
| `nombre_completo` | Nombre completo del paciente |
|
||||
| `numero_documento` | Número de documento |
|
||||
| `tipo_documento` | Tipo de documento |
|
||||
| `fecha_nacimiento` | Fecha de nacimiento |
|
||||
| `telefono` | Teléfono |
|
||||
| `email` | Correo electrónico |
|
||||
| `eps` | EPS o aseguradora |
|
||||
| `direccion` | Dirección |
|
||||
|
||||
---
|
||||
|
||||
### Firma digital
|
||||
|
||||
**Modos disponibles** (configurados en la plantilla mediante `firma_modos`):
|
||||
|
||||
| Modo | Funcionamiento |
|
||||
|---|---|
|
||||
| `canvas` | El cliente dibuja su firma con el dedo o el mouse. Se captura con `canvas.toDataURL('image/png')`. |
|
||||
| `foto` | El cliente sube una imagen desde su cámara o galería (`<input accept="image/*" capture="environment">`). Se convierte a base64 con `FileReader`. |
|
||||
|
||||
Ambos modos pueden estar activos simultáneamente en la misma plantilla.
|
||||
|
||||
**Firma global vs. firma por campo:**
|
||||
- Si el esquema **no incluye** campos tipo `firma`, se muestra una sección de firma global al pie del formulario.
|
||||
- Si el esquema **incluye** campos `firma`, cada uno tiene su propio widget canvas independiente en la posición configurada dentro del formulario.
|
||||
|
||||
---
|
||||
|
||||
### Enlace público y vigencia
|
||||
|
||||
- **URL pública**: `form_cliente.php?t=TOKEN`
|
||||
- **TOKEN**: 64 caracteres hexadecimales generados con `bin2hex(random_bytes(32))`.
|
||||
- **Sin sesión**: el cliente no necesita crear cuenta ni iniciar sesión.
|
||||
- **Sin vencimiento**: la columna `expira_en` existe en la tabla pero siempre es `NULL`. El enlace es permanente.
|
||||
- **Bloqueo por estado**: si el formulario ya fue completado o firmado, el enlace muestra la pantalla de solo lectura. No permite modificar la respuesta.
|
||||
- **Idempotencia**: si el cliente reintenta enviar (por error de red, por ejemplo), el sistema devuelve éxito con los datos ya guardados en lugar de crear un duplicado.
|
||||
|
||||
---
|
||||
|
||||
### PDF y visualización del documento
|
||||
|
||||
No se usa ninguna librería de generación de PDF en el backend. El documento es la página `ver_formulario_enviado.php` con estilos `@media print`. El usuario puede imprimirla o guardarla como PDF directamente desde el navegador.
|
||||
|
||||
**Contenido del documento impreso:**
|
||||
- Encabezado con logo, nombre, subtítulo, datos de contacto y color corporativo
|
||||
- Datos del paciente (nombre, documento, fecha de nacimiento, teléfono, EPS)
|
||||
- Respuestas del formulario campo por campo, en el orden del esquema
|
||||
- Imagen de la firma del paciente
|
||||
- Firma del profesional (si fue completada)
|
||||
- Sello SHA-256 con link para verificar autenticidad
|
||||
- Pie de página con fecha de generación e ID del documento
|
||||
|
||||
**Formas de acceder al documento:**
|
||||
|
||||
| URL | Quién puede acceder |
|
||||
|---|---|
|
||||
| `ver_formulario_enviado.php?id=X` | Admin (cualquier formulario) o Enfermero (solo los que él envió). Requiere sesión. |
|
||||
| `ver_formulario_enviado.php?t=TOKEN` | Cliente u cualquier persona con el enlace. Sin sesión. Solo si el estado es `firmado` o `completado`. |
|
||||
|
||||
---
|
||||
|
||||
### Sello de integridad SHA-256
|
||||
|
||||
Al guardar la respuesta del cliente, el sistema genera un hash SHA-256 que vincula de forma única el contenido del formulario con la firma y el momento en que se completó.
|
||||
|
||||
**Construcción del hash** (en `Formulario::guardarRespuesta()`):
|
||||
|
||||
```
|
||||
SHA-256 de:
|
||||
JSON de los datos del cliente
|
||||
+ firma SVG/PNG del paciente
|
||||
+ ID interno del envío
|
||||
+ token del enlace
|
||||
+ timestamp del momento de registro
|
||||
```
|
||||
|
||||
**¿Para qué sirve?** Cualquier persona con el hash puede ir a `verificar_formulario.php?h=HASH` para confirmar que:
|
||||
- El documento existe en la base de datos.
|
||||
- El nombre del formulario y del paciente.
|
||||
- La fecha en que fue completado.
|
||||
- El estado actual (firmado / completado).
|
||||
|
||||
Si el documento fue alterado, el hash no coincidirá y la verificación fallará.
|
||||
|
||||
---
|
||||
|
||||
### Firma del profesional
|
||||
|
||||
Algunos formularios requieren que un profesional de salud también firme el documento, además del paciente.
|
||||
|
||||
**Flujo:**
|
||||
1. Al diseñar la plantilla en el builder se agrega un campo `tipo: firma_profesional` en la posición deseada.
|
||||
2. Cuando el cliente llena el formulario en `form_cliente.php`, ese campo muestra solo un aviso: *"Uso exclusivo del profesional de salud"*. El cliente no puede interactuar con él.
|
||||
3. Una vez que el cliente ha completado y enviado el formulario, el admin o enfermero abre `ver_formulario_enviado.php?id=X` con sesión activa y verá el canvas de firma en esa posición.
|
||||
4. El profesional dibuja su firma y hace clic en "Guardar firma".
|
||||
5. La firma se guarda mediante POST a `api/lab/firmar_profesional.php`.
|
||||
|
||||
**Validaciones en el servidor:**
|
||||
- Requiere sesión activa (`isUserLoggedIn()`).
|
||||
- Si el usuario es enfermero, solo puede firmar en formularios que él mismo envió.
|
||||
- Valida que el `campo_id` corresponde a un campo `tipo: firma_profesional` en el esquema del formulario.
|
||||
- Valida que la imagen enviada sea un data URI de imagen válido.
|
||||
|
||||
> Si el formulario se accede via `?t=TOKEN` (cliente público), el canvas **no aparece**. En su lugar se muestra un aviso *"Pendiente de firma del profesional"* (solo visible en pantalla, no en el PDF impreso).
|
||||
|
||||
---
|
||||
|
||||
### API Endpoints
|
||||
|
||||
| Endpoint | Método | Autenticación | Descripción |
|
||||
|---|---|---|---|
|
||||
| `api/lab/get_formularios.php` | GET | Sesión | Lista plantillas. `?id=X` para una sola. `?envios=1` para lista de envíos. |
|
||||
| `api/lab/save_formulario.php` | POST | Admin | Crear, editar o eliminar una plantilla. |
|
||||
| `api/lab/send_formulario.php` | POST | Admin / Enfermero | Crear instancia de envío. Devuelve URL pública y mensaje para WhatsApp. |
|
||||
| `api/lab/submit_formulario.php` | GET | Público | Cargar el formulario por token (sin sesión). |
|
||||
| `api/lab/submit_formulario.php` | POST | Público | Guardar la respuesta y firma del cliente. |
|
||||
| `api/lab/firmar_profesional.php` | POST | Sesión | Guardar la firma del profesional en un campo `firma_profesional`. |
|
||||
|
||||
---
|
||||
|
||||
*Documentación generada para uso interno del equipo.*
|
||||
Ver `README_DOCS.md` para saber cómo se organiza y cómo agregar páginas.
|
||||
|
||||
+6
-1
@@ -36,7 +36,12 @@ RUN apk add --no-cache curl-dev \
|
||||
opcache
|
||||
|
||||
# Instalar Redis extension (versión fija para cache reproducible)
|
||||
RUN pecl install redis-6.0.2 && docker-php-ext-enable redis
|
||||
# Se descarga por HTTPS de forma explícita: el filtro de red perimetral
|
||||
# responde 403 a los .tgz servidos por HTTP, lo que rompía "pecl install".
|
||||
RUN apk add --no-cache curl \
|
||||
&& curl -fsSL https://pecl.php.net/get/redis-6.0.2.tgz -o /tmp/redis-6.0.2.tgz \
|
||||
&& pecl install /tmp/redis-6.0.2.tgz \
|
||||
&& docker-php-ext-enable redis
|
||||
|
||||
# Instalar Composer
|
||||
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
# Documentación del proyecto
|
||||
|
||||
La documentación vive **dentro del sistema**, en el módulo Soporte:
|
||||
|
||||
/erp.php?m=soporte&v=documentacion
|
||||
|
||||
Se escribe en Markdown, en `modules/soporte/docs/`, y se versiona con el código.
|
||||
|
||||
| Sección | Carpeta | Quién la ve |
|
||||
|---|---|---|
|
||||
| Manual de usuario | `docs/manual/` | Cualquier usuario autenticado |
|
||||
| Documentación técnica | `docs/tecnica/` | Administradores |
|
||||
| Arquitectura | `docs/arquitectura/` | Administradores |
|
||||
| Operación y soporte | `docs/operacion/` | Administradores |
|
||||
|
||||
## Agregar o editar una página
|
||||
|
||||
Creá un `.md` en la carpeta de la sección. El nombre lleva un prefijo numérico
|
||||
que solo sirve para ordenar:
|
||||
|
||||
modules/soporte/docs/tecnica/70-mi-tema.md
|
||||
|
||||
El título sale del primer encabezado `#` del archivo. No hay que registrar nada
|
||||
en ningún índice: se descubre solo.
|
||||
|
||||
## Limitar un documento a ciertos roles
|
||||
|
||||
Por defecto un documento hereda el permiso de su sección. Para restringirlo más,
|
||||
se declara al inicio del archivo:
|
||||
|
||||
---
|
||||
roles: enfermero, supervisor
|
||||
---
|
||||
|
||||
# Enfermeros — domicilios
|
||||
|
||||
Solo esos roles lo ven; para el resto no aparece en el índice ni es accesible
|
||||
por URL. Los administradores ven todo, siempre. Una sección que queda sin
|
||||
documentos visibles no se muestra.
|
||||
|
||||
## Contenido que se genera solo
|
||||
|
||||
Estos marcadores, en una línea propia, se reemplazan al cargar la página con
|
||||
datos leídos del código y de la base:
|
||||
|
||||
| Marcador | Qué inserta |
|
||||
|----------|-------------|
|
||||
| `{{modulos}}` | Módulos, con sus vistas y endpoints |
|
||||
| `{{endpoints}}` | Todos los endpoints por módulo |
|
||||
| `{{tablas}}` | Tablas de la base, agrupadas por prefijo |
|
||||
| `{{roles}}` | Roles, usuarios activos y sus permisos |
|
||||
| `{{servicios}}` | Clases de `core/`, `services/` y `classes/` |
|
||||
|
||||
Así los inventarios no pueden quedar desactualizados. La descripción de cada
|
||||
endpoint sale de su comentario de cabecera: si lo escribís bien, aparece bien.
|
||||
|
||||
## Documentos anteriores
|
||||
|
||||
`DOCUMENTACION_LAB.md` y `README_LAB.md` quedaron de una etapa previa y están
|
||||
desactualizados. `WEBHOOK_ENDPOINTS.md` se migró a la sección técnica.
|
||||
+5
-125
@@ -1,129 +1,9 @@
|
||||
# Módulo Administrativo — Laboratorio Clínico
|
||||
# Obsoleto
|
||||
|
||||
Módulo add-on para el sistema de chatbot WhatsApp que permite gestionar órdenes médicas recibidas como imágenes, domicilios, enfermeras y pacientes.
|
||||
Este documento quedó desactualizado y se conserva solo por historial de git.
|
||||
|
||||
---
|
||||
La documentación vigente está **dentro del sistema**, en el módulo Soporte:
|
||||
|
||||
## Instalación
|
||||
/erp.php?m=soporte&v=documentacion
|
||||
|
||||
### 1. Ejecutar migraciones de base de datos
|
||||
|
||||
```bash
|
||||
php migrations/20260302_lab_run_migrations.php
|
||||
```
|
||||
|
||||
Crea 7 tablas nuevas sin modificar las existentes:
|
||||
- `lab_pacientes`
|
||||
- `lab_enfermeras`
|
||||
- `lab_ordenes_medicas`
|
||||
- `lab_domicilios`
|
||||
- `lab_asignaciones`
|
||||
- `lab_autorizaciones`
|
||||
- `lab_actividad_admin`
|
||||
|
||||
Para deshacer:
|
||||
```bash
|
||||
php migrations/20260302_lab_run_migrations.php --rollback
|
||||
```
|
||||
|
||||
### 2. Verificar instalación
|
||||
|
||||
Accede desde el navegador (con sesión admin activa):
|
||||
```
|
||||
https://tu-servidor/lab_status.php
|
||||
```
|
||||
|
||||
O desde CLI:
|
||||
```bash
|
||||
php lab_status.php
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Archivos del módulo
|
||||
|
||||
### Vistas PHP
|
||||
| Archivo | Descripción |
|
||||
|---|---|
|
||||
| `lab_dashboard.php` | Panel principal con estadísticas en tiempo real |
|
||||
| `lab_ordenes.php` | Gestión de órdenes médicas (estados, imágenes, historial) |
|
||||
| `lab_pacientes.php` | CRUD de pacientes, vinculación con usuarios WhatsApp |
|
||||
| `lab_domicilios.php` | Agenda de domicilios y asignación de enfermeras |
|
||||
| `lab_enfermeras.php` | CRUD de enfermeras y visualización de agenda diaria |
|
||||
| `lab_reportes.php` | Trazabilidad, log de actividad, exportación CSV |
|
||||
| `lab_status.php` | Verificador de estado del módulo |
|
||||
|
||||
### Clases (models)
|
||||
Ubicadas en `classes/lab/`:
|
||||
- `ActividadAdmin.php` — Base de trazabilidad
|
||||
- `Paciente.php` — Modelo de pacientes
|
||||
- `Enfermera.php` — Modelo de enfermeras
|
||||
- `OrdenMedica.php` — Modelo de órdenes médicas con flujo de estados
|
||||
- `Domicilio.php` — Modelo de domicilios con flujo de estados
|
||||
- `Asignacion.php` — Modelo de asignaciones enfermera ↔ domicilio
|
||||
|
||||
### API REST
|
||||
Ubicados en `api/lab/`:
|
||||
| Endpoint | Método | Descripción |
|
||||
|---|---|---|
|
||||
| `get_pacientes.php` | GET | Lista paginada de pacientes |
|
||||
| `save_paciente.php` | POST | Crear/actualizar paciente |
|
||||
| `get_ordenes.php` | GET | Lista/detalle de órdenes |
|
||||
| `save_orden.php` | POST | Crear/actualizar orden |
|
||||
| `autorizar_orden.php` | POST | Cambiar estado de una orden |
|
||||
| `get_domicilios.php` | GET | Lista/detalle de domicilios |
|
||||
| `save_domicilio.php` | POST | Crear/actualizar domicilio |
|
||||
| `get_enfermeras.php` | GET | Lista de enfermeras + agenda |
|
||||
| `save_enfermera.php` | POST | Crear/actualizar enfermera |
|
||||
| `get_asignaciones.php` | GET | Asignaciones por fecha |
|
||||
| `save_asignacion.php` | POST | Asignar/liberar/completar enfermera |
|
||||
| `get_actividad.php` | GET | Log de actividad con filtros |
|
||||
| `get_stats.php` | GET | Estadísticas para dashboard |
|
||||
| `crear_desde_whatsapp.php` | GET/POST | Crear orden desde conversación activa |
|
||||
|
||||
---
|
||||
|
||||
## Flujos de estado
|
||||
|
||||
### Órdenes médicas
|
||||
```
|
||||
pendiente → en_revision → autorizada → en_domicilio → completada
|
||||
↘ rechazada
|
||||
```
|
||||
|
||||
### Domicilios
|
||||
```
|
||||
programado → confirmado → en_camino → en_domicilio → completado
|
||||
↘ cancelado
|
||||
↘ reprogramado
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Integración con el chatbot
|
||||
|
||||
En `conversations.php`, los mensajes de imagen entrantes tienen un botón **<i class="fas fa-flask"></i>** (verde) en las acciones del mensaje. Al hacer click:
|
||||
|
||||
1. Se abre un modal con la imagen adjunta
|
||||
2. El operador busca o selecciona un paciente (o usa el contacto de la conversación)
|
||||
3. Completa datos opcionales (médico, exámenes, ayuno)
|
||||
4. Se crea la orden en estado `pendiente`
|
||||
|
||||
---
|
||||
|
||||
## Exportaciones CSV
|
||||
|
||||
Disponibles desde `lab_reportes.php`:
|
||||
- **Órdenes médicas** del período — incluye estado, médico, exámenes
|
||||
- **Domicilios** del período — incluye enfermera asignada, dirección, estado
|
||||
- **Pacientes** — catálogo completo con total de órdenes
|
||||
|
||||
---
|
||||
|
||||
## Requisitos
|
||||
|
||||
- PHP 8.2+
|
||||
- MariaDB 10.11+ (o MySQL 8+)
|
||||
- Bootstrap 5.3 (ya incluido en el sistema)
|
||||
- Font Awesome 6.4 (ya incluido en el sistema)
|
||||
- `uploads/media/` con permisos de escritura (755/775)
|
||||
Ver `README_DOCS.md` para saber cómo se organiza y cómo agregar páginas.
|
||||
|
||||
@@ -21,6 +21,53 @@ requireAuthentication();
|
||||
$adminId = (int)($_SESSION['admin_user']['id'] ?? 0);
|
||||
$db = Database::getInstance();
|
||||
|
||||
/**
|
||||
* Le pide el teléfono a quien lo tiene oculto en WhatsApp, una sola vez.
|
||||
*
|
||||
* Solo aplica a quien se identifica con un BSUID: de esa persona no tenemos
|
||||
* número, y sin él el laboratorio no puede llamarla. Se le manda el botón que
|
||||
* Meta dispone para esto y ella decide si lo comparte; si acepta, el webhook
|
||||
* recibe el teléfono y lo vincula solo.
|
||||
*
|
||||
* No se insiste: pedir los datos una vez es razonable, repetirlo en cada
|
||||
* trámite es acoso. Tampoco se interrumpe la creación de la ficha si el envío
|
||||
* falla, porque la ficha es lo importante.
|
||||
*
|
||||
* @return string qué pasó, para que la interfaz lo pueda mostrar
|
||||
*/
|
||||
function pedirContactoSiHaceFalta(Database $db, int $userId): string {
|
||||
$u = $db->fetch(
|
||||
'SELECT phone_number, contacto_pedido_at FROM users WHERE id = ?',
|
||||
[$userId]
|
||||
);
|
||||
if (!$u) return 'usuario_no_encontrado';
|
||||
if (!esBsuid($u['phone_number'])) return 'no_hace_falta'; // ya tenemos su número
|
||||
if (!empty($u['contacto_pedido_at'])) return 'ya_se_pidio';
|
||||
|
||||
try {
|
||||
require_once __DIR__ . '/../../services/WhatsAppService.php';
|
||||
$wa = new WhatsAppService();
|
||||
$texto = getConfigFromDB(
|
||||
'whatsapp_texto_pedir_contacto',
|
||||
'Para poder registrar su atención necesitamos un número de contacto. ¿Nos comparte el suyo?'
|
||||
);
|
||||
|
||||
$r = $wa->pedirContacto($u['phone_number'], $texto);
|
||||
if (!$r) {
|
||||
error_log('[crear_desde_whatsapp] WhatsApp rechazó la solicitud de contacto del usuario ' . $userId);
|
||||
return 'fallo_envio';
|
||||
}
|
||||
|
||||
// Se marca solo si Meta aceptó: si falló, hay que poder reintentarlo
|
||||
$db->update('users', ['contacto_pedido_at' => date('Y-m-d H:i:s')], 'id = ?', [$userId]);
|
||||
return 'pedido';
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log('[crear_desde_whatsapp] Error pidiendo el contacto: ' . $e->getMessage());
|
||||
return 'fallo_envio';
|
||||
}
|
||||
}
|
||||
|
||||
// ── GET: solo_paciente ─────────────────────────────────────────────────────
|
||||
if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['solo_paciente'])) {
|
||||
$convId = (int)($_GET['conversation_id'] ?? 0);
|
||||
@@ -57,7 +104,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'GET' && isset($_GET['solo_paciente'])) {
|
||||
$pacienteRepo = new Paciente();
|
||||
$pacienteId = $pacienteRepo->obtenerOCrearDesdeWhatsapp($conv['user_id']);
|
||||
$paciente = $pacienteRepo->obtener($pacienteId);
|
||||
echo json_encode(['success' => true, 'paciente' => $paciente]);
|
||||
|
||||
// Si la persona oculta su teléfono, la ficha queda sin número. Es el momento
|
||||
// de pedírselo: se le manda el botón de WhatsApp una sola vez.
|
||||
$contactoPedido = pedirContactoSiHaceFalta($db, (int) $conv['user_id']);
|
||||
|
||||
echo json_encode([
|
||||
'success' => true,
|
||||
'paciente' => $paciente,
|
||||
'contacto_pedido' => $contactoPedido,
|
||||
]);
|
||||
exit;
|
||||
}
|
||||
|
||||
|
||||
@@ -29,9 +29,11 @@ if (!isUserLoggedIn()) {
|
||||
$body = json_decode(file_get_contents('php://input'), true);
|
||||
if (!$body) $body = $_POST;
|
||||
|
||||
$envioId = (int)($body['envio_id'] ?? 0);
|
||||
$campoId = trim($body['campo_id'] ?? '');
|
||||
$svg = $body['svg'] ?? '';
|
||||
$envioId = (int)($body['envio_id'] ?? 0);
|
||||
$campoId = trim($body['campo_id'] ?? '');
|
||||
$svg = $body['svg'] ?? '';
|
||||
$proNombre = trim($body['pro_nombre'] ?? '');
|
||||
$proCedula = trim($body['pro_cedula'] ?? '');
|
||||
|
||||
if (!$envioId) err('envio_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 ──
|
||||
$datos = json_decode($envio['datos_cliente'] ?? '{}', true) ?? [];
|
||||
$datos[$campoId . '_svg'] = $svg;
|
||||
if ($proNombre !== '') $datos['_pro_nombre'] = $proNombre;
|
||||
if ($proCedula !== '') $datos['_pro_cedula'] = $proCedula;
|
||||
|
||||
$nuevosDatos = json_encode($datos, JSON_UNESCAPED_UNICODE);
|
||||
$nuevoEstado = $envio['estado'] === 'firmado' ? 'firmado' : 'firmado';
|
||||
|
||||
@@ -55,12 +55,18 @@ try {
|
||||
if (!empty($datos['id'])) {
|
||||
// Actualización general
|
||||
$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') {
|
||||
$dbCheck = Database::getInstance();
|
||||
$chk = $dbCheck->fetchOne("SELECT creado_por FROM lab_domicilios WHERE id = ?", [$id]);
|
||||
if (!$chk || (int)($chk['creado_por'] ?? 0) !== adminId()) {
|
||||
jsonError('Solo puedes editar domicilios que tú mismo agendaste.');
|
||||
$chk = $dbCheck->fetchOne(
|
||||
"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 = ?",
|
||||
[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']);
|
||||
|
||||
@@ -17,7 +17,7 @@ $payload = json_encode([
|
||||
'generationConfig' => ['maxOutputTokens' => 10, 'temperature' => 0],
|
||||
], 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, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
@@ -37,4 +37,4 @@ if ($code !== 200) {
|
||||
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;
|
||||
}
|
||||
|
||||
// Extraer nombres de contactos del payload (contacts[].profile.name)
|
||||
// Extraer nombres de contactos del payload (contacts[].profile.name).
|
||||
// Se indexa por wa_id y también por user_id (BSUID): desde que Meta desplegó
|
||||
// los nombres de usuario, quien oculta su teléfono llega sin wa_id.
|
||||
$contactNames = [];
|
||||
if (isset($value['contacts']) && is_array($value['contacts'])) {
|
||||
foreach ($value['contacts'] as $contact) {
|
||||
$waId = $contact['wa_id'] ?? null;
|
||||
$name = $contact['profile']['name'] ?? null;
|
||||
if ($waId && $name) {
|
||||
$contactNames[$waId] = $name;
|
||||
if (!$name) continue;
|
||||
foreach ([$contact['wa_id'] ?? null, $contact['user_id'] ?? null] as $clave) {
|
||||
if ($clave) $contactNames[$clave] = $name;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($items as $message) {
|
||||
// En algunos payloads la estructura key es 'from' y 'id' (mensajes), en otros puede venir distinta; normalizamos
|
||||
// En algunos payloads la estructura key es 'from' y 'id' (mensajes), en otros puede venir distinta; normalizamos.
|
||||
$phoneNumber = $message['from'] ?? ($message['wa_id'] ?? null);
|
||||
$messageId = $message['id'] ?? ($message['message_id'] ?? null);
|
||||
$timestamp = $message['timestamp'] ?? null;
|
||||
|
||||
// Meta manda el BSUID en todos los mensajes, traigan teléfono o no.
|
||||
$bsuid = $message['from_user_id'] ?? null;
|
||||
|
||||
// Si la persona oculta su teléfono, `from` y `wa_id` no llegan. En ese caso
|
||||
// se busca por BSUID: si ya escribió antes mostrando su número, se le
|
||||
// reconoce y se le sigue respondiendo a ese teléfono.
|
||||
if (empty($phoneNumber) && $bsuid) {
|
||||
$conocido = $this->db->fetch(
|
||||
"SELECT phone_number FROM users WHERE bsuid = :b", ['b' => $bsuid]
|
||||
);
|
||||
// Si no se le conoce, el BSUID hace de identificador: sirve para responderle,
|
||||
// aunque no permita cruzarlo con el paciente ni con el turnero.
|
||||
$phoneNumber = $conocido['phone_number'] ?? $bsuid;
|
||||
}
|
||||
|
||||
// Si falta lo crítico, saltar
|
||||
if (empty($phoneNumber) || empty($messageId)) {
|
||||
continue;
|
||||
@@ -148,7 +165,7 @@ class WhatsAppWebhook {
|
||||
|
||||
// Obtener o crear usuario
|
||||
$user = $this->getUserByPhone($phoneNumber);
|
||||
$contactName = $contactNames[$phoneNumber] ?? null;
|
||||
$contactName = $contactNames[$phoneNumber] ?? ($bsuid ? ($contactNames[$bsuid] ?? null) : null);
|
||||
if (!$user) {
|
||||
$userId = $this->createUser($phoneNumber);
|
||||
$user = $this->getUserById($userId);
|
||||
@@ -173,6 +190,20 @@ class WhatsAppWebhook {
|
||||
}
|
||||
}
|
||||
|
||||
// Guardar la equivalencia BSUID↔usuario mientras la persona todavía muestra
|
||||
// su teléfono. El día que lo oculte, ese registro es lo único que permitirá
|
||||
// reconocerla, así que se anota en cada mensaje y no solo la primera vez.
|
||||
if ($bsuid && !empty($user['id']) && ($user['bsuid'] ?? null) !== $bsuid) {
|
||||
try {
|
||||
$this->db->update('users', ['bsuid' => $bsuid], 'id = ?', [$user['id']]);
|
||||
$user['bsuid'] = $bsuid;
|
||||
} catch (Exception $e) {
|
||||
// Choca si ese BSUID ya está en otro usuario (la persona cambió de
|
||||
// número). No es motivo para perder el mensaje: se sigue adelante.
|
||||
error_log('[webhook] No se pudo guardar el BSUID ' . $bsuid . ': ' . $e->getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
// Procesar diferentes tipos de mensaje
|
||||
$messageText = '';
|
||||
$messageType = 'text';
|
||||
@@ -191,6 +222,28 @@ class WhatsAppWebhook {
|
||||
'reaction_emoji' => $emoji
|
||||
];
|
||||
|
||||
} elseif (($message['type'] ?? '') === 'contacts') {
|
||||
// La persona compartió su contacto, sea por el botón que se le pidió
|
||||
// o a mano. Es la única forma de obtener el teléfono de quien lo oculta.
|
||||
$messageType = 'contacts';
|
||||
$messageText = json_encode($message['contacts'] ?? []);
|
||||
|
||||
$telefonoCompartido = null;
|
||||
foreach ($message['contacts'] ?? [] as $c) {
|
||||
foreach ($c['phones'] ?? [] as $t) {
|
||||
// wa_id ya viene normalizado; `phone` puede traer espacios y signos
|
||||
$candidato = $t['wa_id'] ?? ($t['phone'] ?? null);
|
||||
if ($candidato) {
|
||||
$telefonoCompartido = preg_replace('/[^0-9]/', '', $candidato);
|
||||
break 2;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($telefonoCompartido && !empty($user['id'])) {
|
||||
$this->vincularTelefonoCompartido($user, $telefonoCompartido, $bsuid);
|
||||
}
|
||||
|
||||
} elseif (isset($message['interactive'])) {
|
||||
// Interactive replies (list or button) - normalize to text so bot can process
|
||||
$messageType = 'text';
|
||||
@@ -450,6 +503,59 @@ class WhatsAppWebhook {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Vincula el teléfono que la persona acaba de compartir con el usuario que
|
||||
* hasta ahora solo se conocía por su BSUID.
|
||||
*
|
||||
* Puede haber dos registros de la misma persona: el viejo, de cuando escribía
|
||||
* mostrando el número, y el nuevo creado con el BSUID de identificador. No se
|
||||
* fusionan aquí (implicaría mover conversaciones, estados y aceptación de
|
||||
* términos, y una fusión mal hecha mezcla historias clínicas de dos personas):
|
||||
* se deja el registro con el teléfono como el bueno y se marca el otro, para
|
||||
* que alguien lo revise.
|
||||
*
|
||||
* @return bool si el teléfono quedó vinculado
|
||||
*/
|
||||
private function vincularTelefonoCompartido(&$user, $telefono, $bsuid) {
|
||||
// Ya lo teníamos: nada que hacer
|
||||
if (($user['phone_number'] ?? null) === $telefono) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$existente = $this->db->fetch(
|
||||
"SELECT id FROM users WHERE phone_number = :t AND id <> :id",
|
||||
['t' => $telefono, 'id' => $user['id']]
|
||||
);
|
||||
|
||||
try {
|
||||
if ($existente) {
|
||||
// El registro bueno es el que tiene el teléfono. Se le pasa el BSUID
|
||||
// para que a partir de ahora se le reconozca por ahí.
|
||||
if ($bsuid) {
|
||||
$this->db->update('users', ['bsuid' => null], 'id = ?', [$user['id']]);
|
||||
$this->db->update('users', ['bsuid' => $bsuid], 'id = ?', [$existente['id']]);
|
||||
}
|
||||
error_log(sprintf(
|
||||
'[webhook] BSUID %s compartió el teléfono %s, que ya era del usuario %d. ' .
|
||||
'El usuario %d queda duplicado y hay que revisarlo a mano.',
|
||||
$bsuid, $telefono, $existente['id'], $user['id']
|
||||
));
|
||||
$user = $this->getUserById($existente['id']) ?: $user;
|
||||
return true;
|
||||
}
|
||||
|
||||
// No había otro registro: el placeholder pasa a tener el teléfono real
|
||||
$this->db->update('users', ['phone_number' => $telefono], 'id = ?', [$user['id']]);
|
||||
$user['phone_number'] = $telefono;
|
||||
error_log(sprintf('[webhook] BSUID %s quedó vinculado al teléfono %s', $bsuid, $telefono));
|
||||
return true;
|
||||
|
||||
} catch (Exception $e) {
|
||||
error_log('[webhook] Error vinculando el teléfono compartido: ' . $e->getMessage());
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private function getUserByPhone($phoneNumber) {
|
||||
return $this->db->fetch(
|
||||
"SELECT * FROM users WHERE phone_number = :phone",
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -7,6 +7,15 @@
|
||||
require_once __DIR__ . '/../../classes/Database.php';
|
||||
require_once __DIR__ . '/ActividadAdmin.php';
|
||||
|
||||
// esBsuid() vive en config.php; se garantiza aquí por si esta clase se incluye
|
||||
// directamente, sin pasar por el arranque del ERP.
|
||||
if (!function_exists('esBsuid')) {
|
||||
$configPaciente = __DIR__ . '/../../config/config.php';
|
||||
if (file_exists($configPaciente)) {
|
||||
require_once $configPaciente;
|
||||
}
|
||||
}
|
||||
|
||||
class Paciente {
|
||||
|
||||
private Database $db;
|
||||
@@ -173,8 +182,22 @@ class Paciente {
|
||||
}
|
||||
}
|
||||
|
||||
// Registrar antes y después de lo que realmente cambia. Guardar solo el
|
||||
// valor nuevo impide reconstruir el dato anterior si la corrección
|
||||
// resultó equivocada, que es justo cuando hace falta consultarlo.
|
||||
$antes = $this->db->fetch('SELECT * FROM lab_pacientes WHERE id = ?', [$id]) ?: [];
|
||||
$cambios = [];
|
||||
foreach ($campos as $campo => $nuevo) {
|
||||
$previo = $antes[$campo] ?? null;
|
||||
if ((string)$previo !== (string)$nuevo) {
|
||||
$cambios[$campo] = ['antes' => $previo, 'despues' => $nuevo];
|
||||
}
|
||||
}
|
||||
|
||||
$ok = $this->db->update('lab_pacientes', $campos, 'id = ?', [$id]);
|
||||
$this->log->registrar($adminId, 'pacientes', 'editar', $id, $campos);
|
||||
if ($cambios) {
|
||||
$this->log->registrar($adminId, 'pacientes', 'editar', $id, $cambios);
|
||||
}
|
||||
return $ok > 0;
|
||||
}
|
||||
|
||||
@@ -244,10 +267,19 @@ class Paciente {
|
||||
[$userId]
|
||||
);
|
||||
|
||||
// Quien oculta su teléfono en WhatsApp se identifica con un BSUID, que ocupa
|
||||
// el lugar del número en `users`. No es un teléfono: guardarlo aquí dejaría
|
||||
// en la historia clínica un dato falso con apariencia de número real, porque
|
||||
// normalizarTelefono() le quita el punto y las letras y lo deja en 16 dígitos.
|
||||
// Mejor la ficha sin teléfono, que es la verdad: no lo tenemos.
|
||||
$identificador = $user['phone_number'] ?? null;
|
||||
$esIdentificadorSinTelefono = esBsuid($identificador);
|
||||
|
||||
return $this->crear([
|
||||
'user_id' => $userId,
|
||||
'nombre_completo'=> $user['name'] ?? ('Paciente ' . $user['phone_number']),
|
||||
'telefono' => $user['phone_number'] ?? null,
|
||||
'nombre_completo'=> $user['name']
|
||||
?? ($esIdentificadorSinTelefono ? 'Paciente sin identificar' : 'Paciente ' . $identificador),
|
||||
'telefono' => $esIdentificadorSinTelefono ? null : $identificador,
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -62,6 +62,7 @@ define('SYSTEM_MODULES', [
|
||||
// ── Sistema ──────────────────────────────────────────────────────────────
|
||||
'usuarios' => 'Gestión de Usuarios',
|
||||
'enfermero_portal' => 'Portal Enfermero',
|
||||
'soporte' => 'Soporte y Documentación',
|
||||
// ── Oleada 1 — Turnero ───────────────────────────────────────────────────
|
||||
'turnero' => 'Turnero',
|
||||
// ── Oleada 2 — pendiente ─────────────────────────────────────────────────
|
||||
@@ -338,6 +339,24 @@ function deleteConfigFromDB($key) {
|
||||
}
|
||||
}
|
||||
|
||||
if (!function_exists('esBsuid')) {
|
||||
/**
|
||||
* ¿Este identificador es un BSUID de Meta y no un número de teléfono?
|
||||
*
|
||||
* Desde que WhatsApp permite ocultar el número, quien lo oculta llega
|
||||
* identificado solo por su BSUID, con la forma "CO.1761088155094242".
|
||||
* Ese valor ocupa el lugar del teléfono dentro del bot, así que hay que
|
||||
* distinguirlo antes de tratarlo como si fuera un número real: guardarlo
|
||||
* en un campo de teléfono deja un dato falso con toda la pinta de verdadero.
|
||||
*
|
||||
* @param mixed $valor
|
||||
* @return bool
|
||||
*/
|
||||
function esBsuid($valor) {
|
||||
return (bool) preg_match('/^[A-Z]{2}\.\d+$/', (string) $valor);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Limpia el cache estático de configuraciones
|
||||
*/
|
||||
|
||||
+19
-14
@@ -162,25 +162,30 @@ class App
|
||||
$base = '/erp.php?m=turnero&v=lugar&lugar_id=';
|
||||
try {
|
||||
$pdo = Database::getInstance()->getConnection();
|
||||
$ip = self::clientIp();
|
||||
// ¿IP registrada?
|
||||
$dev = $pdo->prepare(
|
||||
"SELECT lugar_id FROM turnero_dispositivos WHERE ip = ? AND activo = 1 LIMIT 1"
|
||||
);
|
||||
$dev->execute([$ip]);
|
||||
$row = $dev->fetch(PDO::FETCH_ASSOC);
|
||||
if ($row) {
|
||||
return $base . (int)$row['lugar_id'];
|
||||
// 1. Por token de navegador
|
||||
$tok = trim($_COOKIE['turnero_token'] ?? '');
|
||||
if ($tok) {
|
||||
$s = $pdo->prepare(
|
||||
"SELECT lugar_id FROM turnero_dispositivos WHERE token = ? AND activo = 1 LIMIT 1"
|
||||
);
|
||||
$s->execute([$tok]);
|
||||
$row = $s->fetch(PDO::FETCH_ASSOC);
|
||||
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(
|
||||
"SELECT id FROM turnero_lugares WHERE activo=1 AND tipo='muestras' ORDER BY sort_order LIMIT 1"
|
||||
)->fetch(PDO::FETCH_ASSOC);
|
||||
if ($first) {
|
||||
return $base . (int)$first['id'];
|
||||
}
|
||||
if ($first) return $base . (int)$first['id'];
|
||||
} catch (\Throwable $_) {}
|
||||
// Fallback: turnero sin vista específica
|
||||
return '/erp.php?m=turnero';
|
||||
}
|
||||
|
||||
|
||||
@@ -23,6 +23,14 @@ class Router
|
||||
private const PUBLIC_ROUTES = [
|
||||
'turnero/display',
|
||||
'turnero/kiosko',
|
||||
// Tablet de firma del paciente: la manipula el público y nadie va a
|
||||
// iniciar sesión en ella cada mañana. No queda abierta: se identifica
|
||||
// por la cookie del dispositivo y sin ella no muestra dato alguno.
|
||||
'turnero/firma',
|
||||
// Prueba de voces: hay que abrirla EN el televisor para saber qué voces
|
||||
// tiene ese equipo, y allí no hay sesión iniciada. No expone nada: solo
|
||||
// lista las voces del navegador y lee una frase de ejemplo inventada.
|
||||
'turnero/voces',
|
||||
];
|
||||
|
||||
/** Patrón permitido para módulo y vista: solo letras, números y guión bajo */
|
||||
|
||||
+66
-6
@@ -16,11 +16,11 @@ $enfId = (int)($usuario['enfermera_id'] ?? 0);
|
||||
$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
|
||||
if ($rol === 'admin' && isset($_GET['eid'])) {
|
||||
if (in_array($rol, ['admin', 'superadmin']) && isset($_GET['eid'])) {
|
||||
$enfId = (int)$_GET['eid'];
|
||||
}
|
||||
|
||||
if ($rol !== 'admin' && $rol !== 'enfermero') {
|
||||
if (!in_array($rol, ['admin', 'superadmin', 'enfermero'])) {
|
||||
header('Location: index.php'); exit;
|
||||
}
|
||||
|
||||
@@ -761,7 +761,7 @@ const portal = {
|
||||
? (()=>{
|
||||
const tel = item.paciente_telefono.replace(/\D/g,'');
|
||||
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?'
|
||||
);
|
||||
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||'')}')">
|
||||
<i class="fas fa-folder-open me-1"></i>Ver llenados
|
||||
</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>`;
|
||||
@@ -1452,6 +1457,7 @@ const agendaNueva = {
|
||||
_modal: null,
|
||||
_buscarTimer: null,
|
||||
_nuevoPac: false,
|
||||
_editId: null,
|
||||
|
||||
init() {
|
||||
this._modal = new bootstrap.Modal('#modalNuevaAgenda');
|
||||
@@ -1500,12 +1506,63 @@ const agendaNueva = {
|
||||
document.getElementById('na-np-email').value = '';
|
||||
document.getElementById('na-crear-toggle-txt').textContent = 'Crear nuevo paciente';
|
||||
document.getElementById('na-paciente-buscar').disabled = false;
|
||||
this._editId = null;
|
||||
const btn = document.getElementById('na-btn-guardar');
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="fas fa-calendar-check me-1"></i>Agendar';
|
||||
document.querySelector('#modalNuevaAgenda .modal-title').textContent = 'Nuevo domicilio';
|
||||
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) {
|
||||
if (q.length < 2) {
|
||||
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_copago: parseFloat(document.getElementById('na-valor-cop').value) || null,
|
||||
notas_admin: document.getElementById('na-notas').value.trim() || null,
|
||||
estado: 'programado',
|
||||
...(ENFERMERA_ID ? { enfermera_id: ENFERMERA_ID } : {}),
|
||||
...(this._editId ? { id: this._editId } : { estado: 'programado' }),
|
||||
...(ENFERMERA_ID && !this._editId ? { enfermera_id: ENFERMERA_ID } : {}),
|
||||
};
|
||||
|
||||
try {
|
||||
@@ -1814,10 +1871,13 @@ const agendaNueva = {
|
||||
}
|
||||
|
||||
this._modal.hide();
|
||||
this._editId = null;
|
||||
portal.cargar();
|
||||
} catch (e) {
|
||||
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);
|
||||
}
|
||||
},
|
||||
|
||||
+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 class="topaz-modal-body">
|
||||
<div class="topaz-pad-area" id="topaz-pad-area">
|
||||
<i class="fas fa-signature fa-2x" style="color:#adb5bd" id="topaz-pad-icon"></i>
|
||||
<div id="topaz-status-msg" style="font-size:.9rem;color:#64748b;font-weight:600">
|
||||
<canvas id="topaz-canvas" width="500" height="150"
|
||||
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
|
||||
</div>
|
||||
<div class="topaz-pts-badge">Trazos: <span id="topaz-pts">0</span></div>
|
||||
@@ -597,12 +598,12 @@ function renderCampos(esquema, prefilled) {
|
||||
</div>` + wrapClose;
|
||||
} else if (c.tipo === 'radio') {
|
||||
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)}"
|
||||
${o===val?'checked':''} ${isReadOnly?'disabled':''}>
|
||||
<label class="form-check-label">${esc(o)}</label>
|
||||
</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') {
|
||||
const vals = Array.isArray(val) ? val : [];
|
||||
const opts = (c.options||[]).map(o => `
|
||||
@@ -1233,7 +1234,8 @@ function iniciarCondiciones(esquema) {
|
||||
} else {
|
||||
const radio = ctrl.querySelector(`input[type="radio"]:checked`);
|
||||
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);
|
||||
}
|
||||
|
||||
@@ -1270,17 +1272,8 @@ function iniciarCondiciones(esquema) {
|
||||
// TOPAZ SIGWEB
|
||||
// ══════════════════════════════════════════════════════════════════════
|
||||
const topaz = (() => {
|
||||
const SIGWEB_URL = 'http://localhost:47289/SigWeb/SigWebTablet.js';
|
||||
let _loaded = false, _ctx = null, _fid = null, _poll = 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');
|
||||
}
|
||||
const SIGWEB_URL = '/assets/js/SigWebTablet.js';
|
||||
let _loaded = false, _fid = null, _poll = null, _tmr = null;
|
||||
|
||||
function _loadScript() {
|
||||
if (_loaded) return Promise.resolve(true);
|
||||
@@ -1312,12 +1305,14 @@ const topaz = (() => {
|
||||
}
|
||||
_fid = fid;
|
||||
try {
|
||||
_ctx = typeof SigWebTablet !== 'undefined' ? new SigWebTablet() : null;
|
||||
_call('SetImageXSize', 500);
|
||||
_call('SetImageYSize', 150);
|
||||
_call('SetImagePenWidth', 3);
|
||||
_call('SetTabletState', 1);
|
||||
_call('ClearTablet');
|
||||
const canvas = document.getElementById('topaz-canvas');
|
||||
const canvasCtx = canvas.getContext('2d');
|
||||
canvasCtx.clearRect(0, 0, canvas.width, canvas.height);
|
||||
SetImageXSize(500);
|
||||
SetImageYSize(150);
|
||||
SetImagePenWidth(3);
|
||||
ClearTablet();
|
||||
_tmr = SetTabletState(1, canvasCtx, 50);
|
||||
} catch(e) {
|
||||
alert('Error al activar el pad: ' + e.message);
|
||||
return;
|
||||
@@ -1327,13 +1322,30 @@ const topaz = (() => {
|
||||
$('topaz-pts').textContent = '0';
|
||||
_setStatus('Firme en el pad biométrico', false);
|
||||
|
||||
let _lastPts = -1;
|
||||
_poll = setInterval(() => {
|
||||
try {
|
||||
const pts = _call('GetSigTotalPoints');
|
||||
const pts = NumberOfTabletPoints();
|
||||
$('topaz-pts').textContent = pts;
|
||||
const hasSig = pts > 0;
|
||||
$('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(); }
|
||||
}, 400);
|
||||
}
|
||||
@@ -1342,8 +1354,8 @@ const topaz = (() => {
|
||||
|
||||
function _cerrarOverlay() {
|
||||
_stopPoll();
|
||||
try { if (_ctx) _call('SetTabletState', 0); } catch(e) {}
|
||||
_ctx = null; _fid = null;
|
||||
try { SetTabletState(0, _tmr); } catch(e) {}
|
||||
_tmr = null; _fid = null;
|
||||
$('topaz-overlay').style.display = 'none';
|
||||
}
|
||||
|
||||
@@ -1357,18 +1369,13 @@ const topaz = (() => {
|
||||
}
|
||||
|
||||
function aceptar() {
|
||||
let b64;
|
||||
try { b64 = _call('GetSigImageB64'); } catch(e) {
|
||||
alert('Error al capturar la firma: ' + e.message);
|
||||
_cerrarOverlay(); return;
|
||||
}
|
||||
if (!b64) { alert('No se capturó ninguna firma.'); return; }
|
||||
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const fid = _fid;
|
||||
_cerrarOverlay();
|
||||
|
||||
const fid = _fid;
|
||||
_cerrarOverlay();
|
||||
try {
|
||||
GetSigImageB64(function(b64) {
|
||||
if (!b64) { alert('No se capturó ninguna firma.'); return; }
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
if (fid === '__global') {
|
||||
const c = $('firma-canvas');
|
||||
const cx = c.getContext('2d');
|
||||
@@ -1391,8 +1398,10 @@ const topaz = (() => {
|
||||
const st = document.getElementById('fw-' + fid + '-status');
|
||||
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 };
|
||||
|
||||
@@ -19,9 +19,16 @@ if (isEnfermero()) {
|
||||
}
|
||||
$_indexRole = $_SESSION['admin_user']['role'] ?? '';
|
||||
$_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;
|
||||
}
|
||||
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)) {
|
||||
header('Location: lab_dashboard.php'); exit;
|
||||
}
|
||||
|
||||
+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)); }),
|
||||
]);
|
||||
});
|
||||
const csv = '\uFEFF' + csvRows.map(r=>r.map(v=>'"'+String(v).replace(/"/g,'""')+'"').join(',')).join('\r\n');
|
||||
const blob = new Blob([csv],{type:'text/csv;charset=utf-8;'});
|
||||
const xlsEsc = v => String(v).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
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 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);
|
||||
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('/');
|
||||
}
|
||||
|
||||
// Quien oculta su número en WhatsApp llega identificado con un BSUID
|
||||
// ("CO.1761088155094242"), que se guarda en phone_number pero no es un teléfono.
|
||||
const esBsuid = v => /^[A-Z]{2}\.\d+$/.test(String(v || ''));
|
||||
|
||||
/**
|
||||
* Celda de contacto. Si de esta persona no tenemos teléfono porque lo tiene
|
||||
* oculto, se dice así en vez de mostrar el identificador crudo: recepción
|
||||
* necesita entender por qué no puede llamarla, no ver un código.
|
||||
*/
|
||||
function celdaTelefono(p) {
|
||||
if (esBsuid(p.phone_number)) {
|
||||
return (p.telefono ? esc(p.telefono) : '<span class="text-muted">Sin teléfono</span>')
|
||||
+ '<br><i class="fab fa-whatsapp text-success"></i> '
|
||||
+ '<small class="text-muted" title="Tiene el número oculto en WhatsApp. '
|
||||
+ 'Se le puede escribir por el chat, pero no llamar.">Solo por WhatsApp</small>';
|
||||
}
|
||||
return esc(p.telefono || '—')
|
||||
+ (p.phone_number
|
||||
? `<br><i class="fab fa-whatsapp text-success"></i> <small class="text-muted">${esc(p.phone_number)}</small>`
|
||||
: '');
|
||||
}
|
||||
|
||||
async function cargarLista(pag = 1) {
|
||||
paginaActual = pag;
|
||||
const busq = document.getElementById('buscador').value.trim();
|
||||
@@ -495,7 +517,7 @@ async function cargarLista(pag = 1) {
|
||||
${p.genero ? `<small class="text-muted">${p.genero==='M'?'Masculino':p.genero==='F'?'Femenino':'Otro'}</small>` : ''}
|
||||
</td>
|
||||
<td class="small">${tipoDocLabel(p.tipo_documento)}<br><span class="fw-semibold">${esc(p.numero_documento||'—')}</span></td>
|
||||
<td class="small">${esc(p.telefono||'—')}${p.phone_number ? `<br><i class="fab fa-whatsapp text-success"></i> <small class="text-muted">${esc(p.phone_number)}</small>` : ''}</td>
|
||||
<td class="small">${celdaTelefono(p)}</td>
|
||||
<td class="small text-muted">${esc(p.email||'—')}</td>
|
||||
<td class="small text-muted">${esc(p.ciudad||'—')}</td>
|
||||
<td class="small text-muted">${esc(p.eps||'—')}</td>
|
||||
@@ -546,8 +568,18 @@ async function verDetalle(id) {
|
||||
document.getElementById('detail-body').innerHTML = `
|
||||
<dl class="row small mb-3">
|
||||
<dt class="col-5 text-muted">Documento</dt><dd class="col-7">${tipoDocLabel(p.tipo_documento)} ${esc(p.numero_documento||'—')}</dd>
|
||||
<dt class="col-5 text-muted">Teléfono</dt><dd class="col-7">${esc(p.telefono||'—')}</dd>
|
||||
<dt class="col-5 text-muted">WhatsApp</dt><dd class="col-7">${p.phone_number ? `<i class="fab fa-whatsapp text-success"></i> ${esc(p.phone_number)}` : '—'}</dd>
|
||||
<dt class="col-5 text-muted">Teléfono</dt><dd class="col-7">${
|
||||
p.telefono ? esc(p.telefono)
|
||||
: (esBsuid(p.phone_number)
|
||||
? '<span class="text-muted">Sin teléfono — lo tiene oculto en WhatsApp</span>'
|
||||
: '—')
|
||||
}</dd>
|
||||
<dt class="col-5 text-muted">WhatsApp</dt><dd class="col-7">${
|
||||
!p.phone_number ? '—'
|
||||
: (esBsuid(p.phone_number)
|
||||
? '<i class="fab fa-whatsapp text-success"></i> Se le puede escribir por el chat, pero no llamar'
|
||||
: `<i class="fab fa-whatsapp text-success"></i> ${esc(p.phone_number)}`)
|
||||
}</dd>
|
||||
<dt class="col-5 text-muted">Origen</dt><dd class="col-7">${origenBadge(p.origen)}</dd>
|
||||
<dt class="col-5 text-muted">Registro</dt><dd class="col-7 text-muted small">${fmtFecha(p.created_at)}</dd>
|
||||
${p.email ? `<dt class="col-5 text-muted">Email</dt><dd class="col-7">${esc(p.email)}</dd>` : ''}
|
||||
|
||||
+14
-10
@@ -35,7 +35,7 @@ if ($tipo) {
|
||||
ORDER BY o.created_at DESC",
|
||||
[$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'];
|
||||
break;
|
||||
|
||||
@@ -109,7 +109,7 @@ if ($tipo) {
|
||||
$sqlParams
|
||||
);
|
||||
$rangoLabel = !empty($_GET['fecha']) ? $_GET['fecha'] : "{$desde}_{$hasta}";
|
||||
$filename = "domicilios_{$rangoLabel}.csv";
|
||||
$filename = "domicilios_{$rangoLabel}.xls";
|
||||
$headers = [
|
||||
'ID','Paciente','Documento','Teléfono','EPS',
|
||||
'Fecha','Hora programada','Hora llegada','Hora salida','Duración',
|
||||
@@ -133,7 +133,7 @@ if ($tipo) {
|
||||
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'];
|
||||
break;
|
||||
|
||||
@@ -191,7 +191,7 @@ if ($tipo) {
|
||||
ORDER BY d.fecha_programada ASC, d.id ASC",
|
||||
$pParams
|
||||
);
|
||||
$filename = "reporte_pagos_{$rangoNombre}.csv";
|
||||
$filename = "reporte_pagos_{$rangoNombre}.xls";
|
||||
$headers = [
|
||||
'ID','Fecha programada','Fecha pago','Paciente','Documento',
|
||||
'Enfermero/a','Tipo cliente','Seguro','Estado domicilio',
|
||||
@@ -204,17 +204,21 @@ if ($tipo) {
|
||||
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('Pragma: no-cache');
|
||||
|
||||
$f = fopen('php://output', 'w');
|
||||
fputs($f, "\xEF\xBB\xBF"); // BOM UTF-8 para Excel
|
||||
fputcsv($f, $headers);
|
||||
$esc = fn($v) => htmlspecialchars((string)$v, ENT_XML1, 'UTF-8');
|
||||
echo '<' . '?xml version="1.0" encoding="UTF-8"?' . '>' . "\n";
|
||||
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) {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -41,16 +41,31 @@ if ($_POST && !$loginBlocked) {
|
||||
$_SESSION['login_ip'] = $clientIp;
|
||||
$_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 {
|
||||
$_dispStmt = Database::getInstance()->getConnection()->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"
|
||||
);
|
||||
$_dispStmt->execute([$clientIp]);
|
||||
$_disp = $_dispStmt->fetch(PDO::FETCH_ASSOC);
|
||||
$_pdo2 = Database::getInstance()->getConnection();
|
||||
$_disp = null;
|
||||
$_devToken = trim($_COOKIE['turnero_token'] ?? '');
|
||||
if ($_devToken) {
|
||||
$_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.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) {
|
||||
$_SESSION['turnero_dispositivo'] = $_disp;
|
||||
$url = $_disp['tipo'] === 'recepcion'
|
||||
@@ -89,7 +104,13 @@ if ($_POST && !$loginBlocked) {
|
||||
if ($roleSlug === 'enfermero') {
|
||||
header('Location: enfermero_portal.php');
|
||||
} 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)) {
|
||||
header('Location: lab_dashboard.php');
|
||||
} 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
|
||||
);
|
||||
@@ -1,4 +1,7 @@
|
||||
<?php
|
||||
/**
|
||||
* GET ?id=N — Detalle de un examen del catálogo con sus ítems.
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireLogin();
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<?php
|
||||
/**
|
||||
* GET ?exam_id=N — Tarifas de un examen por empresa o convenio.
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireLogin();
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<?php
|
||||
/**
|
||||
* GET ?q=&categoria=&page=&limit= — Catálogo de exámenes, paginado y filtrable.
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireLogin();
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<?php
|
||||
/**
|
||||
* POST — Crea o actualiza un examen del catálogo. Requiere administrador.
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireAdmin();
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') jsonError('Método no permitido', 405);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<?php
|
||||
/**
|
||||
* POST — Crea o actualiza un ítem (analito) de un examen. Requiere administrador.
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireAdmin();
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') jsonError('Método no permitido', 405);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<?php
|
||||
/**
|
||||
* POST — Crea o actualiza la tarifa de un examen. Requiere administrador.
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireAdmin();
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') jsonError('Método no permitido', 405);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<?php
|
||||
/**
|
||||
* POST { id } — Elimina un médico del catálogo.
|
||||
*/
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
if (!isUserLoggedIn()) { http_response_code(401); echo json_encode(['ok'=>false,'error'=>'No autorizado']); exit; }
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<?php
|
||||
/**
|
||||
* GET — Lista de médicos solicitantes.
|
||||
*/
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
if (!isUserLoggedIn()) { http_response_code(401); echo json_encode(['ok'=>false,'error'=>'No autorizado']); exit; }
|
||||
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
<?php
|
||||
/**
|
||||
* POST — Crea o actualiza un médico del catálogo.
|
||||
*/
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
if (!isUserLoggedIn()) { http_response_code(401); echo json_encode(['ok'=>false,'error'=>'No autorizado']); exit; }
|
||||
|
||||
|
||||
@@ -0,0 +1,285 @@
|
||||
<?php
|
||||
/**
|
||||
* modules/soporte/DocIndex.php
|
||||
* Descubre los documentos en docs/, arma el árbol de navegación y resuelve el
|
||||
* control de acceso por sección.
|
||||
*
|
||||
* Convenciones:
|
||||
* docs/<seccion>/<NN>-<slug>.md → el prefijo NN solo ordena, no se muestra
|
||||
* El título sale del primer encabezado `# ` del archivo.
|
||||
*
|
||||
* Un documento puede restringir su visibilidad con una cabecera al inicio:
|
||||
*
|
||||
* ---
|
||||
* roles: enfermero, recepcionista
|
||||
* ---
|
||||
*
|
||||
* Sin cabecera, hereda el permiso de su sección. Los administradores ven todo.
|
||||
*/
|
||||
|
||||
final class DocIndex
|
||||
{
|
||||
/** Secciones, en orden de aparición. `admin` = solo administradores. */
|
||||
public const SECCIONES = [
|
||||
'manual' => ['titulo' => 'Manual de usuario', 'icono' => 'fas fa-book-reader', 'admin' => false,
|
||||
'resumen' => 'Cómo usar el sistema, paso a paso, según su rol.'],
|
||||
'tecnica' => ['titulo' => 'Documentación técnica','icono' => 'fas fa-code', 'admin' => true,
|
||||
'resumen' => 'Cada módulo por dentro: tablas, endpoints y dependencias.'],
|
||||
'arquitectura'=> ['titulo' => 'Arquitectura', 'icono' => 'fas fa-sitemap', 'admin' => true,
|
||||
'resumen' => 'Cómo está armado el sistema y por qué.'],
|
||||
'operacion' => ['titulo' => 'Operación y soporte', 'icono' => 'fas fa-life-ring', 'admin' => true,
|
||||
'resumen' => 'Qué hacer cuando algo falla. Configuraciones críticas.'],
|
||||
];
|
||||
|
||||
/** Roles que ven todo, sin importar lo que declare cada documento. */
|
||||
public const ROLES_TOTALES = ['admin', 'superadmin'];
|
||||
|
||||
public static function dir(): string
|
||||
{
|
||||
return __DIR__ . '/docs';
|
||||
}
|
||||
|
||||
private static function rolActual(): string
|
||||
{
|
||||
return $_SESSION['admin_user']['role'] ?? '';
|
||||
}
|
||||
|
||||
private static function esAdmin(): bool
|
||||
{
|
||||
return in_array(self::rolActual(), self::ROLES_TOTALES, true);
|
||||
}
|
||||
|
||||
/**
|
||||
* Separa la cabecera del cuerpo de un documento.
|
||||
* @return array{0: array<string,string>, 1: string} [metadatos, cuerpo]
|
||||
*/
|
||||
public static function leer(string $archivo): array
|
||||
{
|
||||
$texto = (string)@file_get_contents($archivo);
|
||||
if (!preg_match('/^---\R(.*?)\R---\R?(.*)$/s', $texto, $m)) {
|
||||
return [[], $texto];
|
||||
}
|
||||
$meta = [];
|
||||
foreach (preg_split('/\R/', $m[1]) as $linea) {
|
||||
if (preg_match('/^\s*([\w-]+)\s*:\s*(.*)$/', $linea, $kv)) {
|
||||
$meta[strtolower($kv[1])] = trim($kv[2]);
|
||||
}
|
||||
}
|
||||
return [$meta, ltrim($m[2])];
|
||||
}
|
||||
|
||||
/** ¿El usuario actual puede ver este documento concreto? */
|
||||
public static function puedeVerDoc(string $seccion, string $archivo): bool
|
||||
{
|
||||
if (self::esAdmin()) return true;
|
||||
if (!self::puedeVer($seccion)) return false;
|
||||
|
||||
[$meta] = self::leer($archivo);
|
||||
if (empty($meta['roles'])) return true; // sin cabecera: hereda la sección
|
||||
|
||||
$permitidos = array_filter(array_map('trim', explode(',', strtolower($meta['roles']))));
|
||||
return in_array(strtolower(self::rolActual()), $permitidos, true);
|
||||
}
|
||||
|
||||
/** ¿El usuario actual puede ver esta sección? */
|
||||
public static function puedeVer(string $seccion): bool
|
||||
{
|
||||
$cfg = self::SECCIONES[$seccion] ?? null;
|
||||
if (!$cfg) return false;
|
||||
if (!$cfg['admin']) return true;
|
||||
return self::esAdmin();
|
||||
}
|
||||
|
||||
/** Secciones visibles para el usuario actual. */
|
||||
public static function seccionesVisibles(): array
|
||||
{
|
||||
return array_filter(
|
||||
self::SECCIONES,
|
||||
fn($s) => self::puedeVer($s),
|
||||
ARRAY_FILTER_USE_KEY
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Árbol completo: [seccion => ['titulo'=>..,'docs'=>[['slug','titulo','archivo'],...]]]
|
||||
* Solo incluye lo que el usuario puede ver.
|
||||
*/
|
||||
public static function arbol(): array
|
||||
{
|
||||
$arbol = [];
|
||||
foreach (self::seccionesVisibles() as $sec => $cfg) {
|
||||
$ruta = self::dir() . '/' . $sec;
|
||||
if (!is_dir($ruta)) continue;
|
||||
|
||||
$docs = [];
|
||||
foreach (glob($ruta . '/*.md') ?: [] as $archivo) {
|
||||
if (!self::puedeVerDoc($sec, $archivo)) continue;
|
||||
$base = basename($archivo, '.md');
|
||||
$slug = preg_replace('/^\d+-/', '', $base);
|
||||
$docs[] = [
|
||||
'slug' => $slug,
|
||||
'titulo' => self::titulo($archivo, $slug),
|
||||
'archivo' => $archivo,
|
||||
'orden' => $base,
|
||||
];
|
||||
}
|
||||
usort($docs, fn($a, $b) => strcmp($a['orden'], $b['orden']));
|
||||
|
||||
// Una sección sin documentos visibles no se muestra
|
||||
if (!$docs) continue;
|
||||
|
||||
$arbol[$sec] = $cfg + ['docs' => $docs];
|
||||
}
|
||||
return $arbol;
|
||||
}
|
||||
|
||||
/** Ruta del archivo pedido, o null si no existe o no hay acceso. */
|
||||
public static function resolver(string $seccion, string $slug): ?string
|
||||
{
|
||||
if (!self::puedeVer($seccion)) return null;
|
||||
// Evitar traversal: los slugs son [a-z0-9-]
|
||||
if (!preg_match('/^[a-z0-9-]+$/', $seccion) || !preg_match('/^[a-z0-9-]+$/', $slug)) return null;
|
||||
|
||||
foreach (glob(self::dir() . '/' . $seccion . '/*.md') ?: [] as $archivo) {
|
||||
if (preg_replace('/^\d+-/', '', basename($archivo, '.md')) !== $slug) continue;
|
||||
return self::puedeVerDoc($seccion, $archivo) ? $archivo : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Primer encabezado `# ` del archivo; si no hay, el slug capitalizado. */
|
||||
private static function titulo(string $archivo, string $slugFallback): string
|
||||
{
|
||||
$fh = @fopen($archivo, 'r');
|
||||
if ($fh) {
|
||||
$lineas = 0;
|
||||
while (($l = fgets($fh)) !== false && $lineas++ < 30) {
|
||||
if (preg_match('/^#\s+(.+)$/', trim($l), $m)) { fclose($fh); return trim($m[1]); }
|
||||
}
|
||||
fclose($fh);
|
||||
}
|
||||
return ucfirst(str_replace('-', ' ', $slugFallback));
|
||||
}
|
||||
|
||||
/** Palabras que no aportan al puntaje de relevancia. */
|
||||
private const VACIAS = [
|
||||
'como','cual','cuales','donde','cuando','porque','para','pero','esta','este','esto',
|
||||
'con','sin','por','que','del','las','los','una','uno','del','sus','sobre','desde',
|
||||
'hacer','tengo','puedo','quiero','necesito','ayuda','favor','the','and','not','del',
|
||||
];
|
||||
|
||||
/** Secciones que puede consultar el asistente. Ver contextoIA(). */
|
||||
public const SECCIONES_IA = ['manual'];
|
||||
|
||||
/**
|
||||
* Selecciona los documentos más relevantes para una pregunta y devuelve su
|
||||
* texto, listo para dárselo a un modelo de lenguaje.
|
||||
*
|
||||
* Dos restricciones se aplican a la vez:
|
||||
*
|
||||
* 1. Por sección: el asistente solo consulta el manual de usuario. Está
|
||||
* para ayudar a usar el sistema, no a mantenerlo — la documentación
|
||||
* técnica, de arquitectura y de operación queda fuera incluso para
|
||||
* administradores, que pueden leerla directamente en el módulo.
|
||||
* 2. Por rol: dentro del manual solo entra lo que ese usuario podría leer
|
||||
* por su cuenta, así el asistente no puede revelar contenido ajeno.
|
||||
*
|
||||
* @return array{0: string, 1: array<int,string>} [contexto, títulos usados]
|
||||
*/
|
||||
public static function contextoIA(string $pregunta, int $maxDocs = 4, int $maxChars = 14000): array
|
||||
{
|
||||
$palabras = array_values(array_filter(
|
||||
preg_split('/[^a-záéíóúñü0-9]+/u', mb_strtolower($pregunta)),
|
||||
fn($p) => mb_strlen($p) > 2 && !in_array($p, self::VACIAS, true)
|
||||
));
|
||||
if (!$palabras) return ['', []];
|
||||
|
||||
// Textos de los documentos consultables
|
||||
$docs = [];
|
||||
foreach (self::arbol() as $sec => $cfg) {
|
||||
if (!in_array($sec, self::SECCIONES_IA, true)) continue;
|
||||
foreach ($cfg['docs'] as $doc) {
|
||||
[, $cuerpo] = self::leer($doc['archivo']);
|
||||
$docs[] = [
|
||||
'titulo' => $doc['titulo'],
|
||||
'seccion' => $cfg['titulo'],
|
||||
'cuerpo' => $cuerpo,
|
||||
'heno' => mb_strtolower($doc['titulo'] . ' ' . $cuerpo),
|
||||
];
|
||||
}
|
||||
}
|
||||
if (!$docs) return ['', []];
|
||||
|
||||
// Peso de cada palabra según en cuántos documentos aparece: una que está
|
||||
// en todos ("paciente") no distingue nada; una que está en pocos sí.
|
||||
$total = count($docs);
|
||||
$peso = [];
|
||||
foreach ($palabras as $p) {
|
||||
$enCuantos = 0;
|
||||
foreach ($docs as $d) if (str_contains($d['heno'], $p)) $enCuantos++;
|
||||
$peso[$p] = $enCuantos ? log(1 + $total / $enCuantos) : 0;
|
||||
}
|
||||
|
||||
$candidatos = [];
|
||||
foreach ($docs as $d) {
|
||||
$puntaje = 0.0;
|
||||
foreach ($palabras as $p) {
|
||||
if (!$peso[$p]) continue;
|
||||
// El título pesa mucho más que una mención en el cuerpo
|
||||
$puntaje += substr_count(mb_strtolower($d['titulo']), $p) * 12 * $peso[$p];
|
||||
$puntaje += min(substr_count($d['heno'], $p), 6) * $peso[$p];
|
||||
}
|
||||
if ($puntaje > 0) $candidatos[] = ['puntaje' => $puntaje] + $d;
|
||||
}
|
||||
if (!$candidatos) return ['', []];
|
||||
|
||||
usort($candidatos, fn($a, $b) => $b['puntaje'] <=> $a['puntaje']);
|
||||
|
||||
// Umbral absoluto: por debajo son coincidencias sueltas de palabras
|
||||
// sin relación real con la pregunta, y darle eso al modelo lo lleva a
|
||||
// responder con lo que tenga a mano en vez de admitir que no sabe.
|
||||
if ($candidatos[0]['puntaje'] < 6) return ['', []];
|
||||
|
||||
// Y relativo: descartar lo que quede muy por debajo del mejor resultado
|
||||
$corte = $candidatos[0]['puntaje'] * 0.45;
|
||||
$candidatos = array_values(array_filter($candidatos, fn($c) => $c['puntaje'] >= $corte));
|
||||
$candidatos = array_slice($candidatos, 0, $maxDocs);
|
||||
|
||||
$porDoc = (int)floor($maxChars / count($candidatos));
|
||||
$ctx = '';
|
||||
$titulos = [];
|
||||
foreach ($candidatos as $c) {
|
||||
$texto = preg_replace('/\{\{\w+\}\}/', '', $c['cuerpo']); // los marcadores no aportan
|
||||
$texto = mb_substr(trim($texto), 0, $porDoc);
|
||||
$ctx .= "\n\n===== [{$c['seccion']}] {$c['titulo']} =====\n" . $texto;
|
||||
$titulos[] = $c['titulo'];
|
||||
}
|
||||
return [trim($ctx), $titulos];
|
||||
}
|
||||
|
||||
/**
|
||||
* Índice para el buscador: un registro por documento con su texto plano.
|
||||
* Solo incluye secciones visibles para el usuario actual.
|
||||
*/
|
||||
public static function indiceBusqueda(): array
|
||||
{
|
||||
$out = [];
|
||||
foreach (self::arbol() as $sec => $cfg) {
|
||||
foreach ($cfg['docs'] as $doc) {
|
||||
[, $texto] = self::leer($doc['archivo']);
|
||||
// Aplanar: sin marcas, sin saltos, compacto
|
||||
$texto = preg_replace('/```.*?```/s', ' ', $texto);
|
||||
$texto = preg_replace('/[#>*_`|-]+/', ' ', $texto);
|
||||
$texto = preg_replace('/\s+/', ' ', $texto);
|
||||
$out[] = [
|
||||
's' => $sec,
|
||||
'u' => $doc['slug'],
|
||||
't' => $doc['titulo'],
|
||||
'c' => $cfg['titulo'],
|
||||
'x' => mb_substr(trim($texto), 0, 4000),
|
||||
];
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,220 @@
|
||||
<?php
|
||||
/**
|
||||
* modules/soporte/Generadores.php
|
||||
* Secciones de la documentación que se leen del código y de la base de datos
|
||||
* en cada carga, en vez de escribirse a mano.
|
||||
*
|
||||
* El objetivo es que los inventarios (módulos, endpoints, tablas, roles) no
|
||||
* puedan quedar desactualizados: si alguien agrega un módulo o una tabla,
|
||||
* aparece aquí sin que nadie tenga que acordarse de editar un .md.
|
||||
*
|
||||
* Se invocan desde los documentos con marcadores en una línea propia:
|
||||
* {{modulos}} {{endpoints}} {{tablas}} {{roles}} {{servicios}}
|
||||
*/
|
||||
|
||||
final class Generadores
|
||||
{
|
||||
/** Reemplaza los marcadores {{...}} de un documento por su tabla generada. */
|
||||
public static function expandir(string $md): string
|
||||
{
|
||||
return preg_replace_callback('/^\{\{(\w+)\}\}\s*$/m', function ($m) {
|
||||
$metodo = 'gen' . ucfirst($m[1]);
|
||||
if (!method_exists(self::class, $metodo)) return $m[0];
|
||||
try {
|
||||
return self::$metodo();
|
||||
} catch (\Throwable $e) {
|
||||
return '> No se pudo generar esta sección: ' . $e->getMessage();
|
||||
}
|
||||
}, $md);
|
||||
}
|
||||
|
||||
private static function pdo(): PDO
|
||||
{
|
||||
return Database::getInstance()->getConnection();
|
||||
}
|
||||
|
||||
private static function raiz(): string
|
||||
{
|
||||
return dirname(__DIR__, 2);
|
||||
}
|
||||
|
||||
// ── Módulos registrados ───────────────────────────────────────────
|
||||
private static function genModulos(): string
|
||||
{
|
||||
$dirs = glob(self::raiz() . '/modules/*', GLOB_ONLYDIR) ?: [];
|
||||
$filas = [];
|
||||
foreach ($dirs as $dir) {
|
||||
$slug = basename($dir);
|
||||
$meta = ['name' => $slug, 'description' => '', 'oleada' => ''];
|
||||
$mf = $dir . '/module.php';
|
||||
if (is_file($mf)) {
|
||||
try {
|
||||
$m = @include $mf;
|
||||
if (is_array($m)) $meta = $m + $meta;
|
||||
} catch (\Throwable $e) { /* un module.php con contexto no cargable no debe romper la doc */ }
|
||||
}
|
||||
$vistas = count(glob($dir . '/views/*.php') ?: []);
|
||||
$apis = count(glob($dir . '/api/*.php') ?: []);
|
||||
$enSys = defined('SYSTEM_MODULES') && array_key_exists($slug, SYSTEM_MODULES);
|
||||
$filas[] = [
|
||||
'`' . $slug . '`',
|
||||
$meta['name'] ?? $slug,
|
||||
$vistas ?: '—',
|
||||
$apis ?: '—',
|
||||
$enSys ? 'sí' : 'no',
|
||||
$meta['description'] ?? '',
|
||||
];
|
||||
}
|
||||
usort($filas, fn($a, $b) => strcmp($a[0], $b[0]));
|
||||
return self::tabla(
|
||||
['Slug', 'Nombre', 'Vistas', 'Endpoints', 'En SYSTEM_MODULES', 'Descripción'],
|
||||
$filas
|
||||
) . "\n\n_" . count($filas) . " módulos en `modules/`. Generado del filesystem._\n";
|
||||
}
|
||||
|
||||
// ── Endpoints por módulo ──────────────────────────────────────────
|
||||
private static function genEndpoints(): string
|
||||
{
|
||||
$out = '';
|
||||
foreach (glob(self::raiz() . '/modules/*/api', GLOB_ONLYDIR) ?: [] as $dir) {
|
||||
$slug = basename(dirname($dir));
|
||||
$files = glob($dir . '/*.php') ?: [];
|
||||
if (!$files) continue;
|
||||
sort($files);
|
||||
|
||||
$filas = [];
|
||||
foreach ($files as $f) {
|
||||
$nombre = basename($f);
|
||||
if (str_starts_with($nombre, '_')) continue; // helpers internos
|
||||
$filas[] = ['`' . $nombre . '`', self::resumenPhpDoc($f)];
|
||||
}
|
||||
if (!$filas) continue;
|
||||
$out .= "\n### " . $slug . "\n\n" . self::tabla(['Endpoint', 'Qué hace'], $filas) . "\n";
|
||||
}
|
||||
return $out ?: '> Sin endpoints.';
|
||||
}
|
||||
|
||||
// ── Tablas de la base de datos ────────────────────────────────────
|
||||
private static function genTablas(): string
|
||||
{
|
||||
$rows = self::pdo()->query(
|
||||
"SELECT table_name, table_rows, table_comment
|
||||
FROM information_schema.tables
|
||||
WHERE table_schema = DATABASE() AND table_type = 'BASE TABLE'
|
||||
ORDER BY table_name"
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Agrupar por prefijo para que se lea por dominio
|
||||
$grupos = [];
|
||||
foreach ($rows as $r) {
|
||||
$t = $r['table_name'];
|
||||
$pfx = str_contains($t, '_') ? explode('_', $t)[0] : 'otras';
|
||||
$grupos[$pfx][] = $r;
|
||||
}
|
||||
ksort($grupos);
|
||||
|
||||
$out = '';
|
||||
foreach ($grupos as $pfx => $tablas) {
|
||||
$out .= "\n### " . $pfx . "\n\n";
|
||||
$filas = [];
|
||||
foreach ($tablas as $t) {
|
||||
$cols = self::pdo()->prepare(
|
||||
"SELECT COUNT(*) FROM information_schema.columns
|
||||
WHERE table_schema = DATABASE() AND table_name = ?"
|
||||
);
|
||||
$cols->execute([$t['table_name']]);
|
||||
$filas[] = [
|
||||
'`' . $t['table_name'] . '`',
|
||||
(string)(int)$cols->fetchColumn(),
|
||||
number_format((int)$t['table_rows'], 0, ',', '.'),
|
||||
$t['table_comment'] ?: '',
|
||||
];
|
||||
}
|
||||
$out .= self::tabla(['Tabla', 'Columnas', 'Filas aprox.', 'Comentario'], $filas) . "\n";
|
||||
}
|
||||
return $out . "\n_" . count($rows) . " tablas. Generado de `information_schema`; el conteo de filas es una estimación de InnoDB._\n";
|
||||
}
|
||||
|
||||
// ── Roles y permisos efectivos ────────────────────────────────────
|
||||
private static function genRoles(): string
|
||||
{
|
||||
$roles = self::pdo()->query(
|
||||
"SELECT r.id, r.slug, r.name, r.description,
|
||||
(SELECT COUNT(*) FROM admin_users u WHERE u.role_id = r.id AND u.is_active = 1) AS usuarios
|
||||
FROM roles r ORDER BY r.slug"
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$mods = self::pdo()->query(
|
||||
"SELECT role_id, module_slug, permission FROM role_modules ORDER BY module_slug"
|
||||
)->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$porRol = [];
|
||||
foreach ($mods as $m) {
|
||||
$porRol[(int)$m['role_id']][] = $m['module_slug'] . ($m['permission'] === 'read' ? ' _(solo lectura)_' : '');
|
||||
}
|
||||
|
||||
$filas = [];
|
||||
foreach ($roles as $r) {
|
||||
$lista = $porRol[(int)$r['id']] ?? [];
|
||||
$filas[] = [
|
||||
'`' . $r['slug'] . '`',
|
||||
$r['name'] ?: '',
|
||||
(string)(int)$r['usuarios'],
|
||||
$lista ? implode(', ', $lista) : '—',
|
||||
];
|
||||
}
|
||||
return self::tabla(['Rol', 'Nombre', 'Usuarios activos', 'Módulos'], $filas)
|
||||
. "\n\n_Generado de `roles` y `role_modules`. El acceso efectivo se carga al iniciar sesión desde `role_id`._\n";
|
||||
}
|
||||
|
||||
// ── Servicios y clases del núcleo ─────────────────────────────────
|
||||
private static function genServicios(): string
|
||||
{
|
||||
$out = '';
|
||||
foreach ([['core', 'Núcleo'], ['services', 'Servicios'], ['classes', 'Clases de dominio']] as [$dir, $tit]) {
|
||||
$files = glob(self::raiz() . '/' . $dir . '/*.php') ?: [];
|
||||
if (!$files) continue;
|
||||
sort($files);
|
||||
$filas = [];
|
||||
foreach ($files as $f) {
|
||||
$filas[] = ['`' . basename($f) . '`', self::resumenPhpDoc($f)];
|
||||
}
|
||||
$out .= "\n### " . $tit . " (`" . $dir . "/`)\n\n" . self::tabla(['Archivo', 'Responsabilidad'], $filas) . "\n";
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
|
||||
/** Primera línea con contenido del bloque docblock inicial de un archivo. */
|
||||
private static function resumenPhpDoc(string $archivo): string
|
||||
{
|
||||
$fh = @fopen($archivo, 'r');
|
||||
if (!$fh) return '';
|
||||
$n = 0;
|
||||
$ruta = null;
|
||||
while (($l = fgets($fh)) !== false && $n++ < 25) {
|
||||
$l = trim($l);
|
||||
if (!str_starts_with($l, '*')) continue;
|
||||
$l = trim(ltrim($l, '*/ '));
|
||||
if ($l === '') continue;
|
||||
// Saltar la línea que solo repite la ruta del archivo
|
||||
if ($ruta === null && str_contains($l, '.php')) { $ruta = $l; continue; }
|
||||
if (str_starts_with($l, '@')) break;
|
||||
fclose($fh);
|
||||
return $l;
|
||||
}
|
||||
fclose($fh);
|
||||
return '';
|
||||
}
|
||||
|
||||
/** Arma una tabla Markdown escapando los separadores del contenido. */
|
||||
private static function tabla(array $encabezados, array $filas): string
|
||||
{
|
||||
$esc = fn($v) => str_replace('|', '\\|', (string)$v);
|
||||
$md = '| ' . implode(' | ', array_map($esc, $encabezados)) . " |\n";
|
||||
$md .= '|' . str_repeat('---|', count($encabezados)) . "\n";
|
||||
foreach ($filas as $f) {
|
||||
$md .= '| ' . implode(' | ', array_map($esc, $f)) . " |\n";
|
||||
}
|
||||
return $md;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
<?php
|
||||
/**
|
||||
* modules/soporte/Markdown.php
|
||||
* Renderizador Markdown → HTML para la documentación del proyecto.
|
||||
*
|
||||
* Cubre el subconjunto que usa la documentación: encabezados, listas
|
||||
* (anidadas y numeradas), tablas, bloques de código, citas, reglas, enlaces,
|
||||
* énfasis y código en línea. No pretende ser CommonMark completo — se prefirió
|
||||
* un archivo propio y auditable a incorporar una dependencia externa.
|
||||
*
|
||||
* Todo el texto se escapa antes de aplicar formato, así que el contenido de
|
||||
* los .md no puede inyectar HTML.
|
||||
*/
|
||||
|
||||
final class Markdown
|
||||
{
|
||||
/** Convierte un documento Markdown completo a HTML. */
|
||||
public static function render(string $texto): string
|
||||
{
|
||||
$lineas = preg_split('/\R/', $texto);
|
||||
$html = '';
|
||||
$n = count($lineas);
|
||||
$i = 0;
|
||||
|
||||
while ($i < $n) {
|
||||
$linea = $lineas[$i];
|
||||
|
||||
// ── Bloque de código cercado ──────────────────────────────
|
||||
if (preg_match('/^```\s*([\w-]*)\s*$/', $linea, $m)) {
|
||||
$lang = $m[1];
|
||||
$buffer = [];
|
||||
$i++;
|
||||
while ($i < $n && !preg_match('/^```\s*$/', $lineas[$i])) {
|
||||
$buffer[] = $lineas[$i];
|
||||
$i++;
|
||||
}
|
||||
$i++; // cerrar
|
||||
$clase = $lang ? ' class="lang-' . htmlspecialchars($lang, ENT_QUOTES) . '"' : '';
|
||||
$html .= '<pre><code' . $clase . '>'
|
||||
. htmlspecialchars(implode("\n", $buffer), ENT_QUOTES)
|
||||
. '</code></pre>';
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Línea en blanco ───────────────────────────────────────
|
||||
if (trim($linea) === '') { $i++; continue; }
|
||||
|
||||
// ── Regla horizontal ──────────────────────────────────────
|
||||
if (preg_match('/^(-{3,}|\*{3,}|_{3,})\s*$/', $linea)) {
|
||||
$html .= '<hr>';
|
||||
$i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Encabezado ────────────────────────────────────────────
|
||||
if (preg_match('/^(#{1,6})\s+(.*)$/', $linea, $m)) {
|
||||
$nivel = strlen($m[1]);
|
||||
$texto2 = trim($m[2]);
|
||||
$slug = self::slug($texto2);
|
||||
$html .= "<h{$nivel} id=\"{$slug}\">" . self::inline($texto2) . "</h{$nivel}>";
|
||||
$i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Tabla ─────────────────────────────────────────────────
|
||||
if (strpos($linea, '|') !== false
|
||||
&& isset($lineas[$i + 1])
|
||||
&& preg_match('/^\s*\|?[\s:|-]+\|[\s:|-]*$/', $lineas[$i + 1])) {
|
||||
[$tabla, $i] = self::tabla($lineas, $i);
|
||||
$html .= $tabla;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Cita ──────────────────────────────────────────────────
|
||||
if (preg_match('/^>\s?(.*)$/', $linea)) {
|
||||
$buffer = [];
|
||||
while ($i < $n && preg_match('/^>\s?(.*)$/', $lineas[$i], $m2)) {
|
||||
$buffer[] = $m2[1];
|
||||
$i++;
|
||||
}
|
||||
$html .= '<blockquote>' . self::render(implode("\n", $buffer)) . '</blockquote>';
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Lista (con o sin numerar, admite anidación) ───────────
|
||||
if (preg_match('/^(\s*)([-*+]|\d+\.)\s+/', $linea)) {
|
||||
[$lista, $i] = self::lista($lineas, $i, 0);
|
||||
$html .= $lista;
|
||||
continue;
|
||||
}
|
||||
|
||||
// ── Párrafo ───────────────────────────────────────────────
|
||||
$buffer = [];
|
||||
while ($i < $n
|
||||
&& trim($lineas[$i]) !== ''
|
||||
&& !preg_match('/^(#{1,6}\s|```|>|\s*([-*+]|\d+\.)\s|(-{3,}|\*{3,}|_{3,})\s*$)/', $lineas[$i])
|
||||
&& !(strpos($lineas[$i], '|') !== false
|
||||
&& isset($lineas[$i + 1])
|
||||
&& preg_match('/^\s*\|?[\s:|-]+\|[\s:|-]*$/', $lineas[$i + 1]))) {
|
||||
$buffer[] = $lineas[$i];
|
||||
$i++;
|
||||
}
|
||||
if ($buffer) $html .= '<p>' . self::inline(implode(' ', $buffer)) . '</p>';
|
||||
}
|
||||
|
||||
return $html;
|
||||
}
|
||||
|
||||
/** Construye una lista, recursivamente para los niveles anidados. */
|
||||
private static function lista(array $lineas, int $i, int $sangriaBase): array
|
||||
{
|
||||
$n = count($lineas);
|
||||
preg_match('/^(\s*)([-*+]|\d+\.)\s+/', $lineas[$i], $m0);
|
||||
$ordenada = !in_array($m0[2], ['-', '*', '+'], true);
|
||||
$tag = $ordenada ? 'ol' : 'ul';
|
||||
$html = "<{$tag}>";
|
||||
|
||||
while ($i < $n) {
|
||||
if (trim($lineas[$i]) === '') {
|
||||
// Una línea vacía corta la lista salvo que siga otro ítem
|
||||
if (isset($lineas[$i + 1]) && preg_match('/^(\s*)([-*+]|\d+\.)\s+/', $lineas[$i + 1])) {
|
||||
$i++;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
if (!preg_match('/^(\s*)([-*+]|\d+\.)\s+(.*)$/', $lineas[$i], $m)) break;
|
||||
|
||||
$sangria = strlen($m[1]);
|
||||
if ($sangria < $sangriaBase) break;
|
||||
|
||||
if ($sangria > $sangriaBase) {
|
||||
[$sub, $i] = self::lista($lineas, $i, $sangria);
|
||||
// Colgar la sublista del último ítem abierto
|
||||
$html = preg_replace('/<\/li>$/', '', $html) . $sub . '</li>';
|
||||
continue;
|
||||
}
|
||||
|
||||
$html .= '<li>' . self::inline($m[3]) . '</li>';
|
||||
$i++;
|
||||
}
|
||||
|
||||
return [$html . "</{$tag}>", $i];
|
||||
}
|
||||
|
||||
/** Construye una tabla a partir de la fila de encabezado. */
|
||||
private static function tabla(array $lineas, int $i): array
|
||||
{
|
||||
$n = count($lineas);
|
||||
$celdas = fn(string $l) => array_map('trim', explode('|', trim($l, " \t|")));
|
||||
|
||||
$encabezado = $celdas($lineas[$i]);
|
||||
$alineacion = array_map(function ($c) {
|
||||
$c = trim($c);
|
||||
if (str_starts_with($c, ':') && str_ends_with($c, ':')) return 'center';
|
||||
if (str_ends_with($c, ':')) return 'right';
|
||||
return 'left';
|
||||
}, $celdas($lineas[$i + 1]));
|
||||
$i += 2;
|
||||
|
||||
$html = '<div class="tabla-scroll"><table><thead><tr>';
|
||||
foreach ($encabezado as $k => $c) {
|
||||
$a = $alineacion[$k] ?? 'left';
|
||||
$html .= '<th style="text-align:' . $a . '">' . self::inline($c) . '</th>';
|
||||
}
|
||||
$html .= '</tr></thead><tbody>';
|
||||
|
||||
while ($i < $n && trim($lineas[$i]) !== '' && strpos($lineas[$i], '|') !== false) {
|
||||
$fila = $celdas($lineas[$i]);
|
||||
$html .= '<tr>';
|
||||
foreach ($encabezado as $k => $_) {
|
||||
$a = $alineacion[$k] ?? 'left';
|
||||
$html .= '<td style="text-align:' . $a . '">' . self::inline($fila[$k] ?? '') . '</td>';
|
||||
}
|
||||
$html .= '</tr>';
|
||||
$i++;
|
||||
}
|
||||
|
||||
return [$html . '</tbody></table></div>', $i];
|
||||
}
|
||||
|
||||
/**
|
||||
* Formato dentro de una línea. Se escapa primero y el código en línea se
|
||||
* aparta con marcadores para que su contenido no reciba más formato.
|
||||
*/
|
||||
private static function inline(string $texto): string
|
||||
{
|
||||
$codigos = [];
|
||||
$texto = preg_replace_callback('/`([^`]+)`/', function ($m) use (&$codigos) {
|
||||
$codigos[] = '<code>' . htmlspecialchars($m[1], ENT_QUOTES) . '</code>';
|
||||
return "\x00" . (count($codigos) - 1) . "\x00";
|
||||
}, $texto);
|
||||
|
||||
$texto = htmlspecialchars($texto, ENT_QUOTES);
|
||||
|
||||
// Enlaces [texto](destino) — solo http(s), rutas internas y anclas
|
||||
$texto = preg_replace_callback(
|
||||
'/\[([^\]]+)\]\(([^)\s]+)\)/',
|
||||
function ($m) {
|
||||
$url = html_entity_decode($m[2], ENT_QUOTES);
|
||||
if (!preg_match('#^(https?://|/|\?|\#)#', $url)) return $m[0];
|
||||
$ext = str_starts_with($url, 'http') ? ' target="_blank" rel="noopener"' : '';
|
||||
return '<a href="' . htmlspecialchars($url, ENT_QUOTES) . '"' . $ext . '>' . $m[1] . '</a>';
|
||||
},
|
||||
$texto
|
||||
);
|
||||
|
||||
$texto = preg_replace('/\*\*([^*]+)\*\*/', '<strong>$1</strong>', $texto);
|
||||
$texto = preg_replace('/(?<![\w*])\*([^*\n]+)\*(?![\w*])/', '<em>$1</em>', $texto);
|
||||
$texto = preg_replace('/(?<![\w_])_([^_\n]+)_(?![\w_])/', '<em>$1</em>', $texto);
|
||||
|
||||
// Restaurar código en línea
|
||||
return preg_replace_callback('/\x00(\d+)\x00/', fn($m) => $codigos[(int)$m[1]] ?? '', $texto);
|
||||
}
|
||||
|
||||
/** Ancla estable para un encabezado. */
|
||||
public static function slug(string $texto): string
|
||||
{
|
||||
$t = strtr(mb_strtolower(strip_tags($texto)), [
|
||||
'á'=>'a','é'=>'e','í'=>'i','ó'=>'o','ú'=>'u','ñ'=>'n','ü'=>'u',
|
||||
]);
|
||||
$t = preg_replace('/[^a-z0-9]+/', '-', $t);
|
||||
return trim($t, '-');
|
||||
}
|
||||
|
||||
/** Extrae los encabezados h2/h3 para la tabla de contenidos lateral. */
|
||||
public static function indice(string $texto): array
|
||||
{
|
||||
$out = [];
|
||||
foreach (preg_split('/\R/', $texto) as $l) {
|
||||
if (preg_match('/^(#{2,3})\s+(.*)$/', $l, $m)) {
|
||||
$t = trim($m[2]);
|
||||
$out[] = ['nivel' => strlen($m[1]), 'texto' => $t, 'slug' => self::slug($t)];
|
||||
}
|
||||
}
|
||||
return $out;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
/**
|
||||
* modules/soporte/api/ai_docs.php
|
||||
* POST { pregunta: string, historial?: [{rol,texto},...] }
|
||||
*
|
||||
* LIA respondiendo únicamente sobre la documentación del sistema. El contexto
|
||||
* lo arma DocIndex, que filtra por rol: solo entra lo que este usuario podría
|
||||
* leer por su cuenta en el módulo Soporte. No accede a datos de pacientes ni
|
||||
* de la operación del día.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
require_once __DIR__ . '/../../../services/GeminiService.php';
|
||||
require_once __DIR__ . '/../DocIndex.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
|
||||
function salir(array $datos, int $codigo = 200): void
|
||||
{
|
||||
http_response_code($codigo);
|
||||
echo json_encode($datos, JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
if (!isUserLoggedIn()) salir(['ok' => false, 'error' => 'No autenticado'], 401);
|
||||
if ($_SERVER['REQUEST_METHOD'] !== 'POST') salir(['ok' => false, 'error' => 'Método no permitido'], 405);
|
||||
|
||||
$body = json_decode(file_get_contents('php://input'), true) ?? [];
|
||||
$pregunta = trim($body['pregunta'] ?? '');
|
||||
|
||||
if ($pregunta === '') salir(['ok' => false, 'error' => 'Pregunta vacía'], 400);
|
||||
if (mb_strlen($pregunta) > 800) salir(['ok' => false, 'error' => 'Pregunta demasiado larga'], 400);
|
||||
|
||||
$gemini = new GeminiService(Database::getInstance()->getConnection());
|
||||
|
||||
if (!$gemini->hayClave()) {
|
||||
salir(['ok' => false, 'error' => 'El asistente no está configurado. Avisa a un administrador.'], 503);
|
||||
}
|
||||
if ($gemini->presupuestoAgotado()) {
|
||||
salir(['ok' => false, 'error' => '⚠️ Se agotaron los tokens de LIA. Solicita tokens a soporte para continuar.'], 402);
|
||||
}
|
||||
|
||||
// ── Documentación visible para este usuario, acotada a la pregunta ──
|
||||
[$contexto, $titulos] = DocIndex::contextoIA($pregunta);
|
||||
|
||||
if ($contexto === '') {
|
||||
salir([
|
||||
'ok' => true,
|
||||
'respuesta' => 'Eso no está en el manual de usuario. Pruebe con otras palabras, '
|
||||
. 'o revise el índice de la izquierda: puede estar en otra sección '
|
||||
. 'de la documentación.',
|
||||
'fuentes' => [],
|
||||
]);
|
||||
}
|
||||
|
||||
// ── Historial de la conversación ───────────────────────────────
|
||||
$historial = [];
|
||||
foreach (array_slice(is_array($body['historial'] ?? null) ? $body['historial'] : [], -6) as $h) {
|
||||
$texto = trim((string)($h['texto'] ?? ''));
|
||||
if ($texto === '') continue;
|
||||
$historial[] = [
|
||||
'role' => (($h['rol'] ?? '') === 'model') ? 'model' : 'user',
|
||||
'parts' => [['text' => mb_substr($texto, 0, 1500)]],
|
||||
];
|
||||
}
|
||||
|
||||
$prompt = <<<PROMPT
|
||||
Eres LIA, la asistente de documentación del ERP del Laboratorio Clínico Ximena Caicedo.
|
||||
|
||||
Responde preguntas sobre CÓMO USAR el sistema, apoyándose ÚNICAMENTE en el manual de usuario
|
||||
incluido abajo. Reglas:
|
||||
|
||||
- Si la respuesta no está en la documentación, dilo con claridad. No inventes ni supongas.
|
||||
- Solo conoce el manual de usuario, no la documentación técnica ni la de operación. Si le
|
||||
preguntan algo técnico (base de datos, código, configuración interna), diga que eso está en
|
||||
la documentación del módulo Soporte y que lo consulte allí.
|
||||
- El manual incluido es el que este usuario tiene permitido consultar. No mencione ni deduzca
|
||||
la existencia de contenido que no esté aquí.
|
||||
- Responde en español, tratando SIEMPRE de usted (nunca tú ni vos). Directo y práctico:
|
||||
si es un procedimiento, enumera los pasos.
|
||||
- Usa tablas o viñetas cuando ayuden a leer.
|
||||
- No inventes rutas, nombres de botones ni consultas SQL que no aparezcan en la documentación.
|
||||
|
||||
DOCUMENTACIÓN DISPONIBLE:
|
||||
{$contexto}
|
||||
PROMPT;
|
||||
|
||||
try {
|
||||
$r = $gemini->preguntar($prompt, $pregunta, $historial);
|
||||
salir([
|
||||
'ok' => true,
|
||||
'respuesta' => $r['respuesta'],
|
||||
'truncada' => $r['truncada'],
|
||||
'fuentes' => $titulos,
|
||||
'tokens_usados' => $r['tokens_usados'],
|
||||
'tokens_restantes' => $r['tokens_restantes'],
|
||||
'tokens_max' => $r['tokens_max'],
|
||||
]);
|
||||
} catch (\Throwable $e) {
|
||||
salir(['ok' => false, 'error' => $e->getMessage()], 502);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
# Visión general
|
||||
|
||||
Este sistema es el ERP del **Laboratorio Clínico Ximena Caicedo**. Nació como un bot de WhatsApp y creció hasta cubrir la operación diaria del laboratorio: turnos presenciales, toma de muestras, domicilios, órdenes médicas, formularios firmados digitalmente y facturación del día.
|
||||
|
||||
## Qué resuelve
|
||||
|
||||
| Área | Qué hace el sistema |
|
||||
|---|---|
|
||||
| Atención por WhatsApp | Bot que responde, agenda, envía consentimientos y encuestas |
|
||||
| Turnero presencial | Kiosko, recepción, estaciones de toma de muestras, pantallas de TV |
|
||||
| Domicilios | Agendamiento y asignación de enfermeros a visitas domiciliarias |
|
||||
| Formularios | Consentimientos y fichas clínicas firmadas digitalmente |
|
||||
| Laboratorio | Pacientes, órdenes médicas, exámenes, EPS, empresas, médicos |
|
||||
|
||||
## Las tres capas
|
||||
|
||||
El código está organizado en tres niveles, de lo más general a lo más específico:
|
||||
|
||||
```
|
||||
erp.php punto de entrada único del ERP
|
||||
└── core/App.php arranque, sesión, enrutamiento, control de acceso
|
||||
└── modules/<slug>/views/<vista>.php la pantalla concreta
|
||||
└── modules/<slug>/api/*.php endpoints que consume por fetch
|
||||
```
|
||||
|
||||
Debajo de todo eso están los **servicios** (`services/`), que encapsulan lo que habla con el mundo exterior — sobre todo la API de WhatsApp — y las **clases de dominio** (`classes/lab/`), que concentran las reglas de negocio de pacientes, domicilios, órdenes y formularios.
|
||||
|
||||
## Convivencia con el sistema anterior
|
||||
|
||||
Hay dos generaciones de código funcionando a la vez, y es intencional:
|
||||
|
||||
- **Archivos sueltos en la raíz** (`lab_domicilios.php`, `index.php`, `ver_formulario_enviado.php`, …). Es el sistema original. Siguen siendo el código real de muchas pantallas.
|
||||
- **Módulos en `modules/`**. Es la estructura nueva. Algunos módulos son pantallas completas (turnero, registro de exámenes); otros son apenas un puente que incluye el archivo viejo.
|
||||
|
||||
Un ejemplo de puente, `modules/lab_domicilios/views/index.php`:
|
||||
|
||||
```php
|
||||
require_once APP_ROOT . '/lab_domicilios.php';
|
||||
```
|
||||
|
||||
La migración es gradual y a propósito: mover una pantalla al nuevo esquema no obliga a mover las demás. Al leer el código, **el archivo de la raíz suele ser el que manda**; el módulo solo aporta el registro en el menú y el control de acceso.
|
||||
|
||||
## Stack
|
||||
|
||||
| Componente | Detalle |
|
||||
|---|---|
|
||||
| Lenguaje | PHP 7.4+ (en producción corre sobre versiones más recientes) |
|
||||
| Base de datos | MariaDB 11.8 |
|
||||
| Frontend | HTML server-side + JavaScript sin framework; Bootstrap 5 y Font Awesome |
|
||||
| Mensajería | WhatsApp Cloud API (Meta) |
|
||||
| IA | Google Gemini Flash — asistente LIA del dashboard del turnero |
|
||||
| Dependencias | Predis, Monolog, Guzzle, phpdotenv (vía Composer) |
|
||||
|
||||
No hay build step ni framework de frontend: las vistas son PHP que emite HTML y el JavaScript va embebido en la misma vista. Es deliberado — mantiene el despliegue en un simple `git pull`.
|
||||
|
||||
## Por dónde seguir
|
||||
|
||||
- [Enrutamiento y módulos](?m=soporte&v=documentacion&s=arquitectura&d=enrutamiento) — cómo una URL llega a una pantalla
|
||||
- [Roles y permisos](?m=soporte&v=documentacion&s=arquitectura&d=roles-y-permisos) — quién ve qué
|
||||
- [Modelo de datos](?m=soporte&v=documentacion&s=arquitectura&d=modelo-de-datos) — las 91 tablas, agrupadas
|
||||
- [Integración con WhatsApp](?m=soporte&v=documentacion&s=arquitectura&d=whatsapp) — el punto más delicado del sistema
|
||||
@@ -0,0 +1,123 @@
|
||||
# Enrutamiento y módulos
|
||||
|
||||
Cómo una URL termina ejecutando una pantalla concreta, y qué hace falta para agregar un módulo nuevo.
|
||||
|
||||
## El recorrido de una petición
|
||||
|
||||
```
|
||||
GET /erp.php?m=turnero&v=historial
|
||||
│
|
||||
├── erp.php define APP_ROOT y llama App::run()
|
||||
│
|
||||
├── App::boot() carga config, abre sesión, fija zona horaria
|
||||
│
|
||||
├── Router decide módulo y vista
|
||||
│ ├── 1º intenta la ruta limpia: /turnero/historial
|
||||
│ └── 2º cae a los parámetros: ?m=turnero&v=historial
|
||||
│
|
||||
├── Router::resolveFile()
|
||||
│ └── modules/turnero/views/historial.php ¿existe? si no → 404
|
||||
│
|
||||
├── Rbac::hasModule('turnero') ¿tiene acceso? si no → 403
|
||||
│
|
||||
└── include del archivo de la vista
|
||||
```
|
||||
|
||||
El punto clave: **la ruta es literalmente la ubicación del archivo**. `?m=turnero&v=historial` carga `modules/turnero/views/historial.php`. No hay tabla de rutas ni configuración intermedia.
|
||||
|
||||
## Validación de la URL
|
||||
|
||||
`Router` acepta como módulo y vista solo `[a-zA-Z0-9_]`, máximo 64 caracteres. Cualquier cosa fuera de ese patrón se descarta silenciosamente y se reemplaza por el valor por defecto (`dashboard` / `index`). Eso cierra la puerta a recorrer directorios con `../`.
|
||||
|
||||
## Rutas públicas
|
||||
|
||||
Casi todo exige sesión. Las excepciones están fijas en `core/Router.php`:
|
||||
|
||||
| Ruta | Por qué es pública |
|
||||
|---|---|
|
||||
| `turnero/display` | Pantalla de TV en sala de espera; no hay quién inicie sesión |
|
||||
| `turnero/kiosko` | El paciente saca su turno solo |
|
||||
|
||||
Cualquier otra combinación pasa por el control de acceso.
|
||||
|
||||
> Ojo: `isPublic()` solo omite la verificación **de módulo**. La sesión se maneja aparte, dentro de cada vista.
|
||||
|
||||
## Registrar un módulo nuevo
|
||||
|
||||
Hacen falta tres cosas. Si falta alguna, el módulo no aparece o da 403.
|
||||
|
||||
**1. La carpeta y al menos una vista**
|
||||
|
||||
```
|
||||
modules/mimodulo/
|
||||
module.php
|
||||
views/index.php
|
||||
api/ (opcional)
|
||||
```
|
||||
|
||||
**2. El descriptor `module.php`** — devuelve un arreglo:
|
||||
|
||||
```php
|
||||
<?php return [
|
||||
'slug' => 'mimodulo',
|
||||
'name' => 'Mi Módulo',
|
||||
'icon' => 'fas fa-cube',
|
||||
'category' => 'lab',
|
||||
'route' => '/erp.php?m=mimodulo&v=index',
|
||||
'is_active' => true,
|
||||
'sort_order' => 50,
|
||||
'description' => 'Para qué sirve',
|
||||
'links' => [
|
||||
['name' => 'Inicio', 'icon' => 'fas fa-home', 'route' => '/erp.php?m=mimodulo&v=index'],
|
||||
],
|
||||
];
|
||||
```
|
||||
|
||||
`links` son las entradas que salen en el menú lateral. El descriptor se ejecuta como PHP, así que puede armar los enlaces según el rol de quien mira — el turnero lo hace: muestra escritorios distintos a recepcionistas y bacteriólogos.
|
||||
|
||||
**3. El registro en `SYSTEM_MODULES`** (`config/config.php`)
|
||||
|
||||
```php
|
||||
define('SYSTEM_MODULES', [
|
||||
...
|
||||
'mimodulo' => 'Mi Módulo',
|
||||
]);
|
||||
```
|
||||
|
||||
Estar acá es lo que **activa la verificación de permisos**. Un módulo ausente de esta lista no se valida y queda accesible para cualquier sesión.
|
||||
|
||||
**4. Dar acceso a los roles** — sin esto, todos reciben 403:
|
||||
|
||||
```sql
|
||||
INSERT INTO role_modules (role_id, module_slug, permission, can_view)
|
||||
SELECT id, 'mimodulo', 'write', 1 FROM roles WHERE slug IN ('admin','superadmin');
|
||||
```
|
||||
|
||||
> Los módulos de la sesión se cargan **al iniciar sesión**, desde `role_id`. Después de tocar `role_modules`, el usuario afectado tiene que volver a entrar para que el cambio surta efecto.
|
||||
|
||||
## Módulos actuales
|
||||
|
||||
{{modulos}}
|
||||
|
||||
## Vistas y layout
|
||||
|
||||
Una vista se escribe así:
|
||||
|
||||
```php
|
||||
require_once APP_ROOT . '/config/config.php';
|
||||
if (!isUserLoggedIn()) { header('Location: ' . BASE_URL . 'login.php'); exit; }
|
||||
|
||||
Layout::open('Título de la pantalla', 'fas fa-icono');
|
||||
// HTML, CSS y JS de la pantalla
|
||||
Layout::close();
|
||||
```
|
||||
|
||||
`Layout::open()` emite el `<head>`, la barra superior y el menú lateral — que construye leyendo los `module.php` de los módulos a los que el usuario tiene acceso. `Layout::close()` cierra el documento.
|
||||
|
||||
## Endpoints
|
||||
|
||||
Cada módulo puede tener su carpeta `api/`. Son archivos PHP sueltos que devuelven JSON y se consumen por `fetch` desde las vistas. No pasan por `Router`: se invocan por su ruta real (`modules/turnero/api/get_historial.php`).
|
||||
|
||||
Por convención, `api/_helpers.php` de cada módulo concentra lo común — conexión, lectura del cuerpo JSON, respuestas `jsonOk()` / `jsonError()` y la verificación de acceso.
|
||||
|
||||
Los archivos que empiezan con guión bajo son de uso interno y no se llaman directamente desde el navegador.
|
||||
@@ -0,0 +1,113 @@
|
||||
# Roles y permisos
|
||||
|
||||
Quién puede ver y hacer qué. Es el punto donde más seguido se cometen errores, así que conviene entenderlo completo.
|
||||
|
||||
## Las dos columnas de un usuario
|
||||
|
||||
En `admin_users` conviven dos campos que parecen redundantes y **no lo son**:
|
||||
|
||||
| Columna | Para qué se usa |
|
||||
|---|---|
|
||||
| `role` | Texto del rol (`admin`, `bacteriologo`, …). Lo consultan las vistas para decidir qué mostrar |
|
||||
| `role_id` | Apunta a `roles.id`. Es de donde se **cargan los módulos** al iniciar sesión |
|
||||
|
||||
> **Hay que mantener las dos sincronizadas.** Cambiar solo `role` deja al usuario con los permisos viejos, porque el acceso real sale de `role_id`. Este error ya ocurrió: un usuario cambió de rol, la interfaz mostraba el rol nuevo y los módulos seguían siendo los anteriores.
|
||||
|
||||
Al cambiar el rol de alguien, actualice las dos a la vez:
|
||||
|
||||
```sql
|
||||
UPDATE admin_users
|
||||
SET role = 'lab_recepcion',
|
||||
role_id = (SELECT id FROM roles WHERE slug = 'lab_recepcion')
|
||||
WHERE id = 12;
|
||||
```
|
||||
|
||||
## Cómo se arma el acceso al iniciar sesión
|
||||
|
||||
`authenticateUser()` (`config/config.php`) valida la contraseña y arma la sesión:
|
||||
|
||||
```
|
||||
admin_users.role_id
|
||||
└── role_modules → lista de module_slug + permission
|
||||
└── $_SESSION['admin_user']['modules'] (qué módulos ve)
|
||||
$_SESSION['admin_user']['module_permissions'] (read o write en cada uno)
|
||||
```
|
||||
|
||||
**Esto ocurre una sola vez, al entrar.** Cualquier cambio en `role_modules` no afecta a las sesiones abiertas: el usuario tiene que cerrar sesión y volver a entrar.
|
||||
|
||||
## Las dos preguntas del control de acceso
|
||||
|
||||
```php
|
||||
hasModule('lab_domicilios') // ¿puede entrar al módulo?
|
||||
hasModuleWrite('lab_domicilios') // ¿puede modificar, o solo mirar?
|
||||
```
|
||||
|
||||
- `hasModule()` mira si el slug está en la lista de módulos de la sesión.
|
||||
- `hasModuleWrite()` mira `module_permissions[slug] === 'write'`. Los administradores siempre pueden escribir.
|
||||
|
||||
Una vista típica lo usa así:
|
||||
|
||||
```php
|
||||
$puedeEscribir = hasModuleWrite('lab_domicilios');
|
||||
...
|
||||
<?php if ($puedeEscribir): ?><button>Nuevo domicilio</button><?php endif; ?>
|
||||
```
|
||||
|
||||
## La columna que manda es `permission`
|
||||
|
||||
`role_modules` tiene dos formas de expresar lo mismo, y solo una se usa:
|
||||
|
||||
| Columnas | ¿Se usan? |
|
||||
|---|---|
|
||||
| `permission` (`read` / `write`) | **Sí.** Es lo que lee `hasModuleWrite()` |
|
||||
| `can_view`, `can_create`, `can_edit`, `can_delete`, `can_export` | No las lee el control de acceso |
|
||||
|
||||
> Poner `can_edit = 0` **no impide editar**. Para dejar un módulo en solo lectura hay que fijar `permission = 'read'`. Las columnas `can_*` quedaron de un diseño anterior; conviene mantenerlas coherentes por prolijidad, pero no protegen nada.
|
||||
|
||||
Solo lectura de verdad:
|
||||
|
||||
```sql
|
||||
UPDATE role_modules SET permission = 'read'
|
||||
WHERE role_id = 1030 AND module_slug IN ('lab_domicilios', 'lab_ordenes');
|
||||
```
|
||||
|
||||
## Roles actuales
|
||||
|
||||
{{roles}}
|
||||
|
||||
## Sesiones sin `role_id`
|
||||
|
||||
Hay dos casos heredados que siguen contemplados en el código:
|
||||
|
||||
- **`modules` nulo y rol `admin`** → acceso total. Cubre usuarios anteriores al sistema de roles.
|
||||
- **Rol `enfermero` sin `role_id`** → recibe `enfermero_portal` y `lab_formularios` de forma fija.
|
||||
|
||||
## Verificaciones adicionales
|
||||
|
||||
El control por módulo no siempre alcanza. Varias pantallas agregan sus propias reglas:
|
||||
|
||||
| Dónde | Regla |
|
||||
|---|---|
|
||||
| `enfermero_portal.php` | Solo `admin`, `superadmin` y `enfermero` |
|
||||
| `api/lab/save_domicilio.php` | Un enfermero solo edita domicilios que creó **o** que tiene asignados |
|
||||
| `api/lab/firmar_profesional.php` | Un enfermero solo firma envíos propios |
|
||||
| `modules/turnero/api/_helpers.php` | `requireTurnero()` en todos los endpoints del turnero |
|
||||
| `modules/turnero/module.php` | El menú cambia según rol y según la IP del equipo |
|
||||
|
||||
Al agregar un endpoint que modifica datos, **no alcanza con confiar en que la vista ocultó el botón**: el endpoint tiene que verificar por su cuenta.
|
||||
|
||||
## Diagnóstico rápido
|
||||
|
||||
Alguien reporta que no ve un módulo o que puede editar lo que no debería:
|
||||
|
||||
```sql
|
||||
-- Qué rol tiene realmente y si las dos columnas coinciden
|
||||
SELECT u.id, u.username, u.role, u.role_id, r.slug AS rol_real
|
||||
FROM admin_users u LEFT JOIN roles r ON r.id = u.role_id
|
||||
WHERE u.username = 'usuario';
|
||||
|
||||
-- Qué módulos le da ese rol
|
||||
SELECT module_slug, permission FROM role_modules WHERE role_id = <role_id>;
|
||||
```
|
||||
|
||||
Si los datos se ven bien y el usuario sigue sin acceso: **no ha vuelto a iniciar sesión**.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Modelo de datos
|
||||
|
||||
Las tablas están agrupadas por prefijo, y el prefijo dice a qué dominio pertenecen.
|
||||
|
||||
| Prefijo | Dominio |
|
||||
|---|---|
|
||||
| `lab_` | Laboratorio: pacientes, domicilios, órdenes, formularios, configuración |
|
||||
| `turnero_` | Turnos presenciales: sesiones, turnos, solicitudes, muestras, consentimientos |
|
||||
| `exam_` | Catálogo de exámenes y sus consentimientos asociados |
|
||||
| `terms_` | Términos y condiciones del bot y su historial de aceptaciones |
|
||||
| `admin_`, `roles`, `role_modules` | Usuarios y permisos |
|
||||
| resto | Conversaciones de WhatsApp, plantillas, logs, configuración del sistema |
|
||||
|
||||
## Los cuatro núcleos
|
||||
|
||||
### Turno presencial
|
||||
|
||||
Es la cadena más larga del sistema. Un paciente entra al laboratorio y genera esto:
|
||||
|
||||
```
|
||||
turnero_sesiones una fila por día de operación
|
||||
└── turnero_turnos el turno del paciente (código, estado, tiempos)
|
||||
├── turnero_solicitudes qué se le va a hacer y cuánto se cobró
|
||||
│ ├── turnero_examen_items exámenes pedidos
|
||||
│ └── turnero_muestras muestras a recibir
|
||||
├── turnero_consentimientos formularios a firmar
|
||||
└── turnero_comentarios notas del personal
|
||||
```
|
||||
|
||||
`turnero_turnos.estado` gobierna el flujo:
|
||||
|
||||
```
|
||||
espera → en_recepcion → en_espera_lugar → en_servicio → finalizado
|
||||
ausente / cancelado
|
||||
```
|
||||
|
||||
Solo los turnos **finalizados** cuentan como facturación real; los que están en estados intermedios se reportan aparte como "en proceso". Ausentes y cancelados no cuentan.
|
||||
|
||||
### Domicilio
|
||||
|
||||
```
|
||||
lab_domicilios
|
||||
├── lab_asignaciones qué enfermero lo atiende
|
||||
├── lab_domicilio_notas seguimiento
|
||||
└── lab_domicilio_pagos cobros
|
||||
```
|
||||
|
||||
### Formulario firmado
|
||||
|
||||
Un mismo formulario (`lab_formularios`) se firma por dos vías distintas, y cada una guarda en su propia tabla:
|
||||
|
||||
| Vía | Tabla | Token |
|
||||
|---|---|---|
|
||||
| Turnero | `turnero_consentimientos` | UUID (`?token=`) |
|
||||
| Domicilios y envíos sueltos | `lab_form_envios` | 64 caracteres hex (`?t=`) |
|
||||
|
||||
Las dos las muestra `ver_formulario_enviado.php`, que distingue por el **formato del token**. Es la razón de que existan dos parámetros distintos para lo que parece lo mismo.
|
||||
|
||||
La definición del formulario vive en `lab_formularios.esquema`, un JSON con la lista de campos. Las respuestas quedan en `datos_respuestas` (turnero) o `datos_cliente` (envíos), también JSON.
|
||||
|
||||
### Conversación de WhatsApp
|
||||
|
||||
```
|
||||
users / conversations el contacto y su hilo
|
||||
├── messages cada mensaje
|
||||
├── terms_acceptance aceptación de términos
|
||||
└── message_templates plantillas aprobadas por Meta (caché local)
|
||||
```
|
||||
|
||||
## Convenciones
|
||||
|
||||
- **Timestamps**: `creado_at` / `created_at` según la época en que se creó la tabla. No hay una sola convención.
|
||||
- **Autor**: `creado_por` guarda `admin_users.id`. Varias tablas lo agregaron después, así que las filas viejas lo tienen en `NULL`.
|
||||
- **Borrado**: casi todo es borrado físico. No hay *soft delete* generalizado.
|
||||
- **JSON**: se usa bastante (`esquema`, `datos_respuestas`, `pagos_detalle`, `items_precio`). Guardado como `longtext`, no como tipo `JSON` nativo.
|
||||
|
||||
## Cambios de esquema
|
||||
|
||||
Van en `migrations/`, con nombre `AAAAMMDD_descripcion.sql`. La convención del repositorio es que sean **idempotentes** — `IF NOT EXISTS` y guardas en los `UPDATE`/`INSERT` — para poder correrlas más de una vez sin daño.
|
||||
|
||||
> Un cambio aplicado directo en producción sin dejar la migración correspondiente hace que un entorno nuevo no lo tenga. Si toca el esquema, deje el archivo.
|
||||
|
||||
## Inventario completo
|
||||
|
||||
{{tablas}}
|
||||
@@ -0,0 +1,85 @@
|
||||
# Integración con WhatsApp
|
||||
|
||||
Es la parte del sistema con más piezas fuera de nuestro control. Buena parte de la configuración vive **en Meta**, no en la base de datos, y eso explica varios comportamientos que de otro modo parecen inexplicables.
|
||||
|
||||
## Dos números, una misma cuenta
|
||||
|
||||
El laboratorio opera con dos líneas sobre la misma cuenta de WhatsApp Business (WABA):
|
||||
|
||||
| Canal | Configuración | Para qué |
|
||||
|---|---|---|
|
||||
| Principal | `whatsapp_phone_number_id` | Bot de atención general |
|
||||
| Turnero | `whatsapp_phone_number_id_turnero` | Consentimientos, encuestas y avisos de turno |
|
||||
|
||||
Se elige al construir el servicio:
|
||||
|
||||
```php
|
||||
$wa = new WhatsAppService(); // línea principal
|
||||
$wa = new WhatsAppService('turnero'); // línea del turnero
|
||||
```
|
||||
|
||||
> Si un mensaje sale por el número equivocado, casi siempre es porque se instanció sin el canal. Es el mismo WABA y el mismo token: **lo único que cambia es el `phone_number_id`**.
|
||||
|
||||
## Configuración
|
||||
|
||||
Todo en `system_config`:
|
||||
|
||||
| Clave | Qué es |
|
||||
|---|---|
|
||||
| `whatsapp_token` | Token de acceso a la API |
|
||||
| `whatsapp_api_url` | URL base de la Cloud API |
|
||||
| `whatsapp_business_account_id` | Identificador del WABA |
|
||||
| `whatsapp_phone_number_id` | Número principal |
|
||||
| `whatsapp_phone_number_id_turnero` | Número del turnero |
|
||||
| `webhook_verify_token` | Verificación del webhook |
|
||||
|
||||
## Plantillas: la parte que no controlamos
|
||||
|
||||
Para escribir primero a alguien (fuera de la ventana de 24 horas) hay que usar una **plantilla aprobada por Meta**. `message_templates` guarda una copia local, pero **la copia no manda**: la versión real está en Meta.
|
||||
|
||||
Esto tiene una consecuencia importante y poco intuitiva:
|
||||
|
||||
> **Las URL de los botones viven en la plantilla, no en nuestro código.**
|
||||
|
||||
La plantilla `consentimiento_turno_v2` tiene un botón así:
|
||||
|
||||
```
|
||||
https://erp.laboratorioximenacaicedo.com/form_cliente.php?t={{1}}
|
||||
```
|
||||
|
||||
Nuestro código solo envía el token como `{{1}}`. Cambiar el código **no cambia** el enlace que recibe el paciente: hay que editar la plantilla en el WhatsApp Manager de Meta y esperar la reaprobación.
|
||||
|
||||
El único lugar donde sí armamos la URL completa es el **respaldo en texto plano**, que se usa cuando falla el envío por plantilla (`modules/turnero/api/send_consentimiento.php`).
|
||||
|
||||
## Por qué el enlace pasa por dos páginas
|
||||
|
||||
El botón apunta a `form_cliente.php?t=<UUID>`, pero el consentimiento del turnero lo muestra `ver_formulario_enviado.php?token=<UUID>`.
|
||||
|
||||
`form_cliente.php` detecta que el token tiene formato UUID —o sea, que viene del turnero— y redirige. Convive así porque la plantilla ya estaba aprobada apuntando a la página de envíos, y cambiarla obliga a otra ronda de aprobación en Meta.
|
||||
|
||||
## Términos y condiciones
|
||||
|
||||
Antes de conversar, el bot exige aceptar los términos. El usuario responde **ACEPTO** o **NO ACEPTO**.
|
||||
|
||||
La URL del documento está escrita en **dos lugares** y hay que cambiarlos juntos:
|
||||
|
||||
| Dónde | Rol |
|
||||
|---|---|
|
||||
| `terms_versions.documento_url` | El bot la adjunta al final del mensaje |
|
||||
| `system_config.terms_message` | Va escrita dentro del texto de bienvenida |
|
||||
|
||||
Se vuelve a pedir la aceptación si: nunca aceptó, pasaron más de 6 meses, o hay una versión nueva con `forzar_reenvio`.
|
||||
|
||||
## El webhook
|
||||
|
||||
Meta envía los mensajes entrantes al webhook, que los registra y se los pasa a `BotService`. Ahí se decide si responde el bot automático o queda para un operador humano, según el estado de la conversación y el horario de atención (`BusinessHoursService`).
|
||||
|
||||
## Qué revisar cuando algo falla
|
||||
|
||||
| Síntoma | Dónde mirar primero |
|
||||
|---|---|
|
||||
| El mensaje sale por el número equivocado | Que se haya pasado `'turnero'` al constructor |
|
||||
| Un enlace llega roto o apunta mal | La plantilla en Meta, no el código |
|
||||
| No llega ninguna plantilla | Estado de aprobación en el WhatsApp Manager |
|
||||
| Falla el envío pero llega un texto plano | Es el respaldo actuando: la plantilla falló |
|
||||
| El bot no responde | `webhook_logs`, y el horario de atención |
|
||||
@@ -0,0 +1,104 @@
|
||||
# Decisiones y deuda técnica
|
||||
|
||||
Por qué el sistema es como es, y qué cosas conviene saber antes de tocarlo.
|
||||
|
||||
## Decisiones tomadas a propósito
|
||||
|
||||
### Migración gradual, sin corte
|
||||
|
||||
Conviven el sistema original (archivos en la raíz) y el nuevo (módulos). No hubo una reescritura de golpe.
|
||||
|
||||
**Por qué:** el laboratorio opera todos los días. Una reescritura completa implicaba congelar el desarrollo o mantener dos sistemas en paralelo.
|
||||
|
||||
**Costo:** hay que saber en cuál de los dos está el código de cada pantalla. Los `lab_*` suelen estar en la raíz; el turnero está en el módulo.
|
||||
|
||||
### Sin framework de frontend
|
||||
|
||||
Las vistas son PHP que emiten HTML, con JavaScript embebido en la misma vista.
|
||||
|
||||
**Por qué:** despliegue por `git pull`, sin build ni compilación. Un archivo se edita y ya está en producción.
|
||||
|
||||
**Costo:** hay código repetido entre vistas, y las vistas grandes (el turnero) pasan de las 2.000 líneas.
|
||||
|
||||
### Esquemas de formulario en JSON
|
||||
|
||||
Los formularios se definen en JSON dentro de `lab_formularios.esquema`, no en tablas normalizadas.
|
||||
|
||||
**Por qué:** las fichas clínicas cambian seguido y cada una tiene campos distintos. Normalizarlas obligaba a migrar el esquema con cada formulario nuevo.
|
||||
|
||||
**Costo:** no se puede consultar por SQL «todos los pacientes con fiebre». Las respuestas viven dentro de un JSON.
|
||||
|
||||
### La identidad de quien firma se resuelve en el servidor
|
||||
|
||||
Nunca se acepta del navegador quién firmó algo.
|
||||
|
||||
**Por qué:** es un dato con valor legal. Un cliente puede mentir; la sesión no.
|
||||
|
||||
### El turno original nunca se modifica
|
||||
|
||||
Cuando una muestra pendiente se completa en una visita posterior, el turno original **queda como estaba**. Solo se registra el vínculo.
|
||||
|
||||
**Por qué:** el turno cerrado es un registro histórico. Alterarlo retroactivamente falsea los tiempos de atención y la facturación de aquel día.
|
||||
|
||||
### Inventarios generados, no escritos
|
||||
|
||||
Las tablas de módulos, endpoints, tablas y roles de esta documentación se leen del código y la base en cada carga.
|
||||
|
||||
**Por qué:** una lista escrita a mano envejece sin que nadie se entere. Una generada no puede mentir.
|
||||
|
||||
## Deuda técnica conocida
|
||||
|
||||
### Columnas `can_*` que no hacen nada
|
||||
|
||||
`role_modules` tiene `can_view`, `can_create`, `can_edit`, `can_delete`, `can_export` — y **el control de acceso no las lee**. Solo usa `permission` (`read`/`write`).
|
||||
|
||||
**Riesgo:** poner `can_edit = 0` da falsa sensación de haber restringido algo. Ya causó confusión.
|
||||
|
||||
**Arreglo:** o se usan de verdad, o se eliminan. Mientras tanto, conviene mantenerlas coherentes con `permission`.
|
||||
|
||||
### `role` y `role_id` duplicados
|
||||
|
||||
Un usuario tiene el rol en dos columnas. La interfaz lee una, los permisos salen de la otra.
|
||||
|
||||
**Riesgo:** cambiar solo `role` deja al usuario con permisos que no corresponden.
|
||||
|
||||
**Arreglo:** derivar `role` de `role_id` en lugar de almacenarlo.
|
||||
|
||||
### Dos vías para el mismo formulario
|
||||
|
||||
`turnero_consentimientos` y `lab_form_envios` guardan lo mismo con estructuras distintas y tokens de formato distinto. `ver_formulario_enviado.php` tiene que manejar ambos, y `form_cliente.php` existe solo para redirigir entre ellos.
|
||||
|
||||
**Por qué sigue así:** unificarlas obliga a cambiar la plantilla aprobada en Meta y migrar los registros históricos.
|
||||
|
||||
### Convenciones de nombre mezcladas
|
||||
|
||||
Conviven `creado_at` y `created_at`, `creado_por` y `enviado_por`, español e inglés. Depende de la época de cada tabla.
|
||||
|
||||
### Vistas muy grandes
|
||||
|
||||
`ver_formulario_enviado.php` supera las 3.000 líneas y mezcla render, lógica de tomas prolongadas y JavaScript. Es el archivo más delicado de tocar del sistema.
|
||||
|
||||
### Datos históricos incompletos
|
||||
|
||||
Algunas columnas se agregaron después y las filas viejas quedaron en `NULL`, sin forma de recuperarlas:
|
||||
|
||||
| Columna | Desde | Antes |
|
||||
|---|---|---|
|
||||
| `turnero_consentimientos.creado_por` | 3 ago 2026 | `NULL` |
|
||||
| Identidad por toma en F-LAB-28 | 3 ago 2026 | No se guardaba |
|
||||
| `admin_users.cedula` | 3 ago 2026 | Solo enfermeros la tenían |
|
||||
|
||||
No hay traza de auditoría que permita reconstruirlos.
|
||||
|
||||
### El dominio se deduce de cada petición
|
||||
|
||||
`APP_URL` sale del `HTTP_HOST`. Si alguien entra por una IP o un dominio alterno, los enlaces que se generen llevarán esa dirección — y quedan guardados así en el WhatsApp del paciente.
|
||||
|
||||
**Arreglo:** fijar `APP_URL` explícitamente.
|
||||
|
||||
## Al hacer cambios
|
||||
|
||||
- **Cambio de esquema** → deje la migración en `migrations/`, idempotente.
|
||||
- **Endpoint nuevo** → verifique permisos ahí adentro, no confíes en la vista.
|
||||
- **Tocar el turnero** → es lo que más gente usa a diario; pruebe con un turno real.
|
||||
- **Tocar formularios firmados** → tienen valor legal. Un render roto es un documento inválido.
|
||||
@@ -0,0 +1,79 @@
|
||||
# Primeros pasos
|
||||
|
||||
Lo mínimo para moverse por el sistema, sin importar el rol.
|
||||
|
||||
## Entrar
|
||||
|
||||
Se ingresa con usuario y contraseña. Si no reconoce su usuario, busque su **número de cédula**: la mayoría de las cuentas del personal se crearon así.
|
||||
|
||||
Al entrar va directo a la pantalla principal de su rol. No todos ven lo mismo: el menú de la izquierda muestra únicamente los módulos habilitados para usted.
|
||||
|
||||
## Si no ve algo que deberías ver
|
||||
|
||||
Casi siempre es una de estas dos:
|
||||
|
||||
1. **Le cambiaron los permisos hace poco.** Los permisos se cargan **al iniciar sesión**. Cierre sesión y vuelva a entrar.
|
||||
2. **Su rol no lo incluye.** Pídale a un administrador que lo revise.
|
||||
|
||||
## Cómo está organizado
|
||||
|
||||
| Zona | Qué contiene |
|
||||
|---|---|
|
||||
| Menú izquierdo | Los módulos a los que tiene acceso |
|
||||
| Barra superior | Su usuario y el cierre de sesión |
|
||||
| Centro | La pantalla activa |
|
||||
|
||||
## Los módulos principales
|
||||
|
||||
| Módulo | Para qué |
|
||||
|---|---|
|
||||
| **Turnero** | Turnos presenciales: recepción, toma de muestras, pantallas |
|
||||
| **Domicilios** | Visitas domiciliarias y su asignación a enfermeros |
|
||||
| **Pacientes** | Fichas clínicas e historial |
|
||||
| **Órdenes médicas** | Órdenes recibidas |
|
||||
| **Formularios** | Consentimientos y fichas; diseño y envíos |
|
||||
| **Soporte** | Esta documentación |
|
||||
|
||||
## El recorrido de un paciente
|
||||
|
||||
Desde que saca su turno hasta que se va:
|
||||
|
||||
```
|
||||
KIOSKO RECEPCIÓN TOMA DE MUESTRAS
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌──────┐ llama ┌──────┐ deriva ┌──────────────┐ ┌────────────┐
|
||||
│espera├──────────►│recep.├───────────►│ espera lugar ├──►│ en servicio│
|
||||
└──┬───┘ └──┬───┘ └──────────────┘ └──────┬─────┘
|
||||
│ │ │
|
||||
│ no responde │ ▼
|
||||
└──────────────────┴──────────► ausente ┌────────────────┐
|
||||
│ finalizado │
|
||||
cancelado └────────────────┘
|
||||
cuenta para facturar
|
||||
```
|
||||
|
||||
Un turno **nunca se borra**. Si el paciente no aparece se marca *ausente*; si no se hace, *cancelado*. Los dos quedan registrados, y ninguno cuenta como facturación.
|
||||
|
||||
## Cosas que conviene saber desde el principio
|
||||
|
||||
**Los turnos no se borran.** Se cancelan o se marcan como ausente, pero quedan registrados. Es a propósito: el historial tiene valor clínico y administrativo.
|
||||
|
||||
**Las firmas quedan con nombre y cédula.** Cuando firma un formulario, el sistema registra quién es. No es opcional ni configurable.
|
||||
|
||||
**Una muestra pendiente no se pierde.** Si un paciente queda debiendo una muestra y vuelve otro día, aparece sola en la estación, marcada como *visita anterior*, con los exámenes de aquella orden.
|
||||
|
||||
**Nadie factura lo que no terminó.** En los reportes del día, lo cobrado en turnos finalizados y lo que sigue en curso se muestran por separado. Los ausentes y cancelados no se cuentan.
|
||||
|
||||
## Manual según su rol
|
||||
|
||||
- [Kiosko y pantallas](?m=soporte&v=documentacion&s=manual&d=kiosko-y-pantallas)
|
||||
- [Recepción](?m=soporte&v=documentacion&s=manual&d=recepcion)
|
||||
- [Chat de WhatsApp](?m=soporte&v=documentacion&s=manual&d=chat-whatsapp)
|
||||
- [Órdenes médicas](?m=soporte&v=documentacion&s=manual&d=ordenes-medicas)
|
||||
- [Domicilios](?m=soporte&v=documentacion&s=manual&d=domicilios)
|
||||
- [Formularios](?m=soporte&v=documentacion&s=manual&d=formularios)
|
||||
- [Reportes y métricas](?m=soporte&v=documentacion&s=manual&d=reportes)
|
||||
- [Toma de muestras](?m=soporte&v=documentacion&s=manual&d=toma-de-muestras)
|
||||
- [Enfermeros — domicilios](?m=soporte&v=documentacion&s=manual&d=enfermeros)
|
||||
- [Administración](?m=soporte&v=documentacion&s=manual&d=administracion)
|
||||
@@ -0,0 +1,70 @@
|
||||
# Kiosko y pantallas de TV
|
||||
|
||||
Las dos pantallas que funcionan solas, sin nadie operándolas. Conviene entenderlas porque cuando fallan, el que se entera primero es quien está en recepción.
|
||||
|
||||
## El kiosko
|
||||
|
||||
El tótem de la entrada, donde el paciente saca su turno sin ayuda.
|
||||
|
||||
```
|
||||
PACIENTE LLEGA
|
||||
│
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ Elige tipo │ general, preferencial, embarazada,
|
||||
│ de turno │ solo entrega de muestras…
|
||||
└──────┬───────┘
|
||||
▼
|
||||
┌──────────────┐
|
||||
│Datos básicos │ nombre y celular (opcional)
|
||||
└──────┬───────┘
|
||||
▼
|
||||
┌──────────────┐
|
||||
│ Imprime │──► el turno aparece en la pantalla de TV
|
||||
│ su turno │ y en la cola de recepción
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
Es una de las **dos únicas pantallas públicas** del sistema: no pide usuario ni contraseña, porque nadie va a iniciar sesión en el tótem de la entrada.
|
||||
|
||||
### Prioridades
|
||||
|
||||
El tipo de turno que elige el paciente define su lugar en la cola. Las prioridades las configura un administrador; quien atiende no necesita hacer nada: **Llamar siguiente** ya respeta el orden.
|
||||
|
||||
### Si el paciente deja su celular
|
||||
|
||||
Puede recibir por WhatsApp el aviso de su turno y los consentimientos para firmar desde el teléfono mientras espera. Vale la pena insistirle en que lo deje.
|
||||
|
||||
### Cuando el kiosko falla
|
||||
|
||||
Un turno siempre se puede crear a mano desde recepción. El kiosko es una comodidad, no un requisito — si está caído, la atención sigue.
|
||||
|
||||
## Pantalla de TV
|
||||
|
||||
La de la sala de espera. Muestra el turno que se está llamando y la cola, y reproduce contenido del laboratorio de fondo.
|
||||
|
||||
También es pública: se abre en el navegador del televisor y se deja andando.
|
||||
|
||||
### El contenido de fondo
|
||||
|
||||
Es una lista de videos e imágenes que se reproducen en bucle, uno detrás de otro:
|
||||
|
||||
```
|
||||
┌─────────┐ ┌─────────┐ ┌─────────┐ ┌─────────┐
|
||||
│ video 1 │──►│ imagen │──►│ video 2 │──►│ imagen │──┐
|
||||
│ hasta │ │ 8 seg │ │ hasta │ │ 10 seg │ │
|
||||
│ el final│ │ │ │ el final│ │ │ │
|
||||
└─────────┘ └─────────┘ └─────────┘ └─────────┘ │
|
||||
▲ │
|
||||
└──────────────────── vuelve a empezar ────────────┘
|
||||
```
|
||||
|
||||
Los videos van completos; a las imágenes se les fija cuántos segundos duran. Se administra desde **Configuración del turnero → Pantalla TV**: se suben, se reordenan arrastrando y se eliminan de a uno.
|
||||
|
||||
### Si la pantalla se queda pegada
|
||||
|
||||
Recargue la página en el televisor. Si el contenido no cambió, verifique que la lista tenga elementos activos en la configuración.
|
||||
|
||||
## Verificar paciente
|
||||
|
||||
Una consulta rápida por cédula para ver la ficha y el historial de alguien, sin abrir su turno. Útil cuando el paciente pregunta algo en el mostrador y no quiere perder lo que está haciendo.
|
||||
@@ -0,0 +1,98 @@
|
||||
---
|
||||
roles: recepcionista, lab_recepcion, supervisor
|
||||
---
|
||||
|
||||
# Recepción
|
||||
|
||||
Guía de la pantalla de recepción del turnero: desde que llama al paciente hasta que pasa a toma de muestras.
|
||||
|
||||
## Su escritorio
|
||||
|
||||
Si el equipo está registrado por IP o token, el menú le muestra **solo su escritorio**. Si no lo está, ve todos y elige.
|
||||
|
||||
Esto lo configura un administrador en `Configuración del turnero`. Si está viendo escritorios que no son el tuyo, avise: significa que ese equipo no quedó registrado.
|
||||
|
||||
## El flujo completo
|
||||
|
||||
### 1. Llamar al paciente
|
||||
|
||||
**Llamar siguiente** toma el turno con mayor prioridad de la cola. También puede llamar a uno específico si hace falta saltarse el orden.
|
||||
|
||||
El turno aparece en la pantalla de TV de la sala de espera.
|
||||
|
||||
Si el paciente no responde, **Marcar ausente**. Queda registrado como ausente y no cuenta en la facturación.
|
||||
|
||||
### 2. Verificar o crear el paciente
|
||||
|
||||
Busque por cédula o nombre.
|
||||
|
||||
- **Existe** → se cargan sus datos y su historial.
|
||||
- **No existe** → cree la ficha. Cédula, nombre completo, fecha de nacimiento, teléfono y EPS.
|
||||
|
||||
> El teléfono importa más de lo que parece: es a donde se envían los consentimientos y las encuestas. Un número mal escrito significa un consentimiento que nunca llega.
|
||||
|
||||
### 3. Seleccionar exámenes
|
||||
|
||||
Cargue los exámenes solicitados. Si viene con una orden en RIPS, se pueden importar directamente en vez de cargarlos a mano.
|
||||
|
||||
Al elegir los exámenes, el sistema decide solo qué consentimientos hacen falta: algunos van atados a un examen concreto (VIH, por ejemplo) y otros a la estación de destino.
|
||||
|
||||
### 4. Datos de facturación
|
||||
|
||||
Valor cobrado, método de pago, número de recibo. Si es por empresa o EPS, cargue el NIT y la autorización.
|
||||
|
||||
Se admite pago combinado — efectivo más tarjeta, por ejemplo.
|
||||
|
||||
### 5. Consentimientos
|
||||
|
||||
Quién firma qué, y dónde:
|
||||
|
||||
```
|
||||
RECEPCIÓN TOMA DE MUESTRAS
|
||||
┌─────────────────────┐ ┌──────────────────────┐
|
||||
│ Consentimiento │ │ Datos Toma de │
|
||||
│ pruebas de lab │ │ Muestras (F-LAB-08) │
|
||||
│ │ │ │
|
||||
│ firma: EL PACIENTE │ │ firma: EL PERSONAL │
|
||||
│ vía WhatsApp │ │ en la estación │
|
||||
└─────────────────────┘ └──────────────────────┘
|
||||
obligatorio obligatorio SIEMPRE
|
||||
salvo "solo entrega" (incluso solo entrega)
|
||||
```
|
||||
|
||||
|
||||
Los que hagan falta aparecen listados con su estado. Se envían al WhatsApp del paciente, que los firma desde el celular.
|
||||
|
||||
**No puede guardar la solicitud si quedan consentimientos sin firmar**, salvo que sea una visita de *solo entrega de muestras*.
|
||||
|
||||
Si el envío por WhatsApp falla, el sistema manda un enlace en texto plano como respaldo.
|
||||
|
||||
### 6. Elegir destino y guardar
|
||||
|
||||
Seleccione la estación de toma de muestras y guarde. El turno pasa a esa estación y el paciente sale de su escritorio.
|
||||
|
||||
## Situaciones frecuentes
|
||||
|
||||
### El paciente solo viene a entregar una muestra
|
||||
|
||||
Marque **Solo entrega de muestras**. Se saltan los consentimientos por examen y no hace falta seleccionar exámenes.
|
||||
|
||||
> Ojo: el formulario **Datos Toma de Muestras (F-LAB-08)** se sigue exigiendo en la estación. Ese no se omite nunca, porque recoge la historia clínica del momento de la toma.
|
||||
|
||||
### El paciente ya vino antes y quedó debiendo una muestra
|
||||
|
||||
No tiene que hacer nada especial. En la estación de toma de muestras le va a aparecer sola, marcada como *visita anterior*, junto con los exámenes de aquella orden.
|
||||
|
||||
### El paciente no recibió el consentimiento
|
||||
|
||||
1. Verifique el número de teléfono en su ficha.
|
||||
2. Reenvialo desde la lista de consentimientos.
|
||||
3. Si sigue sin llegar, avise a un administrador: puede ser un problema de la plantilla en Meta, que no se arregla desde acá.
|
||||
|
||||
### Hay que corregir algo después de guardar
|
||||
|
||||
Mientras el turno no esté finalizado, un administrador puede reabrirlo desde el historial y cambiar su estado.
|
||||
|
||||
## Encuestas
|
||||
|
||||
Desde el historial se le puede enviar una encuesta de satisfacción al paciente por WhatsApp.
|
||||
@@ -0,0 +1,78 @@
|
||||
---
|
||||
roles: recepcionista, lab_recepcion, operador_bot, supervisor
|
||||
---
|
||||
|
||||
# Chat de WhatsApp
|
||||
|
||||
La línea de WhatsApp del laboratorio la atiende un bot, pero cuando hace falta una persona, la conversación pasa a un operador. Esta es esa pantalla.
|
||||
|
||||
## Cómo se reparte el trabajo
|
||||
|
||||
```
|
||||
MENSAJE DEL PACIENTE
|
||||
│
|
||||
▼
|
||||
┌─────────────┐ no ┌──────────────────────┐
|
||||
│ ¿Aceptó los ├────────►│ Le pide aceptar y │
|
||||
│ términos? │ │ no avanza hasta que │
|
||||
└──────┬──────┘ │ responda ACEPTO │
|
||||
│ sí └──────────────────────┘
|
||||
▼
|
||||
┌─────────────┐ sí ┌──────────────────────┐
|
||||
│ ¿La tomó un ├────────►│ El bot NO interviene │
|
||||
│ operador? │ │ Responde usted │
|
||||
└──────┬──────┘ └──────────────────────┘
|
||||
│ no
|
||||
▼
|
||||
┌─────────────┐ fuera ┌──────────────────────┐
|
||||
│ ¿Está en ├────────►│ Responde con el │
|
||||
│ horario? │ │ mensaje de fuera de │
|
||||
└──────┬──────┘ │ horario │
|
||||
│ dentro └──────────────────────┘
|
||||
▼
|
||||
El bot responde
|
||||
```
|
||||
|
||||
Lo importante: **cuando toma una conversación, el bot deja de responder ahí**. No hay riesgo de que le conteste encima al paciente mientras usted está escribiendo.
|
||||
|
||||
## Lo que el bot resuelve solo
|
||||
|
||||
Antes de llegar a una persona, el bot responde por su cuenta las preguntas más
|
||||
frecuentes —precios, horarios, ubicación, indicaciones previas— y ofrece un menú
|
||||
numerado para agendar, consultar resultados o ver el portafolio.
|
||||
|
||||
Ese contenido es configurable: si una respuesta quedó desactualizada o hace falta
|
||||
una nueva, un administrador la cambia sin necesidad de programar nada.
|
||||
|
||||
## Atender una conversación
|
||||
|
||||
La lista muestra los hilos con mensajes recientes. Al abrir uno ve el historial completo y puede responder.
|
||||
|
||||
Si el paciente ya está registrado, se ve su ficha; si no, se puede crear desde ahí mismo.
|
||||
|
||||
## La ventana de 24 horas
|
||||
|
||||
Es una regla de WhatsApp, no del sistema:
|
||||
|
||||
> Puede escribir libremente durante **24 horas** desde el último mensaje del paciente. Pasado ese plazo, solo se le puede escribir con una **plantilla aprobada**.
|
||||
|
||||
Por eso a veces el sistema no le deja mandar un texto libre y ofrece plantillas. No es una falla: es la restricción de WhatsApp.
|
||||
|
||||
Las plantillas las crea y aprueba Meta. Si necesita una nueva para un caso que se repite, pídala a un administrador — el trámite lleva días.
|
||||
|
||||
## Términos y condiciones
|
||||
|
||||
Todo contacto nuevo debe aceptar los términos antes de que el bot converse. Responde **ACEPTO** o **NO ACEPTO**.
|
||||
|
||||
Se le vuelven a pedir cuando pasan 6 meses o cuando se publica una versión nueva. Si un paciente dice que no puede abrir el documento de términos, avise: puede ser que el enlace esté caído, y eso lo resuelve un administrador.
|
||||
|
||||
## Problemas frecuentes
|
||||
|
||||
**El paciente dice que escribió y nadie le respondió.**
|
||||
Revise si la conversación quedó tomada por un operador que no siguió. En ese estado el bot no responde y queda esperando a una persona.
|
||||
|
||||
**No me deja enviar un mensaje.**
|
||||
Pasaron más de 24 horas desde el último mensaje del paciente. Use una plantilla.
|
||||
|
||||
**El paciente no recibe los consentimientos.**
|
||||
Verifique el número en su ficha. Si está bien y aun así no llegan, es problema de plantilla — avise a un administrador.
|
||||
@@ -0,0 +1,108 @@
|
||||
---
|
||||
roles: bacteriologo, supervisor
|
||||
---
|
||||
|
||||
# Toma de muestras
|
||||
|
||||
Guía de la estación de toma de muestras: atender al paciente, recibir sus muestras y firmar los formularios.
|
||||
|
||||
## Su estación
|
||||
|
||||
Igual que en recepción, si el equipo está registrado por IP o token, ve **solo su estación**. Si no, las ve todas.
|
||||
|
||||
## Atender un turno
|
||||
|
||||
Los pacientes derivados desde recepción aparecen en su bandeja. Al abrir uno ve su ficha completa: datos, exámenes solicitados, muestras a recibir y formularios pendientes.
|
||||
|
||||
## Recibir muestras
|
||||
|
||||
Cada muestra tiene tres estados posibles:
|
||||
|
||||
| Estado | Significado |
|
||||
|---|---|
|
||||
| **Recibida** | La tomaste o el paciente la entregó correctamente |
|
||||
| **Pendiente** | No se pudo obtener; queda debiendo |
|
||||
| **Rechazada** | Se obtuvo pero no sirve — hemólisis, volumen insuficiente, mal rotulada |
|
||||
|
||||
Al rechazar hay que indicar el motivo. Ese motivo queda registrado y se ve después en el historial.
|
||||
|
||||
### Muestras de visitas anteriores
|
||||
|
||||
```
|
||||
VISITA 1 · lunes VISITA 2 · jueves
|
||||
┌────────────────────┐ ┌────────────────────┐
|
||||
│ Turno A-042 │ │ Turno B-017 │
|
||||
│ │ │ │
|
||||
│ Sangre recibida │ │ Orina recibida │
|
||||
│ Orina PENDIENTE ├───────────────►│ └ visita anterior│
|
||||
└────────────────────┘ reaparece └────────────────────┘
|
||||
finalizado sola │
|
||||
NO se modifica ◄─────────────────────────────
|
||||
quedan enlazados
|
||||
```
|
||||
|
||||
Si el paciente quedó debiendo una muestra otro día, le aparece con una etiqueta ámbar **visita anterior**, e incluye los exámenes de aquella orden para que sepa de qué se trataba.
|
||||
|
||||
Se reciben con un clic, igual que cualquier otra. Al hacerlo, los dos turnos quedan enlazados: desde el historial puede saltar de uno al otro.
|
||||
|
||||
> **El turno original no se modifica.** Sigue finalizado como estaba. Solo se registra en qué visita se completó la muestra.
|
||||
|
||||
## Formularios
|
||||
|
||||
### Datos Toma de Muestras (F-LAB-08)
|
||||
|
||||
Obligatorio en todas las estaciones. Recoge la historia clínica del momento: síntomas, antecedentes familiares y personales, medicación, datos obstétricos si corresponde.
|
||||
|
||||
Lo firma usted, no el paciente.
|
||||
|
||||
**Si el paciente ya lo llenó en una visita anterior**, aparece el botón *Cargar datos de la visita anterior*. Trae las respuestas de la última vez para que solo revises y ajustes lo que cambió. **No trae la firma**: esa la pone usted, con la fecha de hoy.
|
||||
|
||||
### Control de Tomas de Muestras Prolongadas (F-LAB-28)
|
||||
|
||||
Para exámenes que requieren varias tomas en el tiempo: curvas de glicemia, prolactina, cortisol, test de Sullivan.
|
||||
|
||||
```
|
||||
Glicemia pre y post carga
|
||||
|
||||
min 0 min 30 min 60 min 120
|
||||
│ │ │ │
|
||||
┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐
|
||||
│ ✔ │ ────► │ ✔ │ ────► │ │ ... │ │
|
||||
└─────┘ 30min └─────┘ 30min └─────┘ └─────┘
|
||||
07:28 08:06 pendiente bloqueada
|
||||
M. Monterrosa Y. Parada ▲
|
||||
└ cuenta regresiva
|
||||
|
||||
Cada toma guarda SU hora y QUIÉN la firmó. Si cambia
|
||||
el turno del personal, cada firma conserva su nombre.
|
||||
```
|
||||
|
||||
Cómo funciona:
|
||||
|
||||
1. **Marque el examen.** El formulario muestra solo las tomas de ese examen; si el paciente tiene dos exámenes seriados, muestra las de ambos.
|
||||
2. **Configure los tiempos** si se lo pide (minuto 0, 30, 60…).
|
||||
3. **Firme cada toma** a medida que la hace. El sistema registra la hora y **quién firmó**.
|
||||
4. Cuando firma una, el sistema calcula cuándo toca la siguiente y muestra una cuenta regresiva.
|
||||
|
||||
> Cada toma se firma por separado y queda con el nombre de quien la hizo. Si cambia el turno del personal a mitad del protocolo, cada toma conserva el nombre correcto.
|
||||
|
||||
Si hay que cerrar el protocolo antes de terminar todas las tomas, se puede hacer indicando el motivo.
|
||||
|
||||
## Antes de finalizar
|
||||
|
||||
El sistema no le deja finalizar si quedan muestras sin decidir. Cada una tiene que estar recibida, pendiente o rechazada.
|
||||
|
||||
## Comentarios
|
||||
|
||||
Puede dejar notas en el turno. Quedan visibles para el resto del personal y en el historial.
|
||||
|
||||
## Preguntas frecuentes
|
||||
|
||||
**¿Puedo revertir una muestra que marqué mal?**
|
||||
Sí. Con el botón de deshacer vuelve a pendiente.
|
||||
|
||||
**El formulario me muestra secciones de exámenes que el paciente no tiene.**
|
||||
Avise a soporte. Debería mostrar únicamente las del examen marcado.
|
||||
|
||||
**¿Qué pasa si el paciente se va sin dar una muestra?**
|
||||
Déjela en **pendiente**. Cuando vuelva —el día que sea— le va a aparecer sola a quien lo atienda.
|
||||
@@ -0,0 +1,63 @@
|
||||
---
|
||||
roles: lab_readonly, recepcionista, lab_recepcion, supervisor
|
||||
---
|
||||
|
||||
# Órdenes médicas
|
||||
|
||||
Las órdenes que el paciente envía por WhatsApp, casi siempre como foto de la orden en papel. Acá se revisan y se autorizan.
|
||||
|
||||
## El recorrido
|
||||
|
||||
```
|
||||
PACIENTE USTED RESULTADO
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────┐ ┌───────────────┐ ┌────────────────┐
|
||||
│ envía │───────────►│ PENDIENTE │───────────►│ AUTORIZADA │
|
||||
│ la foto │ │ la revisa │ │ puede atenderse│
|
||||
└─────────┘ └───────┬───────┘ └────────────────┘
|
||||
│
|
||||
└──────────────────► ┌────────────────┐
|
||||
│ RECHAZADA │
|
||||
│ con comentario│
|
||||
└────────────────┘
|
||||
```
|
||||
|
||||
Toda orden llega como **pendiente**. Queda registrado quién la revisó, quién la autorizó y cuándo.
|
||||
|
||||
## Revisar una orden
|
||||
|
||||
Al abrirla ve la imagen que envió el paciente y los campos para transcribir lo que dice:
|
||||
|
||||
| Dato | Por qué importa |
|
||||
|---|---|
|
||||
| Médico y registro | Identifica quién la solicitó |
|
||||
| Fecha de la orden | Una orden vieja puede estar vencida |
|
||||
| Diagnóstico | Va en los reportes y en la facturación |
|
||||
| Exámenes solicitados | Define qué se le va a hacer |
|
||||
| **Requiere ayuno** y horas | Determina qué se le indica al paciente antes de venir |
|
||||
| Indicaciones | Cualquier preparación adicional |
|
||||
|
||||
> El ayuno es el dato que más consecuencias tiene si se transcribe mal: un paciente que viene sin ayunar cuando debía obliga a repetir la visita.
|
||||
|
||||
## Autorizar o rechazar
|
||||
|
||||
- **Autorizar** — la orden queda lista y el paciente puede ser atendido.
|
||||
- **Rechazar** — hay que dejar un comentario explicando por qué. Ese comentario es lo que permite entender después qué pasó.
|
||||
|
||||
Motivos frecuentes de rechazo: foto ilegible, orden vencida, faltan datos del médico, o los exámenes no corresponden a los que presta el laboratorio.
|
||||
|
||||
## Si solo tiene permiso de lectura
|
||||
|
||||
Algunos roles ven las órdenes sin poder modificarlas. En ese caso no aparecen los botones de autorizar ni rechazar: puede consultarlas e informarse, pero la decisión la toma otra persona.
|
||||
|
||||
## Preguntas frecuentes
|
||||
|
||||
**La foto llegó ilegible.**
|
||||
Rechace la orden indicándolo en el comentario, para que se le pueda pedir al paciente que la envíe de nuevo.
|
||||
|
||||
**El paciente dice que envió la orden y no aparece.**
|
||||
Verifique en el chat de WhatsApp que el mensaje haya llegado. Si llegó pero no se registró como orden, avise a un administrador.
|
||||
|
||||
**Me equivoqué al transcribir un dato.**
|
||||
Mientras la orden no esté autorizada se puede corregir. Si ya lo está, pídale a un administrador que la reabra.
|
||||
@@ -0,0 +1,64 @@
|
||||
---
|
||||
roles: recepcionista, lab_recepcion, lab_readonly, supervisor
|
||||
---
|
||||
|
||||
# Domicilios
|
||||
|
||||
La pantalla administrativa de las visitas domiciliarias: agendarlas, asignarles enfermero y seguirlas.
|
||||
|
||||
> Es distinta del **portal del enfermero**. Aquí se ve y administra todo; el portal muestra a cada enfermero solo lo suyo y está pensado para el celular.
|
||||
|
||||
## Agendar una visita
|
||||
|
||||
1. **Paciente** — búsquelo por cédula. Si no existe, se crea en el momento.
|
||||
2. **Dirección** — la de su ficha con un botón, o escriba otra. Agregue indicaciones si el lugar es difícil de ubicar.
|
||||
3. **Fecha y hora**.
|
||||
4. **Servicio** — qué se va a hacer.
|
||||
5. **Seguro y autorización**, si aplica.
|
||||
6. **Valores** — domicilio y copago.
|
||||
|
||||
## Asignar un enfermero
|
||||
|
||||
Una visita agendada queda **sin asignar** hasta que se le pone un enfermero. Las sin asignar del día son las que hay que resolver primero: nadie las va a atender solo porque estén agendadas.
|
||||
|
||||
Al asignarla, la visita aparece en el portal de ese enfermero, que puede editarla.
|
||||
|
||||
## Estados
|
||||
|
||||
```
|
||||
┌────────────┐ ┌───────────┐ ┌──────────────┐
|
||||
│ programado ├──►│ en curso ├──►│ completado │
|
||||
└─────┬──────┘ └───────────┘ └──────────────┘
|
||||
│
|
||||
└────────────────────────► ┌──────────────┐
|
||||
│ cancelado │
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
## Notas y archivos
|
||||
|
||||
Cada visita admite notas de seguimiento, con fotos y archivos adjuntos. Sirve para dejar la orden médica en papel, un resultado o una observación de lo ocurrido.
|
||||
|
||||
Las notas quedan visibles para quien atienda después. Es el lugar correcto para dejar constancia de algo que el próximo necesita saber.
|
||||
|
||||
## Formularios
|
||||
|
||||
Se le puede enviar un formulario al paciente para que lo firme desde su celular, o copiar el enlace para hacérselo llegar por otro medio. Los ya firmados se consultan desde la misma visita.
|
||||
|
||||
## Si solo tiene permiso de lectura
|
||||
|
||||
Algunos roles ven los domicilios sin poder modificarlos: no aparecen los botones de crear ni editar. Puede consultar la agenda, el estado de cada visita y sus notas.
|
||||
|
||||
## Preguntas frecuentes
|
||||
|
||||
**Agendé una visita y el enfermero dice que no la ve.**
|
||||
Verifique que le haya asignado el enfermero. Sin asignación, la visita no aparece en ningún portal.
|
||||
|
||||
**Hay que reprogramar.**
|
||||
Edite la fecha y la hora, y avísele al paciente. Desde el portal del enfermero hay un enlace que abre WhatsApp con el mensaje ya redactado.
|
||||
|
||||
**El paciente cambió de dirección.**
|
||||
Edite la visita. Si el cambio es permanente, actualice también la ficha del paciente, o la próxima vez volverá a aparecer la anterior.
|
||||
|
||||
**¿Puedo ver los domicilios de todos los enfermeros?**
|
||||
Desde esta pantalla sí. El portal, en cambio, muestra a cada enfermero solo los suyos.
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
roles: enfermero, supervisor
|
||||
---
|
||||
|
||||
# Enfermeros — domicilios
|
||||
|
||||
Guía del portal del enfermero: sus visitas domiciliarias, cómo agendarlas y qué hacer en cada una.
|
||||
|
||||
## Su portal
|
||||
|
||||
Al entrar va directo al portal. Ve **sus** domicilios: los que le asignaron y los que agendó usted.
|
||||
|
||||
Está pensado para usarse desde el celular en la calle.
|
||||
|
||||
## Agendar un domicilio
|
||||
|
||||
**Nuevo domicilio** abre el formulario:
|
||||
|
||||
1. **Paciente** — búsquelo por cédula. Si no existe, se crea ahí mismo.
|
||||
2. **Dirección** — la del paciente con un botón, o escriba otra. Agregue indicaciones si el lugar es difícil de encontrar («apto 302, tocar campanilla»).
|
||||
3. **Fecha y hora**.
|
||||
4. **Servicio** — qué se va a hacer.
|
||||
5. **Seguro y autorización** si aplica.
|
||||
6. **Valores** — domicilio, copago.
|
||||
|
||||
## Editar
|
||||
|
||||
Puede editar los domicilios **que agendó usted** y también **los que le asignaron**. El botón *Editar* aparece mientras el domicilio no esté completado ni cancelado.
|
||||
|
||||
## Avisar al paciente por WhatsApp
|
||||
|
||||
Hay un enlace que abre WhatsApp con el mensaje ya escrito, presentándote como profesional del Laboratorio Ximena Caicedo. Solo revisa y envía.
|
||||
|
||||
## Notas y archivos
|
||||
|
||||
Puede dejar dos tipos de nota en cada domicilio:
|
||||
|
||||
- **Nota de ficha** — estructurada, para datos clínicos.
|
||||
- **Nota libre** — texto suelto.
|
||||
|
||||
Ambas admiten fotos y archivos adjuntos, útil para órdenes médicas en papel o resultados.
|
||||
|
||||
## Formularios
|
||||
|
||||
Puede enviarle un formulario al paciente para que lo firme desde su celular, o copiar el enlace para pasárselo por otro medio. Los que ya firmó se pueden consultar desde el mismo domicilio.
|
||||
|
||||
Cuando firma usted un formulario, queda registrado con su **nombre y cédula**, tomados de su ficha de enfermero.
|
||||
|
||||
## Estados de un domicilio
|
||||
|
||||
| Estado | Significado |
|
||||
|---|---|
|
||||
| Programado | Agendado, sin atender |
|
||||
| En curso | Está en la visita |
|
||||
| Completado | Terminado |
|
||||
| Cancelado | No se hizo |
|
||||
|
||||
## Preguntas frecuentes
|
||||
|
||||
**No puedo editar un domicilio.**
|
||||
Solo se pueden editar los propios o los asignados a usted, y solo si no está completado ni cancelado.
|
||||
|
||||
**El paciente cambió de dirección.**
|
||||
Edite el domicilio. Si el cambio es permanente, actualice también la ficha del paciente.
|
||||
|
||||
**Necesito reprogramar.**
|
||||
Edite la fecha y hora, y avísele al paciente por WhatsApp.
|
||||
|
||||
**¿Puedo ver domicilios de otro enfermero?**
|
||||
No. El portal muestra únicamente los tuyos.
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
roles: formularios_readonly, enfermero, supervisor
|
||||
---
|
||||
|
||||
# Formularios
|
||||
|
||||
Los consentimientos y fichas clínicas que firman pacientes y personal. Acá se diseñan, se consultan y se revisan los que ya se firmaron.
|
||||
|
||||
## Cómo está armado un formulario
|
||||
|
||||
Un formulario es una lista de campos en orden. El diseñador permite armarlo sin escribir nada técnico: se agregan campos, se les pone nombre y se ordenan.
|
||||
|
||||
| Tipo de campo | Para qué sirve |
|
||||
|---|---|
|
||||
| **Separador** | Divide el formulario en secciones |
|
||||
| **Párrafo** | Texto fijo — es lo que se usa para el articulado legal de un consentimiento |
|
||||
| **Texto**, **Área de texto**, **Número** | Respuestas escritas |
|
||||
| **Fecha**, **Hora** | Fechas y horas |
|
||||
| **Opción única**, **Casillas**, **Lista** | Respuestas para elegir |
|
||||
| **Vinculado** | Se llena solo con un dato del paciente (nombre, documento, EPS) |
|
||||
| **Firma del paciente** | La dibuja el paciente |
|
||||
| **Firma del profesional** | La dibuja quien atiende |
|
||||
|
||||
> Los campos **vinculados** evitan que alguien escriba mal un nombre o un documento: los toma de la ficha del paciente.
|
||||
|
||||
## Secciones que aparecen solo a veces
|
||||
|
||||
Una sección puede depender de una respuesta anterior. Por ejemplo, los datos obstétricos solo aparecen si el sexo registrado es femenino; las tomas de insulina, solo si se marcó ese examen.
|
||||
|
||||
Se configura eligiendo de qué campo depende la sección y con qué respuestas debe mostrarse. Si la condición no se cumple, ni la sección ni sus campos aparecen.
|
||||
|
||||
## Quién firma qué
|
||||
|
||||
```
|
||||
┌───────────────────────┐ ┌───────────────────────┐
|
||||
│ FIRMA DEL PACIENTE │ │ FIRMA DEL PROFESIONAL │
|
||||
│ │ │ │
|
||||
│ la dibuja el paciente │ │ la dibuja quien atiende│
|
||||
│ desde su celular │ │ en el equipo del lab │
|
||||
│ │ │ │
|
||||
│ llega por WhatsApp │ │ queda con nombre │
|
||||
│ │ │ y cédula │
|
||||
└───────────────────────┘ └───────────────────────┘
|
||||
```
|
||||
|
||||
La firma del profesional **siempre** queda identificada con nombre y cédula. No es opcional.
|
||||
|
||||
## Formularios con varias firmas
|
||||
|
||||
Algunos formularios se firman por partes a lo largo de horas — el de tomas prolongadas es el caso típico. Cada firma se guarda por separado, con su hora y el nombre de quien la hizo, de modo que si cambia el turno del personal cada toma conserva el nombre correcto.
|
||||
|
||||
## Consultar formularios firmados
|
||||
|
||||
Se puede ver el documento tal como quedó, con las respuestas y las firmas. Los formularios firmados **no se modifican**: son un registro con valor legal.
|
||||
|
||||
Si un formulario tiene un error, se emite uno nuevo; no se corrige el anterior.
|
||||
|
||||
## Si solo tiene permiso de lectura
|
||||
|
||||
Puede consultar los formularios y los documentos firmados, pero no crear ni modificar plantillas. Es el permiso habitual para quien audita o revisa documentación.
|
||||
|
||||
## Preguntas frecuentes
|
||||
|
||||
**Cambié un formulario. ¿Se modifican los ya firmados?**
|
||||
No. Los firmados conservan la versión con la que se firmaron. El cambio aplica de ahí en adelante.
|
||||
|
||||
**Un documento firmado muestra secciones que no correspondían.**
|
||||
Avise a soporte con el número de turno. Debería mostrar únicamente las secciones del examen del paciente.
|
||||
|
||||
**No aparece el nombre de quien firmó.**
|
||||
Los documentos anteriores a agosto de 2026 no guardaban ese dato y no se puede recuperar. Los posteriores sí lo registran.
|
||||
@@ -0,0 +1,91 @@
|
||||
---
|
||||
roles: supervisor
|
||||
---
|
||||
|
||||
# Administración
|
||||
|
||||
Tareas de administrador: usuarios, permisos, configuración y reportes.
|
||||
|
||||
## Usuarios y permisos
|
||||
|
||||
### Crear un usuario
|
||||
|
||||
La convención de la casa es usar el **número de cédula como nombre de usuario** para el personal asistencial.
|
||||
|
||||
Cargue también la **cédula** en su ficha: es lo que aparece bajo la firma en los formularios. Si falta, el documento sale firmado sin identificación.
|
||||
|
||||
Los enfermeros son un caso aparte: su cédula sale de la ficha de enfermero, no del usuario.
|
||||
|
||||
### Cambiar el rol de alguien
|
||||
|
||||
> Un usuario tiene **dos** campos de rol y hay que cambiar los dos. El texto (`role`) es lo que muestra la interfaz; el vínculo (`role_id`) es de donde salen los permisos reales.
|
||||
|
||||
Si cambia solo uno, el usuario ve un rol y tiene los permisos del otro. Ya pasó.
|
||||
|
||||
Después del cambio, **el usuario debe cerrar sesión y volver a entrar**: los permisos se cargan al iniciar sesión, no en cada pantalla.
|
||||
|
||||
### Dejar un módulo en solo lectura
|
||||
|
||||
Lo que decide si alguien puede modificar es el campo `permission` (`read` o `write`) de cada módulo del rol.
|
||||
|
||||
> Las columnas `can_editar`, `can_crear` y similares **no se usan** para el control de acceso. Ponerlas en cero no impide nada. Lo que manda es `permission`.
|
||||
|
||||
## Turnero
|
||||
|
||||
### Escritorios y estaciones
|
||||
|
||||
Cada puesto de recepción y cada estación de muestras es un *lugar*. Se les puede asignar:
|
||||
|
||||
- **Formularios obligatorios** — todo paciente que pase por ahí los debe firmar.
|
||||
- **Equipos por IP o token** — así el operador ve solo su puesto y no puede confundirse.
|
||||
|
||||
### Formularios obligatorios
|
||||
|
||||
Se exigen por dos vías, y se acumulan:
|
||||
|
||||
| Vía | Ejemplo |
|
||||
|---|---|
|
||||
| Por examen | VIH exige su consentimiento específico |
|
||||
| Por estación | Toda toma de muestras exige F-LAB-08 |
|
||||
|
||||
### Pantalla de TV
|
||||
|
||||
Admite una lista de videos e imágenes que se reproducen en bucle, uno detrás de otro. Se pueden reordenar arrastrando y a cada imagen se le fija cuántos segundos dura.
|
||||
|
||||
### Reabrir un turno
|
||||
|
||||
Desde el historial se puede cambiar el estado de un turno, incluso reabrir uno finalizado, ausente o cancelado.
|
||||
|
||||
## Facturación del día
|
||||
|
||||
El panel muestra tres cifras:
|
||||
|
||||
| Cifra | Qué incluye |
|
||||
|---|---|
|
||||
| **Facturado** | Turnos finalizados |
|
||||
| **En proceso** | Turnos aún activos, ya cobrados pero sin cerrar |
|
||||
| **Total estimado** | La suma de ambos |
|
||||
|
||||
Los turnos **ausentes y cancelados no se cuentan** en ninguna: no se van a cobrar.
|
||||
|
||||
## LIA
|
||||
|
||||
El asistente del dashboard del turnero responde preguntas sobre la operación del día: tiempos por profesional, facturación, exámenes más pedidos, buscar un paciente.
|
||||
|
||||
Tiene un presupuesto de consumo. Cuando se agota, se bloquea y hay que reponerlo. El consumo por pregunta es alto porque envía el contexto completo del día cada vez.
|
||||
|
||||
## Documentos
|
||||
|
||||
Los datos que salen en el encabezado de todos los documentos —nombre, dirección, ciudad, teléfono, logo, color— se editan desde **Configuración del laboratorio**, sin tocar código.
|
||||
|
||||
> La dirección física se cambia desde ahí. Pero **la URL de los botones que llegan por WhatsApp no**: esa vive en la plantilla aprobada por Meta y se cambia en el WhatsApp Manager, con reaprobación de por medio.
|
||||
|
||||
## Términos y condiciones
|
||||
|
||||
El bot exige aceptarlos antes de conversar. La URL del documento está en **dos lugares** que hay que cambiar juntos: la versión activa de términos y el texto del mensaje de bienvenida, que la repite dentro.
|
||||
|
||||
Se vuelve a pedir la aceptación cuando pasan 6 meses o cuando se publica una versión nueva marcada para reenvío.
|
||||
|
||||
## Cuando algo falla
|
||||
|
||||
El [runbook de incidentes](?m=soporte&v=documentacion&s=operacion&d=runbook) tiene los casos frecuentes con su diagnóstico y solución.
|
||||
@@ -0,0 +1,70 @@
|
||||
---
|
||||
roles: supervisor
|
||||
---
|
||||
|
||||
# Reportes y métricas
|
||||
|
||||
Dónde mirar cada cosa. Hay **dos lugares distintos** y conviene no confundirlos.
|
||||
|
||||
```
|
||||
┌──────────────────────────┐ ┌──────────────────────────┐
|
||||
│ DASHBOARD DEL TURNERO │ │ REPORTES │
|
||||
│ │ │ │
|
||||
│ la operación de HOY │ │ el laboratorio en │
|
||||
│ │ │ general y los domicilios │
|
||||
│ • facturación del día │ │ │
|
||||
│ • tiempos de atención │ │ • ingresos y pagos │
|
||||
│ • turnos en espera │ │ • rendimiento │
|
||||
│ • asistente LIA │ │ • traslado de enfermeros │
|
||||
└──────────────────────────┘ └──────────────────────────┘
|
||||
```
|
||||
|
||||
## Facturación del día
|
||||
|
||||
En el dashboard del turnero, tres cifras separadas:
|
||||
|
||||
| Cifra | Qué incluye |
|
||||
|---|---|
|
||||
| **Facturado** | Turnos finalizados — plata efectivamente cobrada |
|
||||
| **En proceso** | Turnos aún activos, ya cobrados pero sin cerrar |
|
||||
| **Total estimado** | La suma de ambos |
|
||||
|
||||
Lo importante de entender: **los ausentes y cancelados no se cuentan en ninguna**. No se van a cobrar, así que sumarlos daría una cifra falsa.
|
||||
|
||||
La gráfica por hora usa la misma separación: azul lo finalizado, ámbar lo que sigue en curso.
|
||||
|
||||
## Tiempos de atención
|
||||
|
||||
El dashboard muestra promedios de espera, recepción y servicio, y los desglosa por persona.
|
||||
|
||||
En el historial, un servicio que superó los 20 minutos se resalta en rojo. **No siempre es un problema**: un examen seriado dura horas por definición. Al abrir el detalle se ve el desglose por toma y quién atendió cada una.
|
||||
|
||||
## Traslado de enfermeros
|
||||
|
||||
En Reportes. Sale de comparar las marcas de hora de las visitas domiciliarias, y sirve para detectar rutas mal armadas — mucho tiempo entre visitas suele significar que el orden geográfico no se tuvo en cuenta al agendar.
|
||||
|
||||
## Preguntar en lugar de buscar
|
||||
|
||||
En el dashboard del turnero está **LIA**, que responde sobre la operación del día:
|
||||
|
||||
- ¿Cuántos pacientes se atendieron hoy y cuántos faltan?
|
||||
- ¿Cuánto se facturó?
|
||||
- ¿Cuáles son los tiempos promedio por bacteriólogo?
|
||||
- ¿Cuántos están en espera ahora?
|
||||
|
||||
Responde con los datos del día, no con estimaciones. Si no tiene el dato, lo dice.
|
||||
|
||||
## Exportar
|
||||
|
||||
El historial de turnos se exporta a CSV con los filtros que tenga aplicados. Sirve para analizar en una planilla lo que la pantalla no muestra.
|
||||
|
||||
## Preguntas frecuentes
|
||||
|
||||
**Los números del dashboard y de reportes no coinciden.**
|
||||
Miran cosas distintas: el dashboard es la operación presencial de hoy; reportes abarca el laboratorio en general, incluidos domicilios. No son comparables directamente.
|
||||
|
||||
**Un turno aparece con un tiempo de servicio enorme.**
|
||||
Revise si era un examen seriado. Esos duran horas por diseño y el tiempo alto es correcto.
|
||||
|
||||
**Necesito un reporte que no existe.**
|
||||
Exporte el historial a CSV y arme el cálculo en una planilla. Si es algo que va a necesitar seguido, pídalo a un administrador.
|
||||
@@ -0,0 +1,167 @@
|
||||
# Runbook de incidentes
|
||||
|
||||
Qué hacer cuando algo falla. Ordenado por lo que reporta el usuario, no por la causa.
|
||||
|
||||
---
|
||||
|
||||
## «No veo un módulo que antes veía»
|
||||
|
||||
O el opuesto: «puedo editar algo que no debería».
|
||||
|
||||
**Casi siempre es una de dos cosas:** el usuario no volvió a iniciar sesión, o `role` y `role_id` quedaron desincronizados.
|
||||
|
||||
```sql
|
||||
-- 1. ¿Las dos columnas coinciden?
|
||||
SELECT u.id, u.username, u.role, u.role_id, r.slug AS rol_real
|
||||
FROM admin_users u LEFT JOIN roles r ON r.id = u.role_id
|
||||
WHERE u.username = 'usuario';
|
||||
|
||||
-- 2. ¿Qué le da ese rol?
|
||||
SELECT module_slug, permission FROM role_modules WHERE role_id = <role_id>;
|
||||
```
|
||||
|
||||
Si los datos están bien → **que cierre sesión y vuelva a entrar**. Los permisos se cargan al iniciar sesión, no en cada petición.
|
||||
|
||||
Si `role` y `role_id` no coinciden, actualice las dos:
|
||||
|
||||
```sql
|
||||
UPDATE admin_users
|
||||
SET role = 'lab_recepcion',
|
||||
role_id = (SELECT id FROM roles WHERE slug = 'lab_recepcion')
|
||||
WHERE id = <id>;
|
||||
```
|
||||
|
||||
> Para dejar un módulo en solo lectura, lo que importa es `permission = 'read'`. Las columnas `can_edit`, `can_create` y demás **no** las lee el control de acceso.
|
||||
|
||||
---
|
||||
|
||||
## «Un enlace que enviamos por WhatsApp está roto»
|
||||
|
||||
Primero, compruebe si el destino responde:
|
||||
|
||||
```bash
|
||||
curl -s -o /dev/null -w "%{http_code}\n" "<la URL>"
|
||||
```
|
||||
|
||||
**Si devuelve 503 o no resuelve**, el dominio está caído o cambió. Revise si el archivo existe en el dominio actual del sistema.
|
||||
|
||||
Las URL que enviamos viven en lugares distintos según el caso:
|
||||
|
||||
| Enlace | Dónde está definido |
|
||||
|---|---|
|
||||
| Botón de consentimiento | **En la plantilla de Meta**, no en el código |
|
||||
| Documento de términos | `terms_versions.documento_url` **y** `system_config.terms_message` |
|
||||
| Respaldo texto plano del consentimiento | Se arma con el dominio del servidor |
|
||||
|
||||
> Si el enlace roto es el botón de una plantilla, **cambiar el código no lo arregla**. Hay que editar la plantilla en el WhatsApp Manager de Meta y esperar la reaprobación.
|
||||
|
||||
Para el documento de términos, hay que cambiar **los dos** lugares a la vez:
|
||||
|
||||
```sql
|
||||
UPDATE terms_versions
|
||||
SET documento_url = REPLACE(documento_url, 'dominio.viejo', 'dominio.nuevo')
|
||||
WHERE documento_url LIKE '%dominio.viejo%';
|
||||
|
||||
UPDATE system_config
|
||||
SET config_value = REPLACE(config_value, 'dominio.viejo', 'dominio.nuevo')
|
||||
WHERE config_value LIKE '%dominio.viejo%';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## «El mensaje salió por el número equivocado»
|
||||
|
||||
Las dos líneas comparten cuenta y token; lo único que cambia es el `phone_number_id`. Revise que el envío haya especificado el canal:
|
||||
|
||||
```php
|
||||
$wa = new WhatsAppService('turnero'); // no new WhatsAppService()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## «LIA responde cortado»
|
||||
|
||||
El asistente del dashboard del turnero tiene tope de salida. Si la respuesta se corta a media frase, ahora avisa con *«respuesta cortada por longitud»*.
|
||||
|
||||
| Qué revisar | Dónde |
|
||||
|---|---|
|
||||
| Tope de tokens de salida | `modules/turnero/api/ai_chat.php`, `maxOutputTokens` |
|
||||
| Presupuesto consumido | `lab_config.lia_tokens_usados` (tope: 1.000.000) |
|
||||
| Clave configurada | `lab_config.gemini_api_key` |
|
||||
|
||||
Si el presupuesto se agotó, LIA se bloquea y pide contactar a soporte. Para reiniciar el contador:
|
||||
|
||||
```sql
|
||||
UPDATE lab_config SET valor = '0' WHERE clave = 'lia_tokens_usados';
|
||||
```
|
||||
|
||||
> El contexto del día se manda completo en **cada** pregunta, así que el gasto por consulta es alto aunque la respuesta sea corta.
|
||||
|
||||
---
|
||||
|
||||
## «El formulario de tomas prolongadas muestra secciones que no corresponden»
|
||||
|
||||
El formulario F-LAB-28 tiene secciones para todos los exámenes posibles y muestra solo las del examen del paciente. Si aparecen de más:
|
||||
|
||||
1. Verifique que el documento se abra con `&embed=1&compact=1` — sin esos parámetros no se aplica el filtrado.
|
||||
2. Revise `_tomas_config` dentro de `datos_respuestas`: ahí queda qué ciclos se configuraron.
|
||||
|
||||
---
|
||||
|
||||
## «No aparece quién firmó una toma»
|
||||
|
||||
Las firmas de tomas prolongadas registran el profesional **desde el 3 de agosto de 2026**. Los documentos firmados antes no tienen ese dato y **no es recuperable** — no quedó traza en ninguna tabla de auditoría.
|
||||
|
||||
Para los nuevos, el nombre y la cédula se resuelven en el servidor desde la sesión de quien firma. Si aparece vacío en un documento reciente, compruebe que el usuario tenga cédula:
|
||||
|
||||
```sql
|
||||
SELECT id, username, full_name, cedula FROM admin_users WHERE id = <id>;
|
||||
```
|
||||
|
||||
Los enfermeros la toman de `lab_enfermeras.numero_documento`; el resto de `admin_users.cedula`.
|
||||
|
||||
---
|
||||
|
||||
## «El bot no responde»
|
||||
|
||||
| Revisar | Cómo |
|
||||
|---|---|
|
||||
| ¿Llegan los mensajes? | Tabla `webhook_logs` |
|
||||
| ¿Está en horario? | `BusinessHoursService` — fuera de horario responde distinto |
|
||||
| ¿La conversación quedó con un operador? | Estado en `conversations`; el bot no interrumpe una atención humana |
|
||||
| ¿Aceptó los términos? | `users.terms_accepted_at`; sin aceptar, el bot no avanza |
|
||||
|
||||
---
|
||||
|
||||
## «Un paciente quedó con una muestra pendiente»
|
||||
|
||||
Cuando el paciente vuelve, las muestras pendientes **y rechazadas** de visitas anteriores aparecen automáticamente en la estación de toma de muestras, con la etiqueta *visita anterior* y los exámenes de aquella orden.
|
||||
|
||||
Al recibirla queda registrado en qué turno se completó (`turnero_muestras.recibida_en_turno_id`), y ambos turnos quedan enlazados en el historial y en la bandeja. **El turno original no se modifica**: sigue finalizado como estaba.
|
||||
|
||||
---
|
||||
|
||||
## Consultas útiles
|
||||
|
||||
```sql
|
||||
-- Facturación real de hoy (solo turnos finalizados)
|
||||
SELECT ROUND(SUM(ts.total_cobrado)) AS facturado, COUNT(*) AS turnos
|
||||
FROM turnero_turnos t
|
||||
JOIN turnero_solicitudes ts ON ts.turno_id = t.id
|
||||
JOIN turnero_sesiones s ON s.id = t.sesion_id
|
||||
WHERE s.fecha = CURDATE() AND t.estado = 'finalizado' AND ts.total_cobrado > 0;
|
||||
|
||||
-- Consentimientos sin firmar
|
||||
SELECT tc.estado, COUNT(*) FROM turnero_consentimientos tc
|
||||
JOIN turnero_turnos t ON t.id = tc.turno_id
|
||||
JOIN turnero_sesiones s ON s.id = t.sesion_id
|
||||
WHERE s.fecha = CURDATE() GROUP BY tc.estado;
|
||||
|
||||
-- Muestras pendientes acumuladas por paciente
|
||||
SELECT p.nombre_completo, COUNT(*) AS pendientes
|
||||
FROM turnero_muestras tm
|
||||
JOIN turnero_solicitudes ts ON ts.id = tm.solicitud_id
|
||||
JOIN lab_pacientes p ON p.id = ts.paciente_id
|
||||
WHERE tm.estado IN ('pendiente','rechazada')
|
||||
GROUP BY p.id ORDER BY pendientes DESC LIMIT 20;
|
||||
```
|
||||
@@ -0,0 +1,88 @@
|
||||
# Configuraciones críticas
|
||||
|
||||
Dónde vive cada cosa que se configura. La pregunta que más tiempo hace perder es *«¿esto dónde se cambia?»*, sobre todo porque no todo está en la base de datos.
|
||||
|
||||
## Las tres tablas de configuración
|
||||
|
||||
| Tabla | Contenido | Se edita desde |
|
||||
|---|---|---|
|
||||
| `system_config` | Credenciales de WhatsApp, webhook, mensaje de términos | Base de datos |
|
||||
| `lab_config` | Datos de la empresa, encabezados de documentos, clave y consumo de LIA | Configuración del laboratorio |
|
||||
| `turnero_*` | Lugares, prioridades, dispositivos, playlist de TV | Configuración del turnero |
|
||||
|
||||
## Lo que NO está en la base de datos
|
||||
|
||||
Esto es lo que más confunde:
|
||||
|
||||
| Configuración | Dónde vive de verdad |
|
||||
|---|---|
|
||||
| URL del botón de consentimiento | **Plantilla en el WhatsApp Manager de Meta** |
|
||||
| Texto y formato de las plantillas | **Meta** (`message_templates` es solo una copia) |
|
||||
| Dominio del sistema | Se deduce del `HTTP_HOST` de cada petición |
|
||||
|
||||
> Cambiar el código **no** cambia la URL que reciben los pacientes en el botón de una plantilla. Eso se edita en Meta y requiere reaprobación.
|
||||
|
||||
## Datos de la empresa
|
||||
|
||||
En `lab_config`, salen impresos en el encabezado de todos los documentos:
|
||||
|
||||
| Clave | Ejemplo |
|
||||
|---|---|
|
||||
| `empresa_nombre` | XIMENA CAICEDO G. E.U |
|
||||
| `empresa_subtitulo` | Laboratorio Hematológico |
|
||||
| `empresa_direccion` | Calle 21 #0A-26, Barrio Blanco |
|
||||
| `empresa_ciudad` | Cúcuta, Norte de Santander |
|
||||
| `empresa_telefono` | +57 305 337 0116 |
|
||||
| `empresa_email` | servicioalcliente@laboratorioximenacaicedo.com |
|
||||
| `doc_logo_base64` | Logo embebido |
|
||||
| `doc_color` | Color de encabezados |
|
||||
|
||||
Se editan desde **Configuración del laboratorio**, sin tocar código.
|
||||
|
||||
## Dominio del sistema
|
||||
|
||||
`APP_URL` y `BASE_URL` se calculan en cada petición a partir del host (`config/config.php`):
|
||||
|
||||
```php
|
||||
$__host = $_SERVER['HTTP_HOST'] ?? 'localhost';
|
||||
define('APP_URL', $__proto . '://' . $__host);
|
||||
```
|
||||
|
||||
Detecta HTTPS detrás de proxy reverso mediante `X-Forwarded-Proto`.
|
||||
|
||||
> Consecuencia: si alguien entra por una IP o un dominio alternativo, **los enlaces que se generen en esa sesión llevarán esa dirección** — y quedan guardados así en el mensaje que recibe el paciente. Si eso importa, conviene fijar `APP_URL` explícitamente.
|
||||
|
||||
## Turnero
|
||||
|
||||
| Qué | Dónde |
|
||||
|---|---|
|
||||
| Escritorios y estaciones | `turnero_lugares` |
|
||||
| Formularios obligatorios por estación | `turnero_lugar_consentimientos` |
|
||||
| Formularios obligatorios por examen | `exam_tipo_consentimientos` |
|
||||
| Equipos fijos por IP o token | `turnero_dispositivos` |
|
||||
| Prioridades de la cola | `turnero_prioridades` |
|
||||
| Playlist de la pantalla de TV | `turnero_tv_media` |
|
||||
|
||||
Las estaciones de toma de muestras exigen el formulario **Datos Toma de Muestras (F-LAB-08)**, incluso en visitas marcadas como *solo entrega de muestras*.
|
||||
|
||||
## LIA
|
||||
|
||||
| Clave | Qué es |
|
||||
|---|---|
|
||||
| `lab_config.gemini_api_key` | Clave de la API de Google Gemini |
|
||||
| `lab_config.lia_tokens_usados` | Consumo acumulado (tope: 1.000.000) |
|
||||
|
||||
El tope está en el código como `LIA_TOKENS_MAX`.
|
||||
|
||||
## Términos y condiciones
|
||||
|
||||
En **dos** lugares que hay que mantener sincronizados:
|
||||
|
||||
- `terms_versions` — versión activa, URL del documento, mensajes de aceptación y rechazo
|
||||
- `system_config.terms_message` — el texto de bienvenida, que **repite la URL** dentro
|
||||
|
||||
## Cambios de esquema
|
||||
|
||||
Van en `migrations/`, con nombre `AAAAMMDD_descripcion.sql` e idempotentes (`IF NOT EXISTS`, guardas en `UPDATE`/`INSERT`).
|
||||
|
||||
Si aplica un cambio directo en producción, **deje también la migración**: sin ella, un entorno nuevo no tendrá ese cambio y nadie se va a enterar hasta que falle.
|
||||
@@ -0,0 +1,79 @@
|
||||
# Despliegue y mantenimiento
|
||||
|
||||
## Cómo se despliega
|
||||
|
||||
No hay build ni compilación. El código PHP se sirve directo:
|
||||
|
||||
```bash
|
||||
git pull
|
||||
```
|
||||
|
||||
Con eso los cambios están en producción. Es la contrapartida de no usar framework de frontend.
|
||||
|
||||
**Si el cambio incluye esquema de base de datos**, hay que correr la migración además del `git pull`.
|
||||
|
||||
## Repositorio
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| Remoto | `gitea` |
|
||||
| Rama | `main` |
|
||||
|
||||
Se trabaja directo sobre `main`.
|
||||
|
||||
## Migraciones
|
||||
|
||||
Van en `migrations/`, con nombre `AAAAMMDD_descripcion.sql`.
|
||||
|
||||
**Deben ser idempotentes** — poder correrse más de una vez sin causar daño:
|
||||
|
||||
```sql
|
||||
ALTER TABLE admin_users
|
||||
ADD COLUMN IF NOT EXISTS cedula VARCHAR(30) NULL AFTER cargo;
|
||||
|
||||
UPDATE admin_users SET cedula = username
|
||||
WHERE cedula IS NULL AND username REGEXP '^[0-9]{5,15}$';
|
||||
```
|
||||
|
||||
MariaDB 11.8 admite `IF NOT EXISTS` en `ALTER TABLE`. Para `UPDATE` e `INSERT`, la guarda va en el `WHERE`.
|
||||
|
||||
Antes de dar por buena una migración, córrala dos veces y verifique que la segunda no cambie nada.
|
||||
|
||||
> Aplicar un cambio directo en producción sin dejar la migración hace que un entorno nuevo no lo tenga, y nadie se entera hasta que algo falla. Si toca el esquema, deje el archivo.
|
||||
|
||||
## Archivos subidos
|
||||
|
||||
| Carpeta | Contenido |
|
||||
|---|---|
|
||||
| `uploads/turnero/tv_media/` | Videos e imágenes de la pantalla de TV |
|
||||
| `uploads/terms/` | Documentos de términos y condiciones |
|
||||
|
||||
Se crean solas al primer uso. **No están en el repositorio**: al mover el sistema de servidor hay que copiarlas aparte, o los enlaces quedan rotos.
|
||||
|
||||
## Verificaciones después de desplegar
|
||||
|
||||
```bash
|
||||
# Sintaxis de los archivos tocados
|
||||
php -l archivo.php
|
||||
|
||||
# ¿Responde un enlace público?
|
||||
curl -s -o /dev/null -w "%{http_code}\n" "https://<dominio>/<ruta>"
|
||||
```
|
||||
|
||||
Si el cambio afectó permisos, recuerde que **las sesiones abiertas conservan los permisos viejos** hasta que el usuario vuelva a entrar.
|
||||
|
||||
## Configuración por entorno
|
||||
|
||||
Las credenciales se leen de variables de entorno (`.env`, vía phpdotenv) y de `system_config`. El dominio no se configura: se deduce del `HTTP_HOST` de cada petición.
|
||||
|
||||
## Mantenimiento periódico
|
||||
|
||||
| Cada | Revisar |
|
||||
|---|---|
|
||||
| Semana | Consumo de LIA (`lab_config.lia_tokens_usados`) contra el tope de 1.000.000 |
|
||||
| Mes | Que los enlaces enviados por WhatsApp respondan — sobre todo el de términos |
|
||||
| Mes | Consentimientos que quedaron sin firmar |
|
||||
| Trimestre | Plantillas de Meta: que sigan aprobadas |
|
||||
| Trimestre | Muestras pendientes acumuladas por paciente |
|
||||
|
||||
Las consultas para varias de estas revisiones están en el [runbook](?m=soporte&v=documentacion&s=operacion&d=runbook).
|
||||
@@ -0,0 +1,88 @@
|
||||
# Respaldos y recuperación
|
||||
|
||||
Qué hay que respaldar, y qué se pierde si no está.
|
||||
|
||||
## Las tres cosas a respaldar
|
||||
|
||||
```
|
||||
┌────────────────────┐ ┌────────────────────┐ ┌────────────────────┐
|
||||
│ BASE DE DATOS │ │ ARCHIVOS SUBIDOS │ │ CÓDIGO │
|
||||
│ │ │ │ │ │
|
||||
│ 91 tablas │ │ uploads/terms/ │ │ repositorio git │
|
||||
│ pacientes, turnos, │ │ uploads/turnero/ │ │ │
|
||||
│ formularios, │ │ │ │ ya respaldado por │
|
||||
│ firmas │ │ NO están en git │ │ estar en el remoto │
|
||||
└────────────────────┘ └────────────────────┘ └────────────────────┘
|
||||
crítico crítico cubierto
|
||||
```
|
||||
|
||||
El código está a salvo por estar versionado. Los otros dos **no tienen respaldo automático por el solo hecho de existir**.
|
||||
|
||||
## Base de datos
|
||||
|
||||
Es lo único irreemplazable. Contiene historia clínica, consentimientos firmados y facturación — información con valor legal y sin forma de reconstruirse.
|
||||
|
||||
```bash
|
||||
mysqldump -h <host> -u <usuario> -p <base> \
|
||||
--single-transaction --routines --triggers \
|
||||
| gzip > respaldo_$(date +%F).sql.gz
|
||||
```
|
||||
|
||||
`--single-transaction` evita bloquear las tablas mientras corre, así se puede hacer con el sistema en uso.
|
||||
|
||||
### Qué contiene lo crítico
|
||||
|
||||
| Tabla | Por qué importa |
|
||||
|---|---|
|
||||
| `lab_pacientes` | Fichas clínicas |
|
||||
| `turnero_consentimientos`, `lab_form_envios` | **Formularios firmados** — valor legal |
|
||||
| `turnero_turnos`, `turnero_solicitudes` | Historial de atención y facturación |
|
||||
| `terms_acceptance` | Aceptación de términos, ~5.000 registros |
|
||||
| `admin_users`, `roles`, `role_modules` | Acceso al sistema |
|
||||
|
||||
Las firmas se guardan como imagen **dentro** de las tablas, no como archivos sueltos. Un respaldo de la base las incluye.
|
||||
|
||||
## Archivos subidos
|
||||
|
||||
```
|
||||
uploads/terms/ documentos de términos y condiciones
|
||||
uploads/turnero/tv_media/ videos e imágenes de la pantalla de TV
|
||||
```
|
||||
|
||||
**No están en el repositorio.** Al mover el sistema de servidor hay que copiarlos aparte, o los enlaces quedan apuntando a archivos que ya no existen.
|
||||
|
||||
Es exactamente lo que pasó con el documento de términos cuando cambió el dominio: la base seguía apuntando a una URL que ya no respondía.
|
||||
|
||||
## Antes de un cambio riesgoso
|
||||
|
||||
Si va a tocar datos en producción, respalde **solo lo que va a tocar**:
|
||||
|
||||
```bash
|
||||
mysqldump -h <host> -u <usuario> -p <base> role_modules admin_users \
|
||||
> antes_del_cambio.sql
|
||||
```
|
||||
|
||||
Es rápido y suele alcanzar. Un respaldo completo para cambiar una columna es desproporcionado; no tener ninguno es imprudente.
|
||||
|
||||
## Verificar que el respaldo sirve
|
||||
|
||||
Un respaldo que nunca se restauró no es un respaldo, es un archivo:
|
||||
|
||||
```bash
|
||||
gunzip -t respaldo_2026-08-03.sql.gz # ¿está íntegro?
|
||||
zcat respaldo_2026-08-03.sql.gz | head -40 # ¿tiene lo que espera?
|
||||
```
|
||||
|
||||
Lo ideal es restaurarlo de vez en cuando en una base de prueba y comprobar que el sistema arranca contra ella.
|
||||
|
||||
## Qué NO es recuperable
|
||||
|
||||
Aunque tengas respaldos, hay datos que nunca se guardaron y no hay de dónde sacarlos:
|
||||
|
||||
| Dato | Desde cuándo existe |
|
||||
|---|---|
|
||||
| Quién creó cada consentimiento del turnero | 3 de agosto de 2026 |
|
||||
| Quién firmó cada toma de F-LAB-28 | 3 de agosto de 2026 |
|
||||
| Cédula del personal no enfermero | 3 de agosto de 2026 |
|
||||
|
||||
Los registros anteriores tienen esos campos vacíos, y **no hay traza de auditoría** que permita reconstruirlos. Vale como advertencia: cuando se agrega una columna para registrar quién hizo algo, lo anterior se pierde.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Índice de módulos
|
||||
|
||||
Inventario de los módulos del sistema, generado del filesystem en cada carga.
|
||||
|
||||
{{modulos}}
|
||||
|
||||
## Cómo leer esta tabla
|
||||
|
||||
**Vistas** son las pantallas (`modules/<slug>/views/*.php`). **Endpoints** son los archivos que devuelven JSON (`modules/<slug>/api/*.php`).
|
||||
|
||||
Un módulo con **1 vista y 0 endpoints** suele ser un puente al sistema anterior: la vista solo incluye el archivo de la raíz, donde está el código real.
|
||||
|
||||
```php
|
||||
// modules/lab_domicilios/views/index.php
|
||||
require_once APP_ROOT . '/lab_domicilios.php';
|
||||
```
|
||||
|
||||
**En SYSTEM_MODULES** indica si el módulo pasa por el control de permisos. Los que dicen «no» quedan accesibles para cualquier sesión — es el caso de módulos auxiliares que se consumen desde otras pantallas.
|
||||
|
||||
## Dónde está el código de verdad
|
||||
|
||||
| Módulo | Código real |
|
||||
|---|---|
|
||||
| `turnero` | En el módulo. Es el más grande y el más nuevo |
|
||||
| `registro_exams` | En el módulo |
|
||||
| `lab_examenes`, `medicos` | En el módulo |
|
||||
| `lab_domicilios`, `lab_pacientes`, `lab_ordenes`, y demás `lab_*` | Archivo de la raíz; el módulo es un puente |
|
||||
| `whatsapp` | `index.php` y `services/BotService.php` |
|
||||
| `enfermero_portal` | `enfermero_portal.php` |
|
||||
|
||||
## Servicios y clases compartidas
|
||||
|
||||
{{servicios}}
|
||||
|
||||
## Detalle por módulo
|
||||
|
||||
- [Turnero](?m=soporte&v=documentacion&s=tecnica&d=turnero)
|
||||
- [WhatsApp y bot](?m=soporte&v=documentacion&s=tecnica&d=whatsapp-bot)
|
||||
- [Formularios](?m=soporte&v=documentacion&s=tecnica&d=formularios)
|
||||
- [Domicilios](?m=soporte&v=documentacion&s=tecnica&d=domicilios)
|
||||
- [Todos los endpoints](?m=soporte&v=documentacion&s=tecnica&d=endpoints)
|
||||
@@ -0,0 +1,104 @@
|
||||
# Módulo Turnero
|
||||
|
||||
El módulo más grande del sistema: 12 vistas y unos 70 endpoints. Gestiona la atención presencial completa.
|
||||
|
||||
## Vistas
|
||||
|
||||
| Vista | Para quién | Qué hace |
|
||||
|---|---|---|
|
||||
| `dashboard` | Admin, supervisor | Métricas del día, facturación, asistente LIA |
|
||||
| `historial` | Admin, supervisor | Turnos de varios días, filtros, exportación |
|
||||
| `bandeja` | Bacteriólogo, admin | Turnos del día con su detalle completo |
|
||||
| `recepcion` | Recepcionista | Atención en el mostrador |
|
||||
| `lugar` | Bacteriólogo | Estación de toma de muestras |
|
||||
| `kiosko` | Público | El paciente saca su turno |
|
||||
| `display_global` | Público | Pantalla de TV de la sala |
|
||||
| `verificar_paciente` | Recepción | Consulta rápida de una ficha |
|
||||
| `chat` | Recepción | Conversación de WhatsApp del turnero |
|
||||
| `configuracion` | Admin | Lugares, dispositivos, plantillas, pantalla de TV |
|
||||
|
||||
`kiosko` y `display_global` son las **únicas rutas públicas** del sistema (`core/Router.php`): no hay quién inicie sesión en un televisor ni en el tótem de la entrada.
|
||||
|
||||
## Menú dinámico
|
||||
|
||||
`modules/turnero/module.php` no devuelve una lista fija: la arma según **el rol y la IP del equipo**.
|
||||
|
||||
```
|
||||
recepcionista → chat, verificar paciente, TV, y su escritorio
|
||||
bacteriólogo → bandeja y su estación
|
||||
admin → todo
|
||||
```
|
||||
|
||||
Si el equipo está en `turnero_dispositivos` (por IP o por cookie `turnero_token`), el usuario ve **solo su puesto**. Si no, los ve todos. Evita que alguien atienda desde el escritorio equivocado.
|
||||
|
||||
## Modelo de datos
|
||||
|
||||
```
|
||||
turnero_sesiones un día de operación
|
||||
└── turnero_turnos código, estado, tiempos, prioridad
|
||||
├── turnero_solicitudes qué se hace y cuánto se cobró
|
||||
│ ├── turnero_examen_items
|
||||
│ └── turnero_muestras
|
||||
├── turnero_consentimientos
|
||||
└── turnero_comentarios
|
||||
```
|
||||
|
||||
### Estados
|
||||
|
||||
```
|
||||
espera → en_recepcion → en_espera_lugar → en_servicio → finalizado
|
||||
ausente / cancelado
|
||||
```
|
||||
|
||||
Solo `finalizado` cuenta como facturación real.
|
||||
|
||||
## Consentimientos
|
||||
|
||||
Se crean automáticamente desde **dos fuentes** que se acumulan:
|
||||
|
||||
| Fuente | Tabla |
|
||||
|---|---|
|
||||
| Por examen | `exam_tipo_consentimientos` |
|
||||
| Por estación destino | `turnero_lugar_consentimientos` |
|
||||
|
||||
`get_consentimientos.php` los autocrea si faltan y calcula el estado de cada uno.
|
||||
|
||||
> En visitas de *solo entrega de muestras* no aplican los formularios por examen (no hay exámenes), pero **sí los de estación**. Por eso F-LAB-08 se exige igual.
|
||||
|
||||
## Tomas prolongadas
|
||||
|
||||
El formulario **F-LAB-28** cubre exámenes seriados. La lógica está repartida entre `ver_formulario_enviado.php` (render) y `modules/turnero/api/guardar_toma.php` (guardado).
|
||||
|
||||
- El esquema trae secciones para todos los exámenes posibles; se muestran solo las del examen marcado, mediante la `condicion` de cada separador.
|
||||
- `_tomas_config` en `datos_respuestas` guarda qué ciclos se configuraron para ese paciente.
|
||||
- Cada firma se guarda en su propio campo (`_tm00_f`, `_tm30_f`, …) junto con la hora y **la identidad de quien firmó**, resuelta en el servidor desde la sesión.
|
||||
- Al firmar, el endpoint calcula cuándo toca la siguiente toma y actualiza `siguiente_toma_at`.
|
||||
|
||||
> La identidad se resuelve **en el servidor**, no se acepta del cliente: cada toma puede firmarla un profesional distinto y esa es la única fuente confiable. Las firmas anteriores al 3 de agosto de 2026 no tienen ese dato y no es recuperable.
|
||||
|
||||
## Muestras entre visitas
|
||||
|
||||
Una muestra que queda `pendiente` o `rechazada` reaparece cuando el paciente vuelve, con la bandera `es_pendiente_anterior` y los exámenes de la orden original.
|
||||
|
||||
Al recibirla, `turnero_muestras.recibida_en_turno_id` registra en qué turno se completó. Historial y bandeja usan ese dato para enlazar ambos turnos en los dos sentidos.
|
||||
|
||||
**El turno original no se modifica**: sigue finalizado. Solo se agrega la trazabilidad.
|
||||
|
||||
## LIA
|
||||
|
||||
`api/ai_chat.php` — asistente sobre Gemini Flash.
|
||||
|
||||
| Aspecto | Detalle |
|
||||
|---|---|
|
||||
| Contexto | Se arma en cada llamada con hasta 60 turnos del día y sus detalles |
|
||||
| Historial | Los últimos intercambios se envían para que entienda preguntas de seguimiento |
|
||||
| Tope de salida | `maxOutputTokens`; si Gemini corta, se avisa con `finishReason` |
|
||||
| Presupuesto | `lab_config.lia_tokens_usados` contra `LIA_TOKENS_MAX` |
|
||||
|
||||
Se contabiliza `totalTokenCount`, que **incluye el contexto de entrada**. Como el contexto va completo en cada pregunta, el gasto por consulta es alto aunque la respuesta sea breve.
|
||||
|
||||
## Endpoints propios
|
||||
|
||||
Los del turnero usan `api/_helpers.php`, que provee `db()`, `inputJson()`, `jsonOk()`, `jsonError()`, `adminId()`, `requireTurnero()` y `notificarSSE()`.
|
||||
|
||||
`notificarSSE()` avisa a las pantallas conectadas para que se refresquen sin recargar.
|
||||
@@ -0,0 +1,104 @@
|
||||
# Formularios y firma digital
|
||||
|
||||
Cómo se definen los formularios, cómo se envían y cómo se firman. Es transversal: lo usan el turnero, los domicilios y los envíos sueltos.
|
||||
|
||||
## Definición
|
||||
|
||||
Un formulario es una fila en `lab_formularios`. Su estructura está en la columna `esquema`, un JSON con la lista de campos:
|
||||
|
||||
```json
|
||||
[
|
||||
{"id": "_sep1", "tipo": "separador", "label": "Datos del paciente"},
|
||||
{"id": "_nom", "tipo": "linked", "linked_key": "nombre_completo", "label": "Nombre"},
|
||||
{"id": "_sint", "tipo": "checkbox", "label": "Síntomas", "options": ["Fiebre", "Tos"]},
|
||||
{"id": "_fir", "tipo": "firma_profesional", "label": "Firma del profesional"}
|
||||
]
|
||||
```
|
||||
|
||||
### Tipos de campo
|
||||
|
||||
| Tipo | Qué es |
|
||||
|---|---|
|
||||
| `separador` | Encabezado de sección; admite `condicion` |
|
||||
| `parrafo` | Texto fijo (consentimientos, notas legales) |
|
||||
| `texto`, `textarea`, `numero` | Entrada libre |
|
||||
| `fecha`, `fecha_hoy`, `hora` | Fechas y horas |
|
||||
| `radio`, `checkbox`, `select` | Opciones |
|
||||
| `linked` | Se autocompleta con un dato del paciente vía `linked_key` |
|
||||
| `firma` | Firma del paciente |
|
||||
| `firma_profesional` | Firma del profesional |
|
||||
|
||||
### Secciones condicionales
|
||||
|
||||
Un separador puede depender de otro campo:
|
||||
|
||||
```json
|
||||
{"id": "_sep_ins", "tipo": "separador", "label": "Insulina · Minuto 0",
|
||||
"condicion": {"campo_id": "_examen", "valores": ["Insulina"]}}
|
||||
```
|
||||
|
||||
La sección y **todos sus campos** se ocultan si la condición no se cumple. Los campos heredan el estado mediante el atributo `data-sep-id`.
|
||||
|
||||
## Las dos vías de envío
|
||||
|
||||
Un mismo formulario se firma por dos caminos, con tablas y tokens distintos:
|
||||
|
||||
| Vía | Tabla | Token | Respuestas |
|
||||
|---|---|---|---|
|
||||
| Turnero | `turnero_consentimientos` | UUID → `?token=` | `datos_respuestas` |
|
||||
| Domicilios y envíos | `lab_form_envios` | 64 hex → `?t=` | `datos_cliente` |
|
||||
|
||||
Las dos las muestra `ver_formulario_enviado.php`, que distingue **por el formato del token**. De ahí que existan dos parámetros para lo que parece lo mismo.
|
||||
|
||||
`form_cliente.php` detecta tokens con formato UUID y redirige a `ver_formulario_enviado.php` — necesario porque la plantilla de WhatsApp aprobada en Meta apunta a la primera página.
|
||||
|
||||
## Parámetros del visor
|
||||
|
||||
| Parámetro | Efecto |
|
||||
|---|---|
|
||||
| `token` | Consentimiento del turnero (UUID) |
|
||||
| `t` | Envío de formulario (64 hex) |
|
||||
| `id` | Acceso interno con sesión |
|
||||
| `embed=1` | Modo embebido; **activa el filtrado de secciones** |
|
||||
| `compact=1` | Grilla de campos y tarjetas de toma |
|
||||
| `zoom` | Escala |
|
||||
| `autoprint=1` | Abre el diálogo de impresión |
|
||||
|
||||
> `embed=1` no es cosmético: sin él no se aplica el filtrado de secciones de tomas prolongadas y el documento muestra secciones que no corresponden. Bandeja e historial lo pasan siempre.
|
||||
|
||||
## Firma
|
||||
|
||||
### Del paciente
|
||||
|
||||
Se dibuja en un canvas y se guarda como imagen en `datos_cliente['<campo>_svg']`. También se admite pad biométrico Topaz.
|
||||
|
||||
### Del profesional
|
||||
|
||||
Se dibuja igual, pero además queda **quién firmó**. Hay tres endpoints según el contexto:
|
||||
|
||||
| Endpoint | Contexto |
|
||||
|---|---|
|
||||
| `modules/turnero/api/guardar_toma.php` | Tomas prolongadas — una firma por toma |
|
||||
| `modules/turnero/api/firmar_profesional_consentimiento.php` | Consentimientos del turnero |
|
||||
| `api/lab/firmar_profesional.php` | Envíos de formularios |
|
||||
|
||||
La identidad se guarda como `_pro_nombre` y `_pro_cedula`; en tomas prolongadas, además por campo (`_tm30_f_pro_nombre`), porque cada toma puede firmarla alguien distinto.
|
||||
|
||||
De dónde sale la identidad:
|
||||
|
||||
```
|
||||
admin_users.cedula → personal en general
|
||||
lab_enfermeras.numero_documento → enfermeros (vía admin_users.enfermera_id)
|
||||
```
|
||||
|
||||
> Al mostrar un documento firmado **no se usa un valor por defecto**: si no quedó guardado quién firmó, se muestra vacío. Antes se caía al usuario de la sesión actual, lo que atribuía la firma a quien simplemente estaba mirando el documento.
|
||||
|
||||
## Precarga desde una visita anterior
|
||||
|
||||
`modules/turnero/api/get_formulario_anterior.php` devuelve las respuestas del último formulario firmado del mismo paciente, para no reescribir la historia clínica en cada visita.
|
||||
|
||||
Excluye deliberadamente firmas e identidad del profesional anterior: cada visita se firma de nuevo, con la fecha de hoy y quien atienda.
|
||||
|
||||
## Diseñador
|
||||
|
||||
`lab_formulario_builder.php` permite armar el esquema desde la interfaz, sin escribir JSON a mano.
|
||||
@@ -0,0 +1,96 @@
|
||||
# WhatsApp y bot
|
||||
|
||||
El sistema nació como bot de WhatsApp y esa integración sigue siendo central.
|
||||
|
||||
## Servicios
|
||||
|
||||
{{servicios}}
|
||||
|
||||
## `WhatsAppService`
|
||||
|
||||
Envuelve la Cloud API de Meta. Se elige la línea al construirlo:
|
||||
|
||||
```php
|
||||
$wa = new WhatsAppService(); // principal
|
||||
$wa = new WhatsAppService('turnero'); // turnero
|
||||
```
|
||||
|
||||
Ambas líneas comparten cuenta (WABA) y token; **lo único que cambia es el `phone_number_id`**. Si un mensaje sale por la línea equivocada, casi siempre falta el argumento.
|
||||
|
||||
Métodos principales:
|
||||
|
||||
```php
|
||||
$wa->sendTextMessage($telefono, $texto, $meta);
|
||||
$wa->sendTemplateMessage($telefono, $plantilla, $idioma, [], [], $componentes, $meta);
|
||||
```
|
||||
|
||||
`$meta` acompaña el registro del mensaje: `['canal' => 'turnero', 'operator_id' => adminId()]`.
|
||||
|
||||
## Plantillas
|
||||
|
||||
Fuera de la ventana de 24 horas hay que usar plantilla aprobada. `message_templates` guarda una copia local con sus `components`, pero **la copia no manda**: la versión real vive en Meta.
|
||||
|
||||
Consecuencia importante:
|
||||
|
||||
> Las URL de los botones están **en la plantilla**, no en el código. Nuestro código solo envía los parámetros (`{{1}}`). Cambiar el código no altera el enlace que recibe el paciente.
|
||||
|
||||
Ejemplo — botón de `consentimiento_turno_v2`:
|
||||
|
||||
```
|
||||
https://erp.laboratorioximenacaicedo.com/form_cliente.php?t={{1}}
|
||||
```
|
||||
|
||||
Y así se arman los componentes al enviar:
|
||||
|
||||
```php
|
||||
$rawComps = [
|
||||
['type' => 'body', 'parameters' => [['type' => 'text', 'text' => $codigo]]],
|
||||
['type' => 'button', 'sub_type' => 'url', 'index' => '0',
|
||||
'parameters' => [['type' => 'text', 'text' => $token]]],
|
||||
];
|
||||
```
|
||||
|
||||
### Respaldo
|
||||
|
||||
Si el envío por plantilla falla, se manda un texto plano con el enlace armado desde el dominio del servidor. Ese texto **no** pasa por Meta, así que su URL puede diferir de la del botón.
|
||||
|
||||
## Contenido configurable
|
||||
|
||||
Buena parte de lo que responde el bot vive en la base, no en el código: respuestas
|
||||
automáticas por palabra clave y menús interactivos. Ver
|
||||
[Menús y respuestas automáticas](?m=soporte&v=documentacion&s=tecnica&d=bot-menus-y-respuestas).
|
||||
|
||||
## `BotService`
|
||||
|
||||
Decide qué hacer con cada mensaje entrante:
|
||||
|
||||
1. ¿Aceptó los términos? Si no, se los pide y no avanza.
|
||||
2. ¿Está en horario? (`BusinessHoursService`)
|
||||
3. ¿La conversación la tomó un operador humano? El bot no interrumpe.
|
||||
4. Si no, responde según el estado de la conversación (`ConversationStateService`) y el menú (`MenuService`).
|
||||
|
||||
## Términos y condiciones
|
||||
|
||||
| Tabla | Contenido |
|
||||
|---|---|
|
||||
| `terms_versions` | Versión activa, URL del documento, mensajes |
|
||||
| `terms_acceptance` | Historial de aceptaciones |
|
||||
| `system_config.terms_message` | Texto de bienvenida, **repite la URL dentro** |
|
||||
|
||||
Se vuelve a pedir la aceptación si nunca aceptó, si pasaron más de 6 meses, o si hay versión nueva con `forzar_reenvio`.
|
||||
|
||||
> La URL del documento está en **dos** lugares. Cambiar solo uno deja al otro sirviendo un enlace viejo.
|
||||
|
||||
## Configuración
|
||||
|
||||
Todo en `system_config`: `whatsapp_token`, `whatsapp_api_url`, `whatsapp_business_account_id`, `whatsapp_phone_number_id`, `whatsapp_phone_number_id_turnero`, `webhook_verify_token`.
|
||||
|
||||
## Diagnóstico
|
||||
|
||||
| Síntoma | Dónde mirar |
|
||||
|---|---|
|
||||
| Sale por el número equivocado | Falta `'turnero'` en el constructor |
|
||||
| Enlace roto en un botón | La plantilla en Meta |
|
||||
| No llega ninguna plantilla | Estado de aprobación en WhatsApp Manager |
|
||||
| Llega texto plano en vez de plantilla | El respaldo actuó: la plantilla falló |
|
||||
| El bot no responde | `webhook_logs`, horario, estado de la conversación |
|
||||
@@ -0,0 +1,97 @@
|
||||
# Menús y respuestas automáticas del bot
|
||||
|
||||
Buena parte de lo que responde el bot **no está en el código**: es contenido configurable en la base. Cambiar lo que contesta no requiere tocar PHP ni desplegar.
|
||||
|
||||
## Respuestas automáticas
|
||||
|
||||
`autoresponses` — dispara una respuesta según lo que escriba el paciente.
|
||||
|
||||
| Columna | Para qué |
|
||||
|---|---|
|
||||
| `trigger_type` | Cómo se dispara: `welcome`, `keyword`, `contains` |
|
||||
| `trigger_value` | Las palabras que la activan, separadas por coma |
|
||||
| `response_text` | Lo que responde |
|
||||
| `response_type` | `text` o plantilla |
|
||||
| `template_name` | Plantilla de Meta, si aplica |
|
||||
| `menu_id` | Si en vez de texto debe mostrar un menú |
|
||||
| `priority` | Cuál gana si varias coinciden |
|
||||
| `is_active` | Permite desactivar sin borrar |
|
||||
|
||||
### Tipos de disparo
|
||||
|
||||
| Tipo | Cuándo actúa |
|
||||
|---|---|
|
||||
| `welcome` | Primer contacto |
|
||||
| `keyword` | El mensaje **es** una de las palabras listadas |
|
||||
| `contains` | El mensaje **contiene** el texto |
|
||||
|
||||
`keyword` es más estricto que `contains`. Para *«hola»* conviene `keyword` — si fuera `contains` se dispararía dentro de cualquier frase que la incluya.
|
||||
|
||||
### Las que están activas
|
||||
|
||||
Cubren saludo, precios, horarios, indicaciones previas, cotización y preparación para frotis. Son las preguntas que más se repiten, resueltas sin intervención humana.
|
||||
|
||||
## Menús interactivos
|
||||
|
||||
`menu_options` — el árbol de opciones numeradas que el paciente recorre respondiendo con un número.
|
||||
|
||||
| Columna | Para qué |
|
||||
|---|---|
|
||||
| `menu_id` | A qué menú pertenece la opción |
|
||||
| `option_number` | El número que marca el paciente |
|
||||
| `text` | Lo que se muestra |
|
||||
| `action_type` | Qué pasa al elegirla |
|
||||
| `action_value` | El destino o el texto de respuesta |
|
||||
| `is_active` | Permite ocultar sin borrar |
|
||||
|
||||
### Acciones
|
||||
|
||||
| `action_type` | Efecto |
|
||||
|---|---|
|
||||
| `menu` | Abre otro menú — `action_value` es su identificador |
|
||||
| `end` | Responde con `action_value` y cierra |
|
||||
| `message` | Envía el texto y sigue en el mismo menú |
|
||||
|
||||
### La estructura actual
|
||||
|
||||
```
|
||||
main_menu (1)
|
||||
├─ 1 Agendar toma a domicilio end
|
||||
├─ 2 Consultar resultados end
|
||||
├─ 3 Información sobre exámenes ─────► menu informacion_examenes (10)
|
||||
│ ├─ 1 Pruebas de embarazo end
|
||||
│ ├─ 2 Prueba de paternidad message
|
||||
│ ├─ 3 Pruebas de aliento end
|
||||
│ ├─ 4 Orina de 24 horas end
|
||||
│ ├─ 5 Solicitar cotización end
|
||||
│ └─ 6 Volver ──────────────────► main_menu
|
||||
├─ 4 Ubicación y horarios end
|
||||
├─ 5 Ver portafolio end
|
||||
├─ 6 Convenios end
|
||||
└─ 7 Salir end
|
||||
```
|
||||
|
||||
> Un menú se apunta por su **identificador** (`main_menu`, `informacion_examenes`), no por su número de fila. Cambiar el orden no rompe los enlaces.
|
||||
|
||||
## Estado de la conversación
|
||||
|
||||
`user_states` recuerda en qué punto quedó cada paciente: en qué menú está, si espera un dato, o si lo tomó un operador. Es lo que permite que responder «3» signifique algo.
|
||||
|
||||
Es la tabla más voluminosa del bot — una fila por contacto activo.
|
||||
|
||||
## Envíos masivos
|
||||
|
||||
`broadcast_history` registra los envíos a varios destinatarios a la vez.
|
||||
|
||||
> Fuera de la ventana de 24 horas hay que usar plantilla aprobada. Un envío masivo a contactos que no escribieron recientemente **solo puede hacerse con plantilla**.
|
||||
|
||||
## Encuestas
|
||||
|
||||
`survey_responses` guarda las respuestas de satisfacción. La encuesta se envía desde el historial del turnero con la plantilla `encuesta_turnero`.
|
||||
|
||||
## Al modificar el bot
|
||||
|
||||
- **Primero mire si alcanza con la base.** Muchos cambios de comportamiento son una fila en `autoresponses` o `menu_options`, sin desplegar nada.
|
||||
- **Use `is_active` en vez de borrar.** Permite volver atrás y conserva el historial.
|
||||
- **Cuide `priority`.** Si dos respuestas coinciden, gana la de mayor prioridad; sin ella el resultado depende del orden de la consulta.
|
||||
- **Verifique el árbol completo** después de tocar un menú: una opción que apunta a un menú inexistente deja al paciente sin salida.
|
||||
@@ -0,0 +1,65 @@
|
||||
# Domicilios
|
||||
|
||||
Visitas domiciliarias: agendamiento, asignación de enfermeros y seguimiento.
|
||||
|
||||
## Dónde está el código
|
||||
|
||||
Es de la generación anterior. El módulo es un puente:
|
||||
|
||||
```php
|
||||
// modules/lab_domicilios/views/index.php
|
||||
require_once APP_ROOT . '/lab_domicilios.php';
|
||||
```
|
||||
|
||||
| Archivo | Rol |
|
||||
|---|---|
|
||||
| `lab_domicilios.php` | Pantalla administrativa |
|
||||
| `enfermero_portal.php` | Portal del enfermero, pensado para celular |
|
||||
| `classes/lab/Domicilio.php` | Reglas de negocio |
|
||||
| `classes/lab/Asignacion.php` | Vínculo enfermero–domicilio |
|
||||
| `api/lab/*.php` | Endpoints |
|
||||
|
||||
## Datos
|
||||
|
||||
```
|
||||
lab_domicilios
|
||||
├── lab_asignaciones qué enfermero atiende
|
||||
├── lab_domicilio_notas seguimiento, admite adjuntos
|
||||
└── lab_domicilio_pagos cobros
|
||||
```
|
||||
|
||||
Estados: `programado` → `en_curso` → `completado`, o `cancelado`.
|
||||
|
||||
## Permisos
|
||||
|
||||
Se combinan dos niveles.
|
||||
|
||||
**Nivel módulo** — `hasModuleWrite('lab_domicilios')` decide si aparecen los botones de crear y editar. Un rol con `permission = 'read'` ve la pantalla sin poder modificar.
|
||||
|
||||
**Nivel registro** — un enfermero solo puede editar domicilios **que creó o que tiene asignados**. Se verifica en el servidor (`api/lab/save_domicilio.php`):
|
||||
|
||||
```sql
|
||||
SELECT d.creado_por,
|
||||
(SELECT COUNT(*) FROM lab_asignaciones a
|
||||
WHERE a.domicilio_id = d.id AND a.enfermera_id = ?) AS asignado
|
||||
FROM lab_domicilios d WHERE d.id = ?
|
||||
```
|
||||
|
||||
> No alcanza con ocultar el botón en la vista: el endpoint verifica por su cuenta. Cualquier endpoint que modifique datos debe hacer lo mismo.
|
||||
|
||||
## Portal del enfermero
|
||||
|
||||
`enfermero_portal.php` admite `admin`, `superadmin` y `enfermero`. Los administradores pueden ver el portal de un enfermero concreto pasando `?eid=<id>`, útil para dar soporte.
|
||||
|
||||
Incluye:
|
||||
|
||||
- Agenda propia con creación y edición
|
||||
- Notas de ficha y notas libres, con fotos y archivos
|
||||
- Envío de formularios al paciente, o copia del enlace
|
||||
- Enlace a WhatsApp con el mensaje ya redactado, presentando al profesional como parte del Laboratorio Ximena Caicedo
|
||||
|
||||
## Formularios
|
||||
|
||||
Los domicilios usan la vía `lab_form_envios` (token de 64 hex, parámetro `?t=`), a diferencia del turnero que usa UUID. Ver [Formularios](?m=soporte&v=documentacion&s=tecnica&d=formularios).
|
||||
|
||||
Cuando el enfermero firma, su nombre y cédula salen de `lab_enfermeras` a través de `admin_users.enfermera_id`.
|
||||
@@ -0,0 +1,54 @@
|
||||
# Registro de exámenes
|
||||
|
||||
Registro de exámenes realizados en sede: creación de órdenes, toma de muestras e ingreso de resultados. Es de los módulos nuevos — todo su código vive dentro de `modules/registro_exams/`.
|
||||
|
||||
## Vistas
|
||||
|
||||
| Vista | Qué hace |
|
||||
|---|---|
|
||||
| `lista` | Órdenes con filtros y estado |
|
||||
| `nueva_orden` | Alta de una orden |
|
||||
| `orden` | Detalle: ítems, estados e ingreso de resultados |
|
||||
| `etiqueta` | Etiqueta imprimible para rotular la muestra |
|
||||
|
||||
## Endpoints
|
||||
|
||||
| Endpoint | Qué hace |
|
||||
|---|---|
|
||||
| `get_ordenes.php` | Lista con filtros |
|
||||
| `get_orden.php` | Detalle de una orden |
|
||||
| `save_orden.php` | Crea o actualiza |
|
||||
| `save_resultado.php` | Guarda el resultado de un ítem |
|
||||
| `cambiar_estado_item.php` | Avanza el estado de un ítem individual |
|
||||
|
||||
Usan `api/_helpers.php` del módulo, con las mismas convenciones que el turnero.
|
||||
|
||||
## Flujo
|
||||
|
||||
```
|
||||
nueva_orden orden orden
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────┐ ┌───────────┐ ┌─────────────┐
|
||||
│ crear ├──────►│ muestra ├──────────►│ resultado │
|
||||
│ orden │ │ tomada │ │ ingresado │
|
||||
└─────────┘ └───────────┘ └─────────────┘
|
||||
│ │
|
||||
▼ ▼
|
||||
etiqueta se rotula
|
||||
imprimible la muestra
|
||||
```
|
||||
|
||||
El estado se lleva **por ítem**, no por orden completa: una orden puede tener unos exámenes resueltos y otros pendientes.
|
||||
|
||||
## Relación con otros módulos
|
||||
|
||||
| Módulo | Vínculo |
|
||||
|---|---|
|
||||
| `lab_examenes` | De ahí sale el catálogo y los valores de referencia |
|
||||
| `lab_pacientes` | El paciente de la orden |
|
||||
| `medicos` | El médico que la solicitó |
|
||||
|
||||
## Al tocarlo
|
||||
|
||||
Los resultados de laboratorio son información clínica: un ítem mal guardado o un valor de referencia equivocado tienen consecuencias reales. Cualquier cambio en `save_resultado.php` merece probarse con una orden de prueba antes de subirlo.
|
||||
@@ -0,0 +1,61 @@
|
||||
# Órdenes médicas, reportes y configuración
|
||||
|
||||
Tres módulos de la generación anterior: el código vive en archivos de la raíz y el módulo solo aporta el registro en el menú.
|
||||
|
||||
| Módulo | Archivo real |
|
||||
|---|---|
|
||||
| `lab_ordenes` | `lab_ordenes.php` |
|
||||
| `lab_reportes` | `lab_reportes.php` |
|
||||
| `lab_configuracion` | `lab_configuracion.php` |
|
||||
| `lab_dashboard` | `index.php` |
|
||||
|
||||
## Órdenes médicas
|
||||
|
||||
Las órdenes que el paciente envía por WhatsApp, normalmente como foto de la orden en papel. El módulo permite revisarlas y autorizarlas.
|
||||
|
||||
### Datos
|
||||
|
||||
`lab_ordenes_medicas` guarda tanto la imagen recibida como lo que el personal extrae de ella:
|
||||
|
||||
| Grupo | Columnas |
|
||||
|---|---|
|
||||
| Origen | `conversation_id`, `whatsapp_media_id`, `local_file` |
|
||||
| Vínculos | `paciente_id`, `domicilio_id` |
|
||||
| Contenido de la orden | `medico_nombre`, `medico_registro`, `fecha_orden`, `diagnostico`, `examenes_solicitados` |
|
||||
| Preparación | `requiere_ayuno`, `horas_ayuno`, `indicaciones` |
|
||||
| Trazabilidad | `revisada_por`, `revisada_at`, `autorizada_por`, `autorizada_at`, `comentario_revision` |
|
||||
|
||||
### Estados
|
||||
|
||||
```
|
||||
pendiente ──► autorizada
|
||||
│
|
||||
└──────► rechazada
|
||||
(con comentario)
|
||||
```
|
||||
|
||||
La orden llega en `pendiente`. Al revisarla se autoriza o se rechaza, y en ambos casos queda **quién** lo hizo y **cuándo** — es de los pocos flujos que registró trazabilidad desde el principio.
|
||||
|
||||
`requiere_ayuno` y `horas_ayuno` importan porque condicionan lo que se le indica al paciente antes de la toma.
|
||||
|
||||
## Reportes
|
||||
|
||||
`lab_reportes.php` — informes de ingresos, rendimiento y pagos.
|
||||
|
||||
Incluye el **tiempo de traslado del enfermero**, que sale de comparar las marcas de hora de las visitas domiciliarias. Es la métrica que permite ver si una ruta está mal armada.
|
||||
|
||||
> Para métricas del turnero (tiempos de atención, facturación del día) el lugar es el **dashboard del turnero**, no este módulo. Los dos existen y cubren cosas distintas: este mira el laboratorio en general y los domicilios; aquel mira la operación presencial del día.
|
||||
|
||||
## Configuración del laboratorio
|
||||
|
||||
`lab_configuracion.php` edita lo que aparece en el encabezado de todos los documentos generados: nombre, subtítulo, dirección, ciudad, teléfono, correo, logo y color.
|
||||
|
||||
Se guarda en `lab_config`, con las claves `empresa_*` y `doc_*`. Ver [Configuraciones críticas](?m=soporte&v=documentacion&s=operacion&d=configuraciones-criticas) para el detalle de qué se configura desde aquí y qué no.
|
||||
|
||||
> Desde aquí **no** se cambian las URL que llegan por WhatsApp en los botones de plantilla: esas viven en Meta.
|
||||
|
||||
## Dashboard
|
||||
|
||||
`index.php` es el panel principal del laboratorio y, a la vez, el punto de entrada histórico del sistema. Convive con `erp.php`, que es el del ERP modular.
|
||||
|
||||
Que el archivo de entrada original siga siendo una pantalla activa es una de las consecuencias de la [migración gradual](?m=soporte&v=documentacion&s=arquitectura&d=decisiones-y-deuda).
|
||||
@@ -1,4 +1,7 @@
|
||||
# Webhook WhatsApp — Endpoints y Características
|
||||
# Webhook de WhatsApp
|
||||
|
||||
_Migrado de `WEBHOOK_ENDPOINTS.md` (raíz del repositorio), donde vivía suelto._
|
||||
|
||||
|
||||
## Endpoint principal
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
# Catálogos del laboratorio
|
||||
|
||||
Los módulos que administran las tablas maestras. Se agrupan acá porque comparten la misma forma: una pantalla de listado con alta, edición y baja sobre una tabla.
|
||||
|
||||
## Los módulos
|
||||
|
||||
| Módulo | Tabla principal | Qué administra |
|
||||
|---|---|---|
|
||||
| `lab_examenes` | `exam_tipos` | Catálogo de exámenes, valores de referencia y tarifas |
|
||||
| `medicos` | `medicos` | Médicos solicitantes |
|
||||
| `lab_pacientes` | `lab_pacientes` | Fichas clínicas e historial |
|
||||
| `lab_enfermeras` | `lab_enfermeras` | Personal clínico |
|
||||
| `lab_eps` | `lab_eps` | EPS y aseguradoras |
|
||||
| `lab_empresas` | `lab_empresas` | Empresas, convenios, subgrupos y tarifas |
|
||||
| `lab_ciudades` | `lab_ciudades` | Ciudades de pacientes |
|
||||
| `usuarios` | `admin_users`, `roles` | Usuarios y asignación de roles |
|
||||
|
||||
## Los que tienen endpoints propios
|
||||
|
||||
La mayoría son puentes al sistema anterior. Dos tienen código propio:
|
||||
|
||||
### `lab_examenes`
|
||||
|
||||
| Endpoint | Qué hace |
|
||||
|---|---|
|
||||
| `list.php` / `get.php` | Listado y detalle |
|
||||
| `save.php` | Alta y edición del examen |
|
||||
| `save_item.php` | Ítems que componen un examen |
|
||||
| `get_tarifas.php` / `save_tarifa.php` | Tarifas por empresa o convenio |
|
||||
|
||||
Es el catálogo del que dependen el turnero y el registro de exámenes. Un examen mal configurado se propaga a todo lo demás: consentimientos que no se piden, tarifas que no se aplican.
|
||||
|
||||
**`exam_tipo_consentimientos`** vincula un examen con los formularios que obliga a firmar. Es una de las dos fuentes de consentimientos del turnero; la otra es la estación destino.
|
||||
|
||||
### `medicos`
|
||||
|
||||
`list.php`, `save.php`, `delete.php`. El médico se asocia a la solicitud del turnero y sale impreso en los documentos.
|
||||
|
||||
## Pacientes
|
||||
|
||||
`lab_pacientes` es el más consultado de todos: lo usan el turnero, domicilios, registro de exámenes y el bot.
|
||||
|
||||
Campos que otros módulos dan por sentados:
|
||||
|
||||
| Campo | Quién lo usa |
|
||||
|---|---|
|
||||
| `numero_documento` | Búsqueda en todas las pantallas |
|
||||
| `telefono` | Envío de consentimientos y encuestas |
|
||||
| `nombre_completo`, `tipo_documento` | Encabezado de todos los documentos |
|
||||
| `fecha_nacimiento` | Cálculo de edad en formularios |
|
||||
| `eps` | Facturación |
|
||||
|
||||
Un teléfono mal cargado se manifiesta lejos de donde se originó: como un consentimiento que nunca llegó.
|
||||
|
||||
## Usuarios
|
||||
|
||||
Ver [Roles y permisos](?m=soporte&v=documentacion&s=arquitectura&d=roles-y-permisos) para el detalle del control de acceso.
|
||||
|
||||
Lo esencial al crear o editar un usuario:
|
||||
|
||||
- **`role` y `role_id` deben cambiarse juntos.** La interfaz lee uno, los permisos salen del otro.
|
||||
- **Cargue la cédula.** Es lo que aparece bajo la firma en los formularios.
|
||||
- El usuario debe **volver a iniciar sesión** para que un cambio de permisos surta efecto.
|
||||
|
||||
## Al agregar un catálogo nuevo
|
||||
|
||||
Si es un ABM simple, siga el patrón de `medicos`: un `views/index.php` y tres endpoints (`list`, `save`, `delete`). Registralo en `SYSTEM_MODULES` y concédalo a los roles que corresponda, o nadie lo verá.
|
||||
@@ -0,0 +1,41 @@
|
||||
# Endpoints
|
||||
|
||||
Inventario de los endpoints de todos los módulos, generado del filesystem en cada carga. La descripción sale del comentario de cabecera de cada archivo.
|
||||
|
||||
## Convenciones
|
||||
|
||||
Los endpoints son archivos PHP sueltos que devuelven JSON. **No pasan por el enrutador**: se invocan por su ruta real.
|
||||
|
||||
```
|
||||
modules/turnero/api/get_historial.php
|
||||
api/lab/save_domicilio.php
|
||||
```
|
||||
|
||||
Cada módulo tiene su `api/_helpers.php` con lo común. Los archivos que empiezan con guión bajo son internos y no se listan acá.
|
||||
|
||||
### Helpers típicos
|
||||
|
||||
| Función | Qué hace |
|
||||
|---|---|
|
||||
| `db()` | Conexión PDO |
|
||||
| `inputJson()` | Cuerpo de la petición como arreglo |
|
||||
| `jsonOk($datos)` | Respuesta correcta |
|
||||
| `jsonError($msg, $codigo)` | Error con código HTTP |
|
||||
| `adminId()` | Id del usuario de la sesión |
|
||||
| `requireMethod('POST')` | Corta si el método no coincide |
|
||||
| `requireTurnero()` | Corta si no tiene acceso al turnero |
|
||||
|
||||
### Reglas al agregar uno
|
||||
|
||||
1. **Verifique permisos en el propio endpoint.** Que la vista haya ocultado el botón no protege nada.
|
||||
2. **Resuelva la identidad en el servidor.** Para saber quién hace una acción, use `adminId()`, no un valor que mande el navegador.
|
||||
3. **Consultas preparadas siempre.**
|
||||
4. **Deje un comentario de cabecera** describiendo qué hace y qué recibe: es lo que aparece en la tabla de abajo.
|
||||
|
||||
## Inventario
|
||||
|
||||
{{endpoints}}
|
||||
|
||||
## Endpoints fuera de módulos
|
||||
|
||||
`api/lab/` agrupa los del laboratorio de la generación anterior — domicilios, pacientes, formularios, configuración. Siguen las mismas convenciones y usan `api/lab/_helpers.php`.
|
||||
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
/**
|
||||
* Descriptor del módulo Soporte — documentación del proyecto.
|
||||
* El manual de usuario es visible para cualquier usuario autenticado; el resto
|
||||
* de secciones (técnica, arquitectura, operación) solo para administradores.
|
||||
*/
|
||||
|
||||
// Una sola entrada en el menú: la navegación por secciones y documentos vive
|
||||
// dentro de la propia página. Tener aquí un enlace por sección hacía que al
|
||||
// abrir una quedaran dos entradas marcadas como activas a la vez.
|
||||
$_sopLinks = [
|
||||
['name' => 'Documentación', 'icon' => 'fas fa-book', 'route' => '/erp.php?m=soporte&v=documentacion'],
|
||||
];
|
||||
|
||||
return [
|
||||
'slug' => 'soporte',
|
||||
'name' => 'Soporte',
|
||||
'icon' => 'fas fa-life-ring',
|
||||
'category' => 'sistema',
|
||||
'route' => '/erp.php?m=soporte&v=documentacion',
|
||||
'is_active' => true,
|
||||
'sort_order' => 90,
|
||||
'oleada' => 1,
|
||||
'description' => 'Documentación del proyecto: manual de usuario, técnica, arquitectura y operación',
|
||||
'links' => $_sopLinks,
|
||||
];
|
||||
@@ -0,0 +1,512 @@
|
||||
<?php
|
||||
/**
|
||||
* modules/soporte/views/documentacion.php
|
||||
* Visor de la documentación del proyecto.
|
||||
*
|
||||
* ?s=<seccion>&d=<documento> documento puntual
|
||||
* ?s=<seccion> portada de la sección
|
||||
* (sin parámetros) portada general
|
||||
*/
|
||||
require_once APP_ROOT . '/config/config.php';
|
||||
if (!isUserLoggedIn()) { header('Location: ' . BASE_URL . 'login.php'); exit; }
|
||||
|
||||
require_once __DIR__ . '/../Markdown.php';
|
||||
require_once __DIR__ . '/../DocIndex.php';
|
||||
require_once __DIR__ . '/../Generadores.php';
|
||||
|
||||
$arbol = DocIndex::arbol();
|
||||
$seccion = preg_replace('/[^a-z0-9-]/', '', $_GET['s'] ?? '');
|
||||
$docSlug = preg_replace('/[^a-z0-9-]/', '', $_GET['d'] ?? '');
|
||||
|
||||
$archivo = ($seccion && $docSlug) ? DocIndex::resolver($seccion, $docSlug) : null;
|
||||
$titulo = 'Documentación';
|
||||
$cuerpo = '';
|
||||
$toc = [];
|
||||
|
||||
if ($archivo) {
|
||||
[, $crudo] = DocIndex::leer($archivo); // descarta la cabecera de permisos
|
||||
$md = Generadores::expandir($crudo);
|
||||
$cuerpo = Markdown::render($md);
|
||||
$toc = Markdown::indice($md);
|
||||
foreach ($arbol[$seccion]['docs'] ?? [] as $d) {
|
||||
if ($d['slug'] === $docSlug) { $titulo = $d['titulo']; break; }
|
||||
}
|
||||
} elseif ($seccion && isset($arbol[$seccion])) {
|
||||
$titulo = $arbol[$seccion]['titulo'];
|
||||
}
|
||||
|
||||
Layout::open('Soporte · Documentación', 'fas fa-life-ring');
|
||||
?>
|
||||
<style>
|
||||
.doc-wrap { display:flex; gap:0; align-items:flex-start; min-height:calc(100vh - 60px); background:#fff; }
|
||||
|
||||
/* ── Índice lateral ── */
|
||||
.doc-nav { width:280px; flex-shrink:0; border-right:1px solid #e2e8f0; background:#f8fafc;
|
||||
align-self:stretch; padding:18px 0 60px; position:sticky; top:0; max-height:100vh; overflow-y:auto; }
|
||||
.doc-nav .buscador { padding:0 16px 14px; }
|
||||
.doc-nav .buscador input { width:100%; font-size:.84rem; padding:7px 11px; border:1px solid #cbd5e1;
|
||||
border-radius:8px; background:#fff; }
|
||||
.doc-nav .buscador input:focus { outline:none; border-color:#2563eb; box-shadow:0 0 0 3px rgba(37,99,235,.12); }
|
||||
.doc-nav .btn-pdf { display:block; margin-top:8px; text-align:center; font-size:.78rem;
|
||||
padding:6px 10px; border:1px solid #cbd5e1; border-radius:8px;
|
||||
color:#475569; text-decoration:none; background:#fff; }
|
||||
.doc-nav .btn-pdf:hover { border-color:#2563eb; color:#1d4ed8; }
|
||||
.doc-nav-sec { padding:10px 16px 4px; font-size:.68rem; font-weight:800; text-transform:uppercase;
|
||||
letter-spacing:.08em; color:#64748b; display:flex; align-items:center; gap:6px; }
|
||||
.doc-nav a { display:block; padding:5px 16px 5px 30px; font-size:.83rem; color:#334155;
|
||||
text-decoration:none; border-left:2px solid transparent; }
|
||||
.doc-nav a:hover { background:#eef2f7; color:#1e293b; }
|
||||
.doc-nav a.activo { background:#e0edff; color:#1d4ed8; font-weight:600; border-left-color:#2563eb; }
|
||||
|
||||
/* ── Resultados de búsqueda ── */
|
||||
#resultados { padding:0 16px; }
|
||||
#resultados .r { display:block; padding:8px 10px; border-radius:8px; text-decoration:none;
|
||||
margin-bottom:4px; background:#fff; border:1px solid #e2e8f0; }
|
||||
#resultados .r:hover { border-color:#2563eb; }
|
||||
#resultados .r-t { font-size:.83rem; font-weight:600; color:#1e293b; }
|
||||
#resultados .r-c { font-size:.68rem; color:#64748b; text-transform:uppercase; letter-spacing:.05em; }
|
||||
#resultados .r-x { font-size:.75rem; color:#475569; margin-top:2px; }
|
||||
#resultados .vacio { font-size:.8rem; color:#94a3b8; padding:8px 4px; }
|
||||
|
||||
/* ── Contenido ── */
|
||||
.doc-main { flex:1; min-width:0; display:flex; gap:0; }
|
||||
.doc-body { flex:1; min-width:0; padding:26px 40px 80px; max-width:900px; }
|
||||
.doc-ruta { font-size:.73rem; color:#94a3b8; margin-bottom:14px; }
|
||||
.doc-ruta a { color:#64748b; text-decoration:none; }
|
||||
.doc-ruta a:hover { text-decoration:underline; }
|
||||
|
||||
.doc-body h1 { font-size:1.65rem; font-weight:800; color:#0f172a; margin:0 0 18px;
|
||||
padding-bottom:12px; border-bottom:1px solid #e2e8f0; }
|
||||
.doc-body h2 { font-size:1.2rem; font-weight:700; color:#1e293b; margin:32px 0 12px;
|
||||
padding-top:6px; scroll-margin-top:20px; }
|
||||
.doc-body h3 { font-size:1rem; font-weight:700; color:#334155; margin:24px 0 9px; scroll-margin-top:20px; }
|
||||
.doc-body h4 { font-size:.9rem; font-weight:700; color:#475569; margin:18px 0 7px; }
|
||||
.doc-body p { font-size:.9rem; line-height:1.7; color:#334155; margin:0 0 13px; }
|
||||
.doc-body ul, .doc-body ol { font-size:.9rem; line-height:1.7; color:#334155; margin:0 0 13px; padding-left:24px; }
|
||||
.doc-body li { margin-bottom:4px; }
|
||||
.doc-body li > ul, .doc-body li > ol { margin:4px 0 2px; }
|
||||
.doc-body a { color:#2563eb; }
|
||||
.doc-body strong { color:#0f172a; }
|
||||
.doc-body code { background:#f1f5f9; color:#be185d; padding:1px 5px; border-radius:4px;
|
||||
font-size:.83em; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; }
|
||||
.doc-body pre { background:#0f172a; color:#e2e8f0; padding:14px 16px; border-radius:10px;
|
||||
overflow-x:auto; margin:0 0 15px; }
|
||||
.doc-body pre code { background:none; color:inherit; padding:0; font-size:.8rem; line-height:1.6; }
|
||||
.doc-body blockquote { border-left:3px solid #f59e0b; background:#fffbeb; margin:0 0 15px;
|
||||
padding:10px 16px; border-radius:0 8px 8px 0; }
|
||||
.doc-body blockquote p { margin:0; color:#78350f; font-size:.86rem; }
|
||||
.doc-body hr { border:none; border-top:1px solid #e2e8f0; margin:26px 0; }
|
||||
.tabla-scroll { overflow-x:auto; margin:0 0 16px; }
|
||||
.doc-body table { width:100%; border-collapse:collapse; font-size:.82rem; }
|
||||
.doc-body thead th { background:#f8fafc; text-align:left; font-weight:700; color:#475569;
|
||||
padding:8px 11px; border-bottom:2px solid #e2e8f0; white-space:nowrap; }
|
||||
.doc-body tbody td { padding:7px 11px; border-bottom:1px solid #f1f5f9; color:#334155; vertical-align:top; }
|
||||
.doc-body tbody tr:hover { background:#f8fafc; }
|
||||
|
||||
/* ── Tabla de contenidos ── */
|
||||
.doc-toc { width:210px; flex-shrink:0; padding:32px 20px 60px; position:sticky; top:0;
|
||||
max-height:100vh; overflow-y:auto; }
|
||||
.doc-toc-t { font-size:.66rem; font-weight:800; text-transform:uppercase; letter-spacing:.08em;
|
||||
color:#94a3b8; margin-bottom:9px; }
|
||||
.doc-toc a { display:block; font-size:.76rem; color:#64748b; text-decoration:none;
|
||||
padding:3px 0 3px 9px; border-left:2px solid #e2e8f0; line-height:1.4; }
|
||||
.doc-toc a:hover { color:#2563eb; border-left-color:#93c5fd; }
|
||||
.doc-toc a.n3 { padding-left:20px; font-size:.73rem; }
|
||||
|
||||
/* ── Portadas ── */
|
||||
.doc-hero { margin-bottom:26px; }
|
||||
.doc-hero h1 { font-size:1.7rem; font-weight:800; color:#0f172a; margin:0 0 8px; }
|
||||
.doc-hero p { font-size:.92rem; color:#64748b; margin:0; }
|
||||
.tarjetas { display:grid; grid-template-columns:repeat(auto-fill,minmax(260px,1fr)); gap:14px; }
|
||||
.tarjeta { display:block; padding:18px; border:1px solid #e2e8f0; border-radius:12px;
|
||||
text-decoration:none; background:#fff; transition:border-color .15s, transform .15s; }
|
||||
.tarjeta:hover { border-color:#2563eb; transform:translateY(-2px); }
|
||||
.tarjeta i { font-size:1.3rem; color:#2563eb; }
|
||||
.tarjeta .t { font-size:1rem; font-weight:700; color:#1e293b; margin:9px 0 5px; }
|
||||
.tarjeta .d { font-size:.82rem; color:#64748b; line-height:1.5; }
|
||||
.tarjeta .n { font-size:.72rem; color:#94a3b8; margin-top:9px; }
|
||||
|
||||
.lista-docs { list-style:none; padding:0; margin:0; }
|
||||
.lista-docs li { border-bottom:1px solid #f1f5f9; }
|
||||
.lista-docs a { display:block; padding:12px 4px; text-decoration:none; color:#1e293b; font-size:.92rem; }
|
||||
.lista-docs a:hover { color:#2563eb; }
|
||||
|
||||
@media (max-width:1100px) { .doc-toc { display:none; } }
|
||||
@media (max-width:820px) {
|
||||
.doc-nav { position:static; width:100%; max-height:none; border-right:none;
|
||||
border-bottom:1px solid #e2e8f0; }
|
||||
.doc-wrap { flex-direction:column; }
|
||||
.doc-body { padding:20px 18px 60px; }
|
||||
}
|
||||
@media print {
|
||||
.doc-nav, .doc-toc, .doc-ruta, #btnAI, #aiPanel { display:none !important; }
|
||||
.doc-body { max-width:none; padding:0; }
|
||||
.doc-body pre { background:#f8fafc; color:#0f172a; border:1px solid #cbd5e1; }
|
||||
}
|
||||
|
||||
/* ── Panel LIA — mismo diseño que el dashboard del turnero ── */
|
||||
#aiPanel {
|
||||
position:fixed; bottom:0; right:24px; width:400px; z-index:1080;
|
||||
box-shadow:0 -8px 40px rgba(0,0,0,.22); border-radius:20px 20px 0 0;
|
||||
background:#fff; border:1px solid #e2e8f0; border-bottom:none;
|
||||
transform:translateY(100%); transition:transform .35s cubic-bezier(.4,0,.2,1);
|
||||
display:flex; flex-direction:column; max-height:580px;
|
||||
}
|
||||
#aiPanel.open { transform:translateY(0); }
|
||||
#aiPanel.minimized .ai-body { display:none; }
|
||||
#aiPanel.minimized { max-height:none; }
|
||||
.ai-header {
|
||||
background:linear-gradient(135deg,#0d47a1,#1565c0,#0288d1);
|
||||
color:#fff; padding:14px 18px; border-radius:20px 20px 0 0;
|
||||
display:flex; align-items:center; gap:12px; flex-shrink:0; cursor:pointer; user-select:none;
|
||||
}
|
||||
.lia-logo {
|
||||
width:44px; height:44px; border-radius:50%;
|
||||
background:rgba(255,255,255,.15); border:2px solid rgba(255,255,255,.4);
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
font-size:1rem; font-weight:900; letter-spacing:-1px; flex-shrink:0;
|
||||
transition:transform .3s;
|
||||
}
|
||||
.lia-logo:hover { transform:scale(1.08); }
|
||||
.lia-logo .lia-i { color:#7dd3fc; }
|
||||
.ai-title { font-weight:800; font-size:1.05rem; letter-spacing:.5px; }
|
||||
.btn-tts { background:rgba(255,255,255,.15); border:1px solid rgba(255,255,255,.3);
|
||||
color:rgba(255,255,255,.7); cursor:pointer; padding:5px 9px; border-radius:8px;
|
||||
font-size:.88rem; transition:all .2s; }
|
||||
.btn-tts:hover { background:rgba(255,255,255,.25); color:#fff; }
|
||||
.ai-header .btn-close { filter:invert(1) brightness(2); opacity:.7; }
|
||||
.ai-header .btn-close:hover { opacity:1; }
|
||||
.ai-body { display:flex; flex-direction:column; flex:1; overflow:hidden; }
|
||||
.ai-quick { display:flex; gap:7px; flex-wrap:wrap; padding:10px 14px;
|
||||
border-bottom:1px solid #f1f5f9; flex-shrink:0; background:#fafbff; }
|
||||
.ai-chip { background:#fff; border:1.5px solid #e2e8f0; border-radius:99px; padding:6px 13px;
|
||||
font-size:.82rem; cursor:pointer; color:#334155; white-space:nowrap;
|
||||
transition:all .15s; font-weight:500; }
|
||||
.ai-chip:hover { background:#eff6ff; border-color:#93c5fd; color:#1d4ed8; }
|
||||
.ai-messages { flex:1; overflow-y:auto; padding:14px 16px; display:flex; flex-direction:column; gap:10px; }
|
||||
.ai-msg { max-width:92%; padding:10px 14px; border-radius:16px; font-size:.9rem;
|
||||
line-height:1.55; animation:msg-in .2s ease; word-wrap:break-word; }
|
||||
@keyframes msg-in { from{opacity:0;transform:translateY(6px)} to{opacity:1;transform:none} }
|
||||
.ai-msg.user { background:linear-gradient(135deg,#1565c0,#0288d1); color:#fff;
|
||||
align-self:flex-end; border-radius:16px 16px 2px 16px; }
|
||||
.ai-msg.bot { background:#f1f5f9; color:#1e293b; align-self:flex-start; border-radius:16px 16px 16px 2px; }
|
||||
.ai-msg.bot.typing::after { content:'●●●'; animation:dots 1.2s infinite; letter-spacing:2px; }
|
||||
@keyframes dots { 0%,100%{opacity:.3} 50%{opacity:1} }
|
||||
.ai-msg.bot strong { color:#1565c0; }
|
||||
.ai-msg.bot ul, .ai-msg.bot ol { margin:4px 0 4px 16px; padding:0; }
|
||||
.ai-msg.bot code { background:#e2e8f0; color:#be185d; padding:1px 4px; border-radius:3px; font-size:.9em; }
|
||||
.ai-msg.bot pre { background:#0f172a; color:#e2e8f0; padding:9px 11px; border-radius:8px;
|
||||
overflow-x:auto; font-size:.76rem; margin:6px 0; }
|
||||
.ai-fuentes { font-size:.68rem; color:#94a3b8; align-self:flex-start; margin:-6px 0 0 4px; }
|
||||
.ai-footer { padding:10px 14px; border-top:1px solid #f1f5f9; display:flex; gap:8px;
|
||||
flex-shrink:0; background:#fafbff; }
|
||||
.ai-footer input { flex:1; border:1.5px solid #e2e8f0; border-radius:10px; padding:9px 14px;
|
||||
font-size:.9rem; outline:none; background:#fff; }
|
||||
.ai-footer input:focus { border-color:#1565c0; }
|
||||
.ai-footer .btn-send { background:linear-gradient(135deg,#1565c0,#0288d1); color:#fff; border:none;
|
||||
border-radius:10px; padding:9px 16px; cursor:pointer; font-size:.88rem; min-width:42px; }
|
||||
.ai-footer .btn-send:disabled { opacity:.5; cursor:default; }
|
||||
#btnAI {
|
||||
position:fixed; bottom:24px; right:24px; z-index:1079;
|
||||
background:linear-gradient(135deg,#0d47a1,#1565c0,#0288d1);
|
||||
color:#fff; border:none; border-radius:50%; width:58px; height:58px;
|
||||
font-size:.78rem; font-weight:900; letter-spacing:-.5px;
|
||||
cursor:pointer; box-shadow:0 4px 20px rgba(21,101,192,.45);
|
||||
transition:transform .2s,box-shadow .2s;
|
||||
display:flex; align-items:center; justify-content:center;
|
||||
}
|
||||
#btnAI:hover { transform:scale(1.1); box-shadow:0 6px 28px rgba(21,101,192,.55); }
|
||||
#btnAI.hidden { display:none; }
|
||||
@media(max-width:600px) { #aiPanel { width:100%; right:0; border-radius:16px 16px 0 0; } }
|
||||
</style>
|
||||
|
||||
<div class="doc-wrap">
|
||||
<!-- ── Índice lateral ── -->
|
||||
<nav class="doc-nav">
|
||||
<div class="buscador">
|
||||
<input type="search" id="q" placeholder="Buscar en la documentación…" autocomplete="off">
|
||||
<a class="btn-pdf" href="<?= BASE_URL ?>erp.php?m=soporte&v=imprimir" target="_blank">
|
||||
<i class="fas fa-file-pdf"></i> Exportar a PDF
|
||||
</a>
|
||||
</div>
|
||||
<div id="resultados" style="display:none"></div>
|
||||
<div id="arbol">
|
||||
<?php foreach ($arbol as $sec => $cfg): ?>
|
||||
<div class="doc-nav-sec">
|
||||
<i class="<?= htmlspecialchars($cfg['icono']) ?>"></i>
|
||||
<a href="<?= BASE_URL ?>erp.php?m=soporte&v=documentacion&s=<?= urlencode($sec) ?>"
|
||||
style="padding:0;border:none;color:inherit;font:inherit;letter-spacing:inherit">
|
||||
<?= htmlspecialchars($cfg['titulo']) ?>
|
||||
</a>
|
||||
</div>
|
||||
<?php foreach ($cfg['docs'] as $d): ?>
|
||||
<a href="<?= BASE_URL ?>erp.php?m=soporte&v=documentacion&s=<?= urlencode($sec) ?>&d=<?= urlencode($d['slug']) ?>"
|
||||
class="<?= ($sec === $seccion && $d['slug'] === $docSlug) ? 'activo' : '' ?>">
|
||||
<?= htmlspecialchars($d['titulo']) ?>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<div class="doc-main">
|
||||
<div class="doc-body">
|
||||
<?php if ($archivo): ?>
|
||||
<div class="doc-ruta">
|
||||
<a href="<?= BASE_URL ?>erp.php?m=soporte&v=documentacion">Documentación</a>
|
||||
›
|
||||
<a href="<?= BASE_URL ?>erp.php?m=soporte&v=documentacion&s=<?= urlencode($seccion) ?>">
|
||||
<?= htmlspecialchars($arbol[$seccion]['titulo'] ?? $seccion) ?>
|
||||
</a>
|
||||
› <?= htmlspecialchars($titulo) ?>
|
||||
</div>
|
||||
<?= $cuerpo ?>
|
||||
|
||||
<?php elseif ($seccion && isset($arbol[$seccion])): ?>
|
||||
<div class="doc-ruta">
|
||||
<a href="<?= BASE_URL ?>erp.php?m=soporte&v=documentacion">Documentación</a>
|
||||
› <?= htmlspecialchars($arbol[$seccion]['titulo']) ?>
|
||||
</div>
|
||||
<div class="doc-hero">
|
||||
<h1><?= htmlspecialchars($arbol[$seccion]['titulo']) ?></h1>
|
||||
<p><?= htmlspecialchars($arbol[$seccion]['resumen']) ?></p>
|
||||
</div>
|
||||
<?php if ($arbol[$seccion]['docs']): ?>
|
||||
<ul class="lista-docs">
|
||||
<?php foreach ($arbol[$seccion]['docs'] as $d): ?>
|
||||
<li><a href="<?= BASE_URL ?>erp.php?m=soporte&v=documentacion&s=<?= urlencode($seccion) ?>&d=<?= urlencode($d['slug']) ?>">
|
||||
<?= htmlspecialchars($d['titulo']) ?>
|
||||
</a></li>
|
||||
<?php endforeach; ?>
|
||||
</ul>
|
||||
<?php else: ?>
|
||||
<p class="text-muted">Esta sección aún no tiene documentos.</p>
|
||||
<?php endif; ?>
|
||||
|
||||
<?php else: ?>
|
||||
<div class="doc-hero">
|
||||
<h1>Documentación del sistema</h1>
|
||||
<p>Todo sobre cómo funciona este ERP: cómo se usa, cómo está construido y qué hacer cuando algo falla.</p>
|
||||
</div>
|
||||
<div class="tarjetas">
|
||||
<?php foreach ($arbol as $sec => $cfg): ?>
|
||||
<a class="tarjeta" href="<?= BASE_URL ?>erp.php?m=soporte&v=documentacion&s=<?= urlencode($sec) ?>">
|
||||
<i class="<?= htmlspecialchars($cfg['icono']) ?>"></i>
|
||||
<div class="t"><?= htmlspecialchars($cfg['titulo']) ?></div>
|
||||
<div class="d"><?= htmlspecialchars($cfg['resumen']) ?></div>
|
||||
<div class="n"><?= count($cfg['docs']) ?> documento<?= count($cfg['docs']) === 1 ? '' : 's' ?></div>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
|
||||
<?php if ($toc): ?>
|
||||
<aside class="doc-toc">
|
||||
<div class="doc-toc-t">En esta página</div>
|
||||
<?php foreach ($toc as $t): ?>
|
||||
<a href="#<?= htmlspecialchars($t['slug']) ?>" class="<?= $t['nivel'] === 3 ? 'n3' : '' ?>">
|
||||
<?= htmlspecialchars($t['texto']) ?>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</aside>
|
||||
<?php endif; ?>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Panel LIA — asistente sobre la documentación ── -->
|
||||
<button id="btnAI" onclick="toggleAI()" title="Preguntar a LIA">LIA</button>
|
||||
<div id="aiPanel">
|
||||
<div class="ai-header" onclick="minimizarIA(event)">
|
||||
<div class="lia-logo" id="liaLogo">
|
||||
<span>L</span><span class="lia-i">I</span><span>A</span>
|
||||
</div>
|
||||
<div style="flex:1;line-height:1.1">
|
||||
<div class="ai-title">LIA</div>
|
||||
<div style="font-size:.68rem;opacity:.75;font-weight:400">Asistente de Documentación</div>
|
||||
</div>
|
||||
<button class="btn-tts" onclick="event.stopPropagation();minimizarIA(event)" title="Minimizar" id="btnMin">
|
||||
<i class="fas fa-minus" id="iconMin"></i>
|
||||
</button>
|
||||
<button class="btn-close" onclick="event.stopPropagation();toggleAI()"></button>
|
||||
</div>
|
||||
<div class="ai-body" id="aiBody">
|
||||
<div class="ai-quick" id="aiQuick"></div>
|
||||
<div class="ai-messages" id="aiMessages"></div>
|
||||
<div class="ai-footer">
|
||||
<input type="text" id="aiInput" placeholder="Pregunta cómo hacer algo…" maxlength="400"
|
||||
onkeydown="if(event.key==='Enter')enviarIA()">
|
||||
<button class="btn-send" onclick="enviarIA()"><i class="fas fa-paper-plane"></i></button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
/* ── Buscador: índice completo servido con la página ── */
|
||||
const DOCS = <?= json_encode(DocIndex::indiceBusqueda(), JSON_UNESCAPED_UNICODE) ?>;
|
||||
const BASEQ = '<?= BASE_URL ?>erp.php?m=soporte&v=documentacion';
|
||||
|
||||
const norm = s => (s || '').toLowerCase()
|
||||
.normalize('NFD').replace(/[̀-ͯ]/g, '');
|
||||
|
||||
function buscar(termino) {
|
||||
const q = norm(termino).trim();
|
||||
const cajaR = document.getElementById('resultados');
|
||||
const cajaA = document.getElementById('arbol');
|
||||
if (q.length < 2) { cajaR.style.display = 'none'; cajaA.style.display = ''; return; }
|
||||
|
||||
const palabras = q.split(/\s+/);
|
||||
const hits = [];
|
||||
DOCS.forEach(d => {
|
||||
const heno = norm(d.t + ' ' + d.c + ' ' + d.x);
|
||||
if (!palabras.every(p => heno.includes(p))) return;
|
||||
// El título pesa más que el cuerpo
|
||||
const enTitulo = palabras.every(p => norm(d.t).includes(p));
|
||||
const pos = heno.indexOf(palabras[0]);
|
||||
hits.push({ d, score: (enTitulo ? 0 : 1000) + pos, pos });
|
||||
});
|
||||
hits.sort((a, b) => a.score - b.score);
|
||||
|
||||
cajaA.style.display = 'none';
|
||||
cajaR.style.display = '';
|
||||
if (!hits.length) {
|
||||
cajaR.innerHTML = '<div class="vacio">Sin resultados para «' + esc(termino) + '»</div>';
|
||||
return;
|
||||
}
|
||||
cajaR.innerHTML = hits.slice(0, 20).map(h => {
|
||||
const ini = Math.max(0, h.pos - 40);
|
||||
const frag = h.d.x.substr(ini, 120).trim();
|
||||
return `<a class="r" href="${BASEQ}&s=${encodeURIComponent(h.d.s)}&d=${encodeURIComponent(h.d.u)}">
|
||||
<div class="r-c">${esc(h.d.c)}</div>
|
||||
<div class="r-t">${esc(h.d.t)}</div>
|
||||
<div class="r-x">${ini > 0 ? '…' : ''}${esc(frag)}…</div>
|
||||
</a>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function esc(s) {
|
||||
const d = document.createElement('div');
|
||||
d.textContent = s == null ? '' : String(s);
|
||||
return d.innerHTML;
|
||||
}
|
||||
|
||||
/* ── LIA: asistente sobre la documentación ──
|
||||
Mismo comportamiento y nombres que el panel del dashboard del turnero. */
|
||||
const AI_API = '<?= BASE_URL ?>modules/soporte/api/ai_docs.php';
|
||||
let _liaHistorial = [];
|
||||
|
||||
function toggleAI() {
|
||||
const panel = document.getElementById('aiPanel');
|
||||
const fab = document.getElementById('btnAI');
|
||||
const abre = !panel.classList.contains('open');
|
||||
panel.classList.toggle('open', abre);
|
||||
panel.classList.remove('minimized');
|
||||
fab.classList.toggle('hidden', abre);
|
||||
if (abre) {
|
||||
if (!document.getElementById('aiMessages').children.length) saludarIA();
|
||||
document.getElementById('aiInput').focus();
|
||||
}
|
||||
}
|
||||
|
||||
function minimizarIA(e) {
|
||||
if (e) e.stopPropagation();
|
||||
const panel = document.getElementById('aiPanel');
|
||||
panel.classList.toggle('minimized');
|
||||
const icono = document.getElementById('iconMin');
|
||||
icono.className = panel.classList.contains('minimized') ? 'fas fa-chevron-up' : 'fas fa-minus';
|
||||
}
|
||||
|
||||
function saludarIA() {
|
||||
agregarMsg('bot', 'Hola, soy LIA. Pregúnteme cómo hacer algo en el sistema y le respondo con la documentación.');
|
||||
// Atajos armados con los documentos que este usuario realmente puede ver
|
||||
const quick = document.getElementById('aiQuick');
|
||||
quick.innerHTML = DOCS.slice(0, 4)
|
||||
.map(d => `<button class="ai-chip" onclick="enviarRapido('¿Qué explica ${esc(d.t)}?')">${esc(d.t)}</button>`)
|
||||
.join('');
|
||||
}
|
||||
|
||||
function enviarRapido(texto) {
|
||||
document.getElementById('aiInput').value = texto;
|
||||
enviarIA();
|
||||
}
|
||||
|
||||
function agregarMsg(tipo, html) {
|
||||
const msgs = document.getElementById('aiMessages');
|
||||
const div = document.createElement('div');
|
||||
div.className = 'ai-msg ' + tipo;
|
||||
div.innerHTML = html;
|
||||
msgs.appendChild(div);
|
||||
msgs.scrollTop = msgs.scrollHeight;
|
||||
return div;
|
||||
}
|
||||
|
||||
/* Formato mínimo de la respuesta: negritas, código, listas y saltos. */
|
||||
function mdToHtml(md) {
|
||||
let h = esc(md);
|
||||
h = h.replace(/```[\s\S]*?```/g, m => '<pre>' + m.replace(/```\w*\n?/g, '') + '</pre>');
|
||||
h = h.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||
h = h.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>');
|
||||
h = h.replace(/^\s*[-*]\s+(.*)$/gm, '<li>$1</li>');
|
||||
h = h.replace(/(<li>[\s\S]*?<\/li>)/g, '<ul>$1</ul>');
|
||||
return h.replace(/\n{2,}/g, '<br><br>').replace(/\n/g, '<br>');
|
||||
}
|
||||
|
||||
async function enviarIA() {
|
||||
const input = document.getElementById('aiInput');
|
||||
const pregunta = input.value.trim();
|
||||
if (!pregunta) return;
|
||||
|
||||
document.getElementById('aiQuick').innerHTML = '';
|
||||
agregarMsg('user', esc(pregunta));
|
||||
|
||||
const typing = agregarMsg('bot typing', '');
|
||||
input.value = '';
|
||||
input.disabled = true;
|
||||
|
||||
try {
|
||||
const res = await fetch(AI_API, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ pregunta, historial: _liaHistorial }),
|
||||
});
|
||||
const d = await res.json();
|
||||
typing.className = 'ai-msg bot';
|
||||
|
||||
if (d.ok) {
|
||||
typing.innerHTML = mdToHtml(d.respuesta);
|
||||
if (d.fuentes && d.fuentes.length) {
|
||||
const f = document.createElement('div');
|
||||
f.className = 'ai-fuentes';
|
||||
f.textContent = 'Según: ' + d.fuentes.join(' · ');
|
||||
document.getElementById('aiMessages').appendChild(f);
|
||||
}
|
||||
_liaHistorial.push({ rol: 'user', texto: pregunta }, { rol: 'model', texto: d.respuesta });
|
||||
if (_liaHistorial.length > 6) _liaHistorial = _liaHistorial.slice(-6);
|
||||
} else {
|
||||
typing.textContent = d.error || 'No pude responder.';
|
||||
}
|
||||
} catch (_) {
|
||||
typing.className = 'ai-msg bot';
|
||||
typing.textContent = 'Error de conexión';
|
||||
} finally {
|
||||
input.disabled = false;
|
||||
input.focus();
|
||||
const msgs = document.getElementById('aiMessages');
|
||||
msgs.scrollTop = msgs.scrollHeight;
|
||||
}
|
||||
}
|
||||
|
||||
const inputQ = document.getElementById('q');
|
||||
let _t = null;
|
||||
inputQ.addEventListener('input', () => {
|
||||
clearTimeout(_t);
|
||||
_t = setTimeout(() => buscar(inputQ.value), 120);
|
||||
});
|
||||
// Atajo: "/" enfoca el buscador
|
||||
document.addEventListener('keydown', e => {
|
||||
if (e.key === '/' && document.activeElement !== inputQ) { e.preventDefault(); inputQ.focus(); }
|
||||
if (e.key === 'Escape' && document.activeElement === inputQ) { inputQ.value = ''; buscar(''); inputQ.blur(); }
|
||||
});
|
||||
</script>
|
||||
|
||||
<?php Layout::close(); ?>
|
||||
@@ -0,0 +1,270 @@
|
||||
<?php
|
||||
/**
|
||||
* modules/soporte/views/imprimir.php
|
||||
* Documentación completa en un solo documento, maquetado para imprimir a PDF.
|
||||
*
|
||||
* ?s=<seccion> limita la exportación a una sección
|
||||
* (sin parámetro) exporta todo lo que el usuario puede ver
|
||||
*
|
||||
* Se sirve sin el layout del ERP: la idea es que la página sea el entregable.
|
||||
*/
|
||||
require_once APP_ROOT . '/config/config.php';
|
||||
if (!isUserLoggedIn()) { header('Location: ' . BASE_URL . 'login.php'); exit; }
|
||||
|
||||
require_once __DIR__ . '/../Markdown.php';
|
||||
require_once __DIR__ . '/../DocIndex.php';
|
||||
require_once __DIR__ . '/../Generadores.php';
|
||||
|
||||
$arbol = DocIndex::arbol();
|
||||
|
||||
// Filtro opcional por sección
|
||||
$soloSeccion = preg_replace('/[^a-z0-9-]/', '', $_GET['s'] ?? '');
|
||||
if ($soloSeccion && isset($arbol[$soloSeccion])) {
|
||||
$arbol = [$soloSeccion => $arbol[$soloSeccion]];
|
||||
}
|
||||
|
||||
// Datos del laboratorio para la portada
|
||||
$emp = ['empresa_nombre' => 'Laboratorio Clínico', 'empresa_subtitulo' => '', 'doc_logo_base64' => ''];
|
||||
try {
|
||||
$rows = Database::getInstance()->getConnection()->query(
|
||||
"SELECT clave, valor FROM lab_config
|
||||
WHERE clave IN ('empresa_nombre','empresa_subtitulo','doc_logo_base64')"
|
||||
)->fetchAll(PDO::FETCH_KEY_PAIR);
|
||||
$emp = array_merge($emp, $rows ?: []);
|
||||
} catch (\Throwable $_) {}
|
||||
|
||||
// Numeración: 1. Sección → 1.1 Documento
|
||||
$capitulos = [];
|
||||
$nSec = 0;
|
||||
foreach ($arbol as $sec => $cfg) {
|
||||
$nSec++;
|
||||
$docs = [];
|
||||
$nDoc = 0;
|
||||
foreach ($cfg['docs'] as $d) {
|
||||
$nDoc++;
|
||||
[, $cuerpo] = DocIndex::leer($d['archivo']);
|
||||
$md = Generadores::expandir($cuerpo);
|
||||
// El H1 del documento se reemplaza por el encabezado numerado
|
||||
$md = preg_replace('/^#\s+.*$/m', '', $md, 1);
|
||||
$docs[] = [
|
||||
'num' => "{$nSec}.{$nDoc}",
|
||||
'titulo' => $d['titulo'],
|
||||
'id' => 'doc-' . $sec . '-' . $d['slug'],
|
||||
'html' => Markdown::render($md),
|
||||
];
|
||||
}
|
||||
$capitulos[] = ['num' => (string)$nSec, 'titulo' => $cfg['titulo'],
|
||||
'resumen' => $cfg['resumen'], 'id' => 'cap-' . $sec, 'docs' => $docs];
|
||||
}
|
||||
|
||||
$totalDocs = array_sum(array_map(fn($c) => count($c['docs']), $capitulos));
|
||||
$esc = fn($s) => htmlspecialchars((string)$s, ENT_QUOTES);
|
||||
?><!DOCTYPE html>
|
||||
<html lang="es">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<title>Documentación del Sistema — <?= $esc($emp['empresa_nombre']) ?></title>
|
||||
<style>
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 18mm 16mm 16mm 16mm;
|
||||
}
|
||||
@page :first { margin: 0; }
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html { -webkit-print-color-adjust: exact; print-color-adjust: exact; }
|
||||
body {
|
||||
font-family: "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
font-size: 10.5pt; line-height: 1.55; color: #1e293b;
|
||||
margin: 0; padding: 0; background: #fff;
|
||||
}
|
||||
|
||||
/* ── Barra de acciones (no se imprime) ── */
|
||||
.barra {
|
||||
position: sticky; top: 0; z-index: 10;
|
||||
background: #0f172a; color: #fff; padding: 11px 20px;
|
||||
display: flex; align-items: center; gap: 14px; flex-wrap: wrap;
|
||||
}
|
||||
.barra .t { font-weight: 700; flex: 1; font-size: .95rem; }
|
||||
.barra button, .barra a {
|
||||
background: #2563eb; color: #fff; border: none; border-radius: 7px;
|
||||
padding: 7px 15px; font-size: .84rem; cursor: pointer; text-decoration: none;
|
||||
}
|
||||
.barra a.sec { background: rgba(255,255,255,.14); }
|
||||
.barra button:hover { background: #1d4ed8; }
|
||||
.hoja { max-width: 195mm; margin: 0 auto; padding: 14mm 12mm 20mm; }
|
||||
|
||||
/* ── Portada ── */
|
||||
.portada {
|
||||
display: flex; flex-direction: column; justify-content: center;
|
||||
min-height: 247mm; padding: 0 22mm; text-align: center;
|
||||
page-break-after: always; break-after: page;
|
||||
}
|
||||
.portada img { max-width: 170px; margin: 0 auto 26px; }
|
||||
.portada .lab { font-size: 13pt; color: #64748b; letter-spacing: .06em;
|
||||
text-transform: uppercase; margin-bottom: 6px; }
|
||||
.portada .sub { font-size: 10.5pt; color: #94a3b8; margin-bottom: 46px; }
|
||||
.portada h1 { font-size: 30pt; font-weight: 800; color: #0d47a1;
|
||||
margin: 0 0 10px; line-height: 1.15; letter-spacing: -.5px; }
|
||||
.portada .lin { width: 76px; height: 3px; background: #0d47a1;
|
||||
margin: 22px auto 24px; border-radius: 2px; }
|
||||
.portada .desc { font-size: 11pt; color: #475569; max-width: 118mm;
|
||||
margin: 0 auto 50px; line-height: 1.65; }
|
||||
.portada .meta { font-size: 9.5pt; color: #64748b; line-height: 1.9; }
|
||||
.portada .meta b { color: #334155; }
|
||||
|
||||
/* ── Índice ── */
|
||||
.indice { page-break-after: always; break-after: page; }
|
||||
.indice h2 { font-size: 17pt; color: #0d47a1; margin: 0 0 22px;
|
||||
padding-bottom: 10px; border-bottom: 2px solid #e2e8f0; }
|
||||
.idx-cap { margin-bottom: 16px; }
|
||||
.idx-cap > a { display: block; font-size: 11.5pt; font-weight: 700; color: #0f172a;
|
||||
text-decoration: none; margin-bottom: 3px; }
|
||||
.idx-cap .res { font-size: 9pt; color: #94a3b8; margin: 0 0 7px 20px; }
|
||||
.idx-doc { display: block; font-size: 10pt; color: #334155; text-decoration: none;
|
||||
margin: 0 0 3px 20px; }
|
||||
.idx-doc .n { color: #94a3b8; display: inline-block; min-width: 30px; }
|
||||
|
||||
/* ── Capítulo ── */
|
||||
.cap { page-break-before: always; break-before: page; padding-top: 26mm; text-align: center; }
|
||||
.cap .n { font-size: 46pt; font-weight: 800; color: #dbeafe; line-height: 1; }
|
||||
.cap h2 { font-size: 22pt; color: #0d47a1; margin: 6px 0 12px; font-weight: 800; }
|
||||
.cap p { color: #64748b; font-size: 10.5pt; max-width: 115mm; margin: 0 auto; }
|
||||
|
||||
/* ── Documento ── */
|
||||
.doc { page-break-before: always; break-before: page; }
|
||||
.doc-h {
|
||||
border-bottom: 2px solid #0d47a1; padding-bottom: 8px; margin-bottom: 16px;
|
||||
page-break-after: avoid; break-after: avoid;
|
||||
}
|
||||
.doc-h .n { font-size: 9pt; color: #2563eb; font-weight: 700; letter-spacing: .08em; }
|
||||
.doc-h h3 { font-size: 16pt; color: #0f172a; margin: 2px 0 0; font-weight: 700; }
|
||||
|
||||
h2, h3, h4 { page-break-after: avoid; break-after: avoid; }
|
||||
.doc h2 { font-size: 12.5pt; color: #1565c0; margin: 17px 0 7px; font-weight: 700; }
|
||||
.doc h3 { font-size: 11pt; color: #334155; margin: 13px 0 5px; font-weight: 700; }
|
||||
.doc h4 { font-size: 10pt; color: #475569; margin: 11px 0 4px; font-weight: 700; }
|
||||
.doc p { margin: 0 0 8px; text-align: justify; }
|
||||
.doc ul, .doc ol { margin: 0 0 9px; padding-left: 20px; }
|
||||
.doc li { margin-bottom: 2px; }
|
||||
.doc a { color: #1d4ed8; text-decoration: none; }
|
||||
.doc strong { color: #0f172a; }
|
||||
|
||||
code { background: #f1f5f9; color: #be185d; padding: 1px 4px; border-radius: 3px;
|
||||
font-family: "Consolas", "SF Mono", Menlo, monospace; font-size: .86em; }
|
||||
pre { background: #f8fafc; border: 1px solid #cbd5e1; border-left: 3px solid #1565c0;
|
||||
padding: 9px 12px; border-radius: 4px; margin: 0 0 10px; overflow-x: auto;
|
||||
page-break-inside: avoid; break-inside: avoid; }
|
||||
pre code { background: none; color: #0f172a; padding: 0; font-size: 8.2pt; line-height: 1.45; }
|
||||
|
||||
blockquote { border-left: 3px solid #f59e0b; background: #fffbeb; margin: 0 0 10px;
|
||||
padding: 8px 13px; page-break-inside: avoid; break-inside: avoid; }
|
||||
blockquote p { margin: 0; color: #78350f; font-size: 9.8pt; text-align: left; }
|
||||
|
||||
.tabla-scroll { margin: 0 0 11px; page-break-inside: avoid; break-inside: avoid; }
|
||||
table { width: 100%; border-collapse: collapse; font-size: 9pt; }
|
||||
thead { display: table-header-group; }
|
||||
th { background: #1565c0; color: #fff; text-align: left; font-weight: 600;
|
||||
padding: 5px 8px; border: 1px solid #1565c0; }
|
||||
td { padding: 4px 8px; border: 1px solid #e2e8f0; vertical-align: top; }
|
||||
tbody tr:nth-child(even) { background: #f8fafc; }
|
||||
|
||||
hr { border: none; border-top: 1px solid #e2e8f0; margin: 16px 0; }
|
||||
|
||||
/* Tablas muy largas (inventarios generados) pueden partirse entre páginas */
|
||||
.doc table { page-break-inside: auto; }
|
||||
tr { page-break-inside: avoid; break-inside: avoid; }
|
||||
|
||||
@media print {
|
||||
.barra { display: none !important; }
|
||||
.hoja { max-width: none; margin: 0; padding: 0; }
|
||||
a { color: #1e293b !important; text-decoration: none; }
|
||||
.portada { min-height: 250mm; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="barra">
|
||||
<span class="t">
|
||||
<?= $totalDocs ?> documento<?= $totalDocs === 1 ? '' : 's' ?>
|
||||
<?= $soloSeccion ? '· ' . $esc($arbol[$soloSeccion]['titulo']) : '· documentación completa' ?>
|
||||
</span>
|
||||
<?php if ($soloSeccion): ?>
|
||||
<a class="sec" href="<?= BASE_URL ?>erp.php?m=soporte&v=imprimir">Exportar todo</a>
|
||||
<?php else: ?>
|
||||
<?php foreach (DocIndex::seccionesVisibles() as $s => $c): ?>
|
||||
<a class="sec" href="<?= BASE_URL ?>erp.php?m=soporte&v=imprimir&s=<?= urlencode($s) ?>">Solo <?= $esc($c['titulo']) ?></a>
|
||||
<?php endforeach; ?>
|
||||
<?php endif; ?>
|
||||
<a class="sec" href="<?= BASE_URL ?>erp.php?m=soporte&v=documentacion">Volver</a>
|
||||
<button onclick="window.print()">Imprimir / Guardar como PDF</button>
|
||||
</div>
|
||||
|
||||
<div class="hoja">
|
||||
|
||||
<!-- ── Portada ── -->
|
||||
<div class="portada">
|
||||
<?php if (!empty($emp['doc_logo_base64'])): ?>
|
||||
<img src="<?= $esc($emp['doc_logo_base64']) ?>" alt="">
|
||||
<?php endif; ?>
|
||||
<div class="lab"><?= $esc($emp['empresa_nombre']) ?></div>
|
||||
<?php if (!empty($emp['empresa_subtitulo'])): ?>
|
||||
<div class="sub"><?= $esc($emp['empresa_subtitulo']) ?></div>
|
||||
<?php endif; ?>
|
||||
|
||||
<h1>Documentación<br>del Sistema</h1>
|
||||
<div class="lin"></div>
|
||||
<p class="desc">
|
||||
<?php if ($soloSeccion): ?>
|
||||
<?= $esc($arbol[$soloSeccion]['resumen']) ?>
|
||||
<?php else: ?>
|
||||
Manual de usuario, documentación técnica, arquitectura y guía de operación
|
||||
del sistema de gestión del laboratorio.
|
||||
<?php endif; ?>
|
||||
</p>
|
||||
<div class="meta">
|
||||
<?php if ($soloSeccion): ?><b><?= $esc($arbol[$soloSeccion]['titulo']) ?></b><br><?php endif; ?>
|
||||
<b><?= $totalDocs ?></b> documentos<br>
|
||||
Generado el <b><?= date('d/m/Y') ?></b><br>
|
||||
<span style="font-size:8.5pt">Los inventarios técnicos reflejan el estado del sistema a esta fecha</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ── Índice ── -->
|
||||
<div class="indice">
|
||||
<h2>Contenido</h2>
|
||||
<?php foreach ($capitulos as $cap): ?>
|
||||
<div class="idx-cap">
|
||||
<a href="#<?= $esc($cap['id']) ?>"><?= $esc($cap['num']) ?>. <?= $esc($cap['titulo']) ?></a>
|
||||
<p class="res"><?= $esc($cap['resumen']) ?></p>
|
||||
<?php foreach ($cap['docs'] as $d): ?>
|
||||
<a class="idx-doc" href="#<?= $esc($d['id']) ?>">
|
||||
<span class="n"><?= $esc($d['num']) ?></span><?= $esc($d['titulo']) ?>
|
||||
</a>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
</div>
|
||||
|
||||
<!-- ── Contenido ── -->
|
||||
<?php foreach ($capitulos as $cap): ?>
|
||||
<div class="cap" id="<?= $esc($cap['id']) ?>">
|
||||
<div class="n"><?= $esc($cap['num']) ?></div>
|
||||
<h2><?= $esc($cap['titulo']) ?></h2>
|
||||
<p><?= $esc($cap['resumen']) ?></p>
|
||||
</div>
|
||||
<?php foreach ($cap['docs'] as $d): ?>
|
||||
<div class="doc" id="<?= $esc($d['id']) ?>">
|
||||
<div class="doc-h">
|
||||
<div class="n">CAPÍTULO <?= $esc($d['num']) ?></div>
|
||||
<h3><?= $esc($d['titulo']) ?></h3>
|
||||
</div>
|
||||
<?= $d['html'] ?>
|
||||
</div>
|
||||
<?php endforeach; ?>
|
||||
<?php endforeach; ?>
|
||||
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,32 @@
|
||||
<?php
|
||||
/**
|
||||
* modules/turnero/_acceso.php
|
||||
* Restricción por rol dentro del módulo Turnero.
|
||||
*
|
||||
* El control de acceso del ERP es por módulo: quien tiene `turnero` puede abrir
|
||||
* cualquiera de sus vistas escribiendo la URL, aunque el menú no se la muestre.
|
||||
* Para las pantallas que operan sobre la atención o cambian configuración eso no
|
||||
* alcanza, así que exigen además un rol.
|
||||
*
|
||||
* Uso, al inicio de la vista:
|
||||
* require_once __DIR__ . '/../_acceso.php';
|
||||
* turneroExigirRol(['admin', 'superadmin', 'supervisor']);
|
||||
*/
|
||||
|
||||
if (!function_exists('turneroExigirRol')) {
|
||||
/**
|
||||
* Corta la ejecución si el rol de la sesión no está entre los permitidos.
|
||||
* Los administradores pasan siempre.
|
||||
*/
|
||||
function turneroExigirRol(array $permitidos): void
|
||||
{
|
||||
$rol = $_SESSION['admin_user']['role'] ?? '';
|
||||
if (in_array($rol, ['admin', 'superadmin'], true)) return;
|
||||
if (in_array($rol, $permitidos, true)) return;
|
||||
|
||||
// Devolver a una pantalla que sí pueda ver, en vez de un error seco
|
||||
$destino = defined('BASE_URL') ? BASE_URL : '/';
|
||||
header('Location: ' . $destino . 'erp.php?m=turnero&v=dashboard&sin_acceso=1');
|
||||
exit;
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
<?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.
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
@@ -14,12 +14,30 @@ $pregunta = trim($body['pregunta'] ?? '');
|
||||
if ($pregunta === '') jsonError('Pregunta vacía', 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();
|
||||
|
||||
// ── Clave Gemini ──────────────────────────────────────────────
|
||||
$row = $pdo->query("SELECT valor FROM lab_config WHERE clave = 'gemini_api_key' LIMIT 1")->fetch();
|
||||
$apiKey = trim($row['valor'] ?? '');
|
||||
$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($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 ($tokensUsados >= LIA_TOKENS_MAX) {
|
||||
jsonError('⚠️ Se agotaron los tokens de LIA. Solicita tokens a soporte para continuar.', 402);
|
||||
}
|
||||
|
||||
// ── Contexto del día ──────────────────────────────────────────
|
||||
$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 ────────────────────────────────────────────────────
|
||||
$systemPrompt = <<<PROMPT
|
||||
Eres el 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.
|
||||
Solo usa la información del contexto proporcionado. Si no tienes el dato, dilo claramente.
|
||||
Eres LIA, la asistente inteligente del sistema de turnero del Laboratorio Clínico.
|
||||
Responde en español, tratando SIEMPRE de usted (nunca tú ni vos), con el detalle que la pregunta requiera: si te piden un dato puntual sé breve, pero si te piden un listado, un análisis o una comparación, desarróllalo completo. Usa tablas o viñetas cuando ayuden a leer los datos.
|
||||
Puedes analizar: tiempos de espera y servicio por paciente, recepcionista o bacteriólogo; facturación del día; exámenes solicitados; franjas horarias con mayor demanda; buscar pacientes por nombre o cédula.
|
||||
También responde preguntas de cómo se usa el sistema, apoyándose en el MANUAL que se incluye más abajo cuando esté presente.
|
||||
Solo usa la información del contexto proporcionado. Si no tienes el dato, dilo claramente y no lo inventes.
|
||||
El manual incluido es el que este usuario tiene permitido consultar: no mencione ni deduzca contenido que no esté ahí.
|
||||
Tienes el historial de esta conversación: si el usuario pregunta algo que se refiere a tu respuesta anterior, respóndelo sin pedir que repita el contexto.
|
||||
|
||||
CONTEXTO DEL DÍA:
|
||||
{$ctx}
|
||||
{$ctx}{$bloqueDocs}
|
||||
PROMPT;
|
||||
|
||||
// ── Llamada a Gemini Flash ────────────────────────────────────
|
||||
$payload = json_encode([
|
||||
'system_instruction' => ['parts' => [['text' => $systemPrompt]]],
|
||||
'contents' => [
|
||||
'contents' => array_merge($historial, [
|
||||
['role' => 'user', 'parts' => [['text' => $pregunta]]]
|
||||
],
|
||||
]),
|
||||
'generationConfig' => [
|
||||
'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);
|
||||
|
||||
$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, [
|
||||
CURLOPT_RETURNTRANSFER => true,
|
||||
CURLOPT_POST => true,
|
||||
@@ -207,5 +247,27 @@ if ($code !== 200 || empty($gemini['candidates'][0]['content']['parts'][0]['text
|
||||
jsonError("Error IA: {$detail}", 502);
|
||||
}
|
||||
|
||||
$respuesta = $gemini['candidates'][0]['content']['parts'][0]['text'];
|
||||
jsonOk(['respuesta' => $respuesta]);
|
||||
$respuesta = $gemini['candidates'][0]['content']['parts'][0]['text'];
|
||||
|
||||
// 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
|
||||
/**
|
||||
* GET ?q= — Busca diagnósticos CIE-10 por código o descripción.
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireMethod('GET');
|
||||
requireTurnero();
|
||||
|
||||
@@ -39,8 +39,8 @@ if ($nuevoEstado === '') jsonError('nuevo_estado es requerido.');
|
||||
const TRANSICIONES = [
|
||||
'espera' => ['en_recepcion', 'ausente', 'cancelado'],
|
||||
'en_recepcion' => ['en_espera_lugar', 'espera', 'ausente', 'cancelado'],
|
||||
'en_espera_lugar' => ['en_servicio', 'ausente', 'cancelado'],
|
||||
'en_servicio' => ['finalizado', 'ausente', 'cancelado', 'en_espera_lugar'],
|
||||
'en_espera_lugar' => ['en_servicio', 'en_recepcion', 'ausente', 'cancelado'],
|
||||
'en_servicio' => ['finalizado', 'ausente', 'cancelado', 'en_espera_lugar', 'en_recepcion'],
|
||||
];
|
||||
|
||||
$pdo = db();
|
||||
@@ -65,10 +65,60 @@ try {
|
||||
|
||||
$estadoActual = $turno['estado'];
|
||||
|
||||
// Estados finales no modificables
|
||||
// Estados finales — solo admin (sin role_id) puede reabrir
|
||||
if (in_array($estadoActual, ['finalizado', 'ausente', 'cancelado'], true)) {
|
||||
$pdo->rollBack();
|
||||
jsonError("El turno ya está en estado '{$estadoActual}' y no puede modificarse.", 422);
|
||||
$roleId = $_SESSION['admin_user']['role_id'] ?? null;
|
||||
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
|
||||
@@ -116,6 +166,14 @@ try {
|
||||
$sets[] = 'recepcion_desk_id = ?';
|
||||
$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;
|
||||
|
||||
case 'en_espera_lugar':
|
||||
|
||||
@@ -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 (!is_array($cfg) || empty($cfg)) jsonError('tomas_config requerido.');
|
||||
if ($examType === '') jsonError('exam_type requerido.');
|
||||
|
||||
foreach ($cfg as $grupo => $ids) {
|
||||
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;
|
||||
}
|
||||
|
||||
$dr['_tomas_config'] = $cfg;
|
||||
if ($examType !== '') $dr['_c8j2g16'] = $examType;
|
||||
// Store per-exam nested: { examName: { groupKey: [firmaIds] } }
|
||||
$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;
|
||||
|
||||
$pdo->prepare("UPDATE turnero_consentimientos SET datos_respuestas = ? WHERE id = ?")
|
||||
|
||||
@@ -68,10 +68,10 @@ if ($existente) {
|
||||
);
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"INSERT INTO turnero_consentimientos (turno_id, formulario_id, token, estado)
|
||||
VALUES (?, ?, ?, 'pendiente')"
|
||||
"INSERT INTO turnero_consentimientos (turno_id, formulario_id, token, estado, creado_por)
|
||||
VALUES (?, ?, ?, 'pendiente', ?)"
|
||||
);
|
||||
$stmt->execute([$turnoId, $formularioId, $token]);
|
||||
$stmt->execute([$turnoId, $formularioId, $token, adminId()]);
|
||||
}
|
||||
|
||||
$url = BASE_URL . 'ver_formulario_enviado.php?token=' . urlencode($token);
|
||||
|
||||
@@ -187,15 +187,15 @@ try {
|
||||
$consentimientosRequeridos = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
$stmtCreateConsent = $pdo->prepare(
|
||||
"INSERT IGNORE INTO turnero_consentimientos (turno_id, formulario_id, token, estado)
|
||||
VALUES (?, ?, UUID(), 'pendiente')"
|
||||
"INSERT IGNORE INTO turnero_consentimientos (turno_id, formulario_id, token, estado, creado_por)
|
||||
VALUES (?, ?, UUID(), 'pendiente', ?)"
|
||||
);
|
||||
$stmtRead = $pdo->prepare(
|
||||
"SELECT id, token, estado FROM turnero_consentimientos
|
||||
WHERE turno_id = ? AND formulario_id = ?"
|
||||
);
|
||||
foreach ($consentimientosRequeridos as &$c) {
|
||||
$stmtCreateConsent->execute([$turnoId, $c['formulario_id']]);
|
||||
$stmtCreateConsent->execute([$turnoId, $c['formulario_id'], adminId()]);
|
||||
$stmtRead->execute([$turnoId, $c['formulario_id']]);
|
||||
$row = $stmtRead->fetch(PDO::FETCH_ASSOC);
|
||||
$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,11 @@ $turnoId = (int)($body['turno_id'] ?? 0);
|
||||
$formularioId = (int)($body['formulario_id'] ?? 0);
|
||||
$svg = $body['svg'] ?? '';
|
||||
$soloPro = !empty($body['solo_profesional']);
|
||||
$datosResp = (isset($body['datos_respuestas']) && is_array($body['datos_respuestas']))
|
||||
? json_encode($body['datos_respuestas'], JSON_UNESCAPED_UNICODE) : null;
|
||||
// Siempre mergeamos datos_respuestas (incluye _pro_nombre/_pro_cedula)
|
||||
$datosResp = null;
|
||||
if (isset($body['datos_respuestas']) && is_array($body['datos_respuestas']) && !empty($body['datos_respuestas'])) {
|
||||
$datosResp = json_encode($body['datos_respuestas'], JSON_UNESCAPED_UNICODE);
|
||||
}
|
||||
|
||||
if (!$turnoId) jsonError('turno_id requerido.');
|
||||
if (!$formularioId) jsonError('formulario_id requerido.');
|
||||
@@ -52,9 +55,12 @@ if ($soloPro) {
|
||||
} else {
|
||||
$pdo->prepare(
|
||||
"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 = ?"
|
||||
)->execute([$svg, $turnoId, $formularioId]);
|
||||
)->execute([$svg, $datosResp, $datosResp, $turnoId, $formularioId]);
|
||||
}
|
||||
|
||||
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
|
||||
WHERE t.sesion_id = ?
|
||||
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
|
||||
(SELECT $cols
|
||||
FROM turnero_turnos t
|
||||
@@ -91,7 +92,8 @@ if ($area === 'recepcion') {
|
||||
LEFT JOIN turnero_solicitudes s ON s.turno_id = t.id
|
||||
WHERE t.sesion_id = ?
|
||||
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
|
||||
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';
|
||||
$estadoActivo = $area === 'recepcion' ? "'en_recepcion'" : "'en_servicio'";
|
||||
// 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];
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
@@ -173,12 +175,33 @@ $stmt = $pdo->prepare(
|
||||
$stmt->execute([$sesionId]);
|
||||
$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([
|
||||
'sesion_id' => $sesionId,
|
||||
'area' => $area,
|
||||
'lugar_id' => $lugarId,
|
||||
'activo' => $activo,
|
||||
'cola' => $cola,
|
||||
'stats' => $stats,
|
||||
'timestamp' => date('c'),
|
||||
'sesion_id' => $sesionId,
|
||||
'area' => $area,
|
||||
'lugar_id' => $lugarId,
|
||||
'activo' => $activo,
|
||||
'cola' => $cola,
|
||||
'en_espera_muestra' => $enEsperaMuestra,
|
||||
'stats' => $stats,
|
||||
'timestamp' => date('c'),
|
||||
]);
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
<?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:
|
||||
* tipo string 'F' = Físico/turnero (default) | 'D' = Domicilio
|
||||
*
|
||||
* Formato resultante:
|
||||
* F-YYYYMMDD-NNN para tipo=F
|
||||
* D-YYYYMMDD-NNN para tipo=D
|
||||
* Formato: F-YYYYMMDD-NNN | D-YYYYMMDD-NNN
|
||||
*
|
||||
* 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';
|
||||
requireTurnero();
|
||||
@@ -18,23 +19,17 @@ $tipo = strtoupper(trim($_GET['tipo'] ?? 'F'));
|
||||
if (!in_array($tipo, ['F', 'D'], true)) $tipo = 'F';
|
||||
|
||||
$pdo = db();
|
||||
$prefix = $tipo . '-' . date('Ymd') . '-';
|
||||
$prefijo = $tipo . '-' . date('Ymd'); // e.g. 'F-20260727'
|
||||
$prefix = $prefijo . '-';
|
||||
|
||||
if ($tipo === 'D') {
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT MAX(CAST(SUBSTRING_INDEX(numero_orden, '-', -1) AS UNSIGNED)) AS ultimo
|
||||
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);
|
||||
// Asegurar que exista la fila del día (INSERT IGNORE no toca si ya existe)
|
||||
$pdo->prepare("INSERT IGNORE INTO turnero_consecutivos (prefijo, ultimo) VALUES (?, 0)")
|
||||
->execute([$prefijo]);
|
||||
|
||||
$siguiente = $prefix . str_pad($ultimo + 1, 3, '0', STR_PAD_LEFT);
|
||||
jsonOk(['consecutivo' => $siguiente]);
|
||||
// Incremento atómico: UPDATE toma lock de fila → dos hilos nunca leen el mismo valor
|
||||
$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)
|
||||
);
|
||||
$pdo->prepare(
|
||||
"INSERT IGNORE INTO turnero_consentimientos (turno_id, formulario_id, token, estado) VALUES (?, ?, ?, 'pendiente')"
|
||||
)->execute([$turnoId, $formularioId, $token]);
|
||||
"INSERT IGNORE INTO turnero_consentimientos (turno_id, formulario_id, token, estado, creado_por) VALUES (?, ?, ?, 'pendiente', ?)"
|
||||
)->execute([$turnoId, $formularioId, $token, adminId()]);
|
||||
|
||||
jsonOk(['token' => $token, 'nuevo' => true]);
|
||||
|
||||
@@ -56,7 +56,8 @@ $stmt = $pdo->prepare(
|
||||
tc.datos_respuestas,
|
||||
f.nombre AS formulario_nombre,
|
||||
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
|
||||
LEFT JOIN lab_formularios f ON f.id = tc.formulario_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)
|
||||
$overrideProg = $c['formulario_es_toma_prog'] ?? null;
|
||||
$esTomaProg = $overrideProg !== null ? (bool)(int)$overrideProg : count($camposFirmaPro) > 1;
|
||||
$tomasTotal = 0;
|
||||
$tomasFirm = 0;
|
||||
$dr = $c['datos_respuestas'] ? (json_decode($c['datos_respuestas'], true) ?? []) : [];
|
||||
$tomasTotal = 0;
|
||||
$tomasFirm = 0;
|
||||
$tomasDetalle = []; // reiniciar cada vuelta: si no, un formulario sin tomas hereda las del anterior
|
||||
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
|
||||
// (y a su etiqueta + campo hora, para poder mostrar el detalle por toma)
|
||||
$firmaCondMap = [];
|
||||
$curCondCampo = null;
|
||||
$curCondVals = [];
|
||||
$curLabel = null;
|
||||
$curHoraId = null;
|
||||
foreach ($campos as $campo) {
|
||||
$ft = $campo['tipo'] ?? '';
|
||||
if ($ft === 'separador') {
|
||||
@@ -97,8 +102,15 @@ foreach ($consentimientos as &$c) {
|
||||
$curCondVals = $cond
|
||||
? ($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'])) {
|
||||
$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
|
||||
$tomasConfig = is_array($dr['_tomas_config'] ?? null) ? $dr['_tomas_config'] : 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)));
|
||||
}
|
||||
|
||||
$tomasTotal = count($relevantIds);
|
||||
$tomasDetalle = [];
|
||||
foreach ($relevantIds as $fid) {
|
||||
if ((!empty($dr[$fid]) && strlen($dr[$fid]) > 10)
|
||||
|| (!empty($dr[$fid.'_svg']) && strlen($dr[$fid.'_svg']) > 10)) $tomasFirm++;
|
||||
$firmado = (!empty($dr[$fid]) && strlen($dr[$fid]) > 10)
|
||||
|| (!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_paciente'] = !($tieneFirmaPro && !$tieneFirmaPac);
|
||||
$c['requiere_firma_paciente'] = $soloPro ? false : !($tieneFirmaPro && !$tieneFirmaPac);
|
||||
$c['origen_lugar_id'] = in_array((int)$c['formulario_id'], $fidsDeLugar, true)
|
||||
? ($lugarDestinoId ?: -1) : null;
|
||||
$c['es_toma_progresiva'] = $esTomaProg;
|
||||
$c['tomas_total'] = $tomasTotal;
|
||||
$c['tomas_firmadas'] = $tomasFirm;
|
||||
$c['tomas_detalle'] = $tomasDetalle ?? [];
|
||||
$c['cierre_anticipado'] = $dr['_cierre_anticipado'] ?? null;
|
||||
// Exponer campos firma_profesional (ids) para el frontend
|
||||
$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);
|
||||
|
||||
@@ -193,34 +226,9 @@ if ($incluirSolicitud) {
|
||||
$stmt->execute([$solicitud['id']]);
|
||||
$examenes = $stmt->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// Visita de solo entrega de muestras: no requiere consentimientos ni formularios
|
||||
if (!empty($solicitud['solo_muestras'])) {
|
||||
$respuesta['consentimientos'] = [];
|
||||
$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);
|
||||
}
|
||||
// Nota: en visitas de "solo entrega de muestras" no hay exámenes, así que la
|
||||
// Fuente 1 (por examen) no aplica, pero la Fuente 2 (por lugar, ej. F-LAB-08
|
||||
// Datos Toma de Muestras) sigue siendo obligatoria — se procesa más abajo igual.
|
||||
|
||||
// Auto-crear consentimientos requeridos desde DOS fuentes:
|
||||
// 1. Por examen → exam_tipo_consentimientos
|
||||
@@ -271,8 +279,8 @@ if ($incluirSolicitud) {
|
||||
$existFormIds = array_map('intval', array_column($consentimientos, 'formulario_id'));
|
||||
$stmtIns = $pdo->prepare(
|
||||
"INSERT IGNORE INTO turnero_consentimientos
|
||||
(turno_id, formulario_id, token, estado)
|
||||
VALUES (?, ?, ?, 'pendiente')"
|
||||
(turno_id, formulario_id, token, estado, creado_por)
|
||||
VALUES (?, ?, ?, 'pendiente', ?)"
|
||||
);
|
||||
$creados = 0;
|
||||
foreach ($requeridos as $r) {
|
||||
@@ -285,7 +293,7 @@ if ($incluirSolicitud) {
|
||||
mt_rand(0,0x3fff)|0x8000,
|
||||
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++;
|
||||
}
|
||||
}
|
||||
@@ -302,7 +310,8 @@ if ($incluirSolicitud) {
|
||||
tc.datos_respuestas,
|
||||
f.nombre AS formulario_nombre,
|
||||
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
|
||||
LEFT JOIN lab_formularios f ON f.id = tc.formulario_id
|
||||
WHERE tc.turno_id = ?
|
||||
@@ -322,10 +331,10 @@ if ($incluirSolicitud) {
|
||||
$tieneFirmaPac = !empty(array_filter($campos, fn($f) => ($f['tipo'] ?? '') === 'firma'));
|
||||
$overrideProg = $c['formulario_es_toma_prog'] ?? null;
|
||||
$esTomaProg = $overrideProg !== null ? (bool)(int)$overrideProg : count($camposFirmaPro) > 1;
|
||||
$dr2 = $c['datos_respuestas'] ? (json_decode($c['datos_respuestas'], true) ?? []) : [];
|
||||
$tomasTotal2 = 0;
|
||||
$tomasFirm = 0;
|
||||
if ($esTomaProg) {
|
||||
$dr2 = $c['datos_respuestas'] ? (json_decode($c['datos_respuestas'], true) ?? []) : [];
|
||||
$firmaCondMap2 = [];
|
||||
$curC2 = null; $curV2 = [];
|
||||
foreach ($campos as $campo2) {
|
||||
@@ -350,7 +359,14 @@ if ($incluirSolicitud) {
|
||||
}
|
||||
$cfg2 = is_array($dr2['_tomas_config'] ?? null) ? $dr2['_tomas_config'] : 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)));
|
||||
}
|
||||
$tomasTotal2 = count($relevantIds2);
|
||||
@@ -358,15 +374,17 @@ if ($incluirSolicitud) {
|
||||
if (!empty($dr2[$fid2]) && strlen($dr2[$fid2]) > 10) $tomasFirm++;
|
||||
}
|
||||
}
|
||||
$soloPro2 = !empty($c['formulario_solo_profesional']);
|
||||
$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)
|
||||
? $lugarDestinoId : null;
|
||||
$c['es_toma_progresiva'] = $esTomaProg;
|
||||
$c['tomas_total'] = $tomasTotal2;
|
||||
$c['tomas_firmadas'] = $tomasFirm;
|
||||
$c['cierre_anticipado'] = $dr2['_cierre_anticipado'] ?? null;
|
||||
$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);
|
||||
$respuesta['consentimientos'] = $consentimientos;
|
||||
@@ -396,13 +414,14 @@ if ($incluirSolicitud) {
|
||||
"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.id AS solicitud_id_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'
|
||||
AND tm.estado IN ('pendiente','rechazada')
|
||||
ORDER BY tm.id ASC"
|
||||
);
|
||||
$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,
|
||||
COALESCE(lt.nombre, tm.tipo_muestra) AS label,
|
||||
1 AS es_pendiente_anterior,
|
||||
ts.id AS solicitud_id_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.estado = 'pendiente'
|
||||
AND tm.estado IN ('pendiente','rechazada')
|
||||
ORDER BY tm.id ASC"
|
||||
);
|
||||
$stmtPrev->execute([$pacId]);
|
||||
@@ -442,6 +462,33 @@ if ($incluirSolicitud) {
|
||||
} 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['paciente'] = $paciente;
|
||||
$respuesta['examenes'] = $examenes;
|
||||
|
||||
@@ -73,7 +73,15 @@ $stmtRes = $pdo->prepare(
|
||||
THEN TIMESTAMPDIFF(SECOND, inicio_lugar_at, fin_lugar_at) / 60.0
|
||||
END
|
||||
), 1
|
||||
) AS tiempo_servicio_promedio_min
|
||||
) AS tiempo_servicio_promedio_min,
|
||||
ROUND(
|
||||
AVG(
|
||||
CASE
|
||||
WHEN fin_lugar_at IS NOT NULL AND creado_at IS NOT NULL
|
||||
THEN TIMESTAMPDIFF(SECOND, creado_at, fin_lugar_at) / 60.0
|
||||
END
|
||||
), 1
|
||||
) AS tiempo_total_promedio_min
|
||||
FROM turnero_turnos
|
||||
WHERE sesion_id = ?"
|
||||
);
|
||||
@@ -133,7 +141,8 @@ $stmtTurnos = $pdo->prepare(
|
||||
ul.full_name AS atendido_lugar_nombre,
|
||||
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.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
|
||||
JOIN turnero_prioridades p ON p.id = t.prioridad_id
|
||||
LEFT JOIN turnero_lugares l ON l.id = t.lugar_destino_id
|
||||
|
||||
@@ -76,6 +76,8 @@ if (empty($escritoriosRec)) {
|
||||
JOIN turnero_prioridades p ON p.id = t.prioridad_id
|
||||
WHERE t.sesion_id = ?
|
||||
AND t.estado = 'en_recepcion'
|
||||
-- Misma regla que en los puestos: el turno vale hasta medianoche
|
||||
AND DATE(t.llamado_recepcion_at) = CURDATE()
|
||||
ORDER BY t.llamado_recepcion_at DESC"
|
||||
);
|
||||
$stmtRec->execute([$sesionId]);
|
||||
@@ -120,6 +122,10 @@ foreach ($lugares as $lugar) {
|
||||
WHERE t.sesion_id = ?
|
||||
AND t.estado = 'en_servicio'
|
||||
AND t.lugar_destino_id = ?
|
||||
-- Un turno vale hasta la medianoche de su día. Quedan bastantes sin
|
||||
-- cerrar, y sin este filtro el puesto mostraba como \"llamado ahora\"
|
||||
-- a un paciente de días atrás en cuanto se cerraba el turno real.
|
||||
AND DATE(t.llamado_lugar_at) = CURDATE()
|
||||
ORDER BY t.llamado_lugar_at DESC
|
||||
LIMIT 1"
|
||||
);
|
||||
|
||||
@@ -0,0 +1,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]);
|
||||
@@ -0,0 +1,134 @@
|
||||
<?php
|
||||
/**
|
||||
* GET /modules/turnero/api/get_firma_pendiente.php
|
||||
*
|
||||
* Le dice a la tablet del paciente qué mostrar: si hay alguien siendo atendido
|
||||
* en su puesto y le falta firmar el consentimiento de bienvenida (F-LAB-01).
|
||||
*
|
||||
* SIN SESIÓN DE OPERADOR, a propósito: esta tablet la manipula el público y
|
||||
* nadie va a iniciar sesión en ella cada mañana. Se identifica por la cookie
|
||||
* del dispositivo, que es un token de 64 caracteres registrado en Configuración.
|
||||
*
|
||||
* Por eso devuelve lo mínimo: el turno que está en ese puesto en este instante
|
||||
* y nada más. No permite consultar otros turnos, ni buscar, ni ver historial.
|
||||
* Sin dispositivo reconocido no responde nada.
|
||||
*/
|
||||
|
||||
require_once __DIR__ . '/../../../config/config.php';
|
||||
|
||||
header('Content-Type: application/json; charset=utf-8');
|
||||
header('X-Content-Type-Options: nosniff');
|
||||
header('Cache-Control: no-store');
|
||||
|
||||
/** El único formulario que se firma en esta tablet: el de bienvenida. */
|
||||
const FORMULARIO_BIENVENIDA = 17;
|
||||
|
||||
function responder(array $datos): void {
|
||||
echo json_encode($datos, JSON_UNESCAPED_UNICODE);
|
||||
exit;
|
||||
}
|
||||
|
||||
$pdo = Database::getInstance()->getConnection();
|
||||
|
||||
// ── 1. ¿Qué puesto es esta tablet? ────────────────────────────────────────
|
||||
// Solo por token de navegador. La IP no sirve aquí: varias tablets salen por
|
||||
// la misma y acabaríamos mostrándole a un paciente los datos de otro puesto.
|
||||
$token = trim($_COOKIE['turnero_token'] ?? '');
|
||||
if ($token === '') {
|
||||
responder(['ok' => false, 'motivo' => 'sin_dispositivo']);
|
||||
}
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT td.lugar_id, td.nombre AS dispositivo, tl.nombre AS lugar, tl.tipo
|
||||
FROM turnero_dispositivos td
|
||||
JOIN turnero_lugares tl ON tl.id = td.lugar_id
|
||||
WHERE td.token = ? AND td.activo = 1
|
||||
LIMIT 1"
|
||||
);
|
||||
$stmt->execute([$token]);
|
||||
$disp = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$disp || $disp['tipo'] !== 'recepcion') {
|
||||
responder(['ok' => false, 'motivo' => 'sin_dispositivo']);
|
||||
}
|
||||
|
||||
// ── 2. ¿Hay alguien siendo atendido ahí ahora? ────────────────────────────
|
||||
// Mismo criterio que la pantalla del televisor: el turno vale hasta la
|
||||
// medianoche de su día, para no mostrar a un paciente que ya se fue.
|
||||
// La sesión está abierta mientras fin_at siga en nulo; no hay columna de estado.
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT t.id, t.codigo, t.paciente_id, t.paciente_nombre
|
||||
FROM turnero_turnos t
|
||||
JOIN turnero_sesiones s ON s.id = t.sesion_id
|
||||
WHERE t.estado = 'en_recepcion'
|
||||
AND t.recepcion_desk_id = ?
|
||||
AND DATE(t.llamado_recepcion_at) = CURDATE()
|
||||
AND s.fin_at IS NULL
|
||||
ORDER BY t.llamado_recepcion_at DESC
|
||||
LIMIT 1"
|
||||
);
|
||||
$stmt->execute([(int)$disp['lugar_id']]);
|
||||
$turno = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$turno) {
|
||||
responder(['ok' => true, 'estado' => 'reposo', 'lugar' => $disp['lugar']]);
|
||||
}
|
||||
|
||||
// Sin paciente vinculado no hay a quién atribuirle la firma
|
||||
if (empty($turno['paciente_id'])) {
|
||||
responder(['ok' => true, 'estado' => 'reposo', 'lugar' => $disp['lugar']]);
|
||||
}
|
||||
|
||||
// ── 3. ¿Le falta firmar el consentimiento de bienvenida? ──────────────────
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT token, estado FROM turnero_consentimientos
|
||||
WHERE turno_id = ? AND formulario_id = ?
|
||||
LIMIT 1"
|
||||
);
|
||||
$stmt->execute([(int)$turno['id'], FORMULARIO_BIENVENIDA]);
|
||||
$consent = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if ($consent && in_array($consent['estado'], ['firmado', 'rechazado'], true)) {
|
||||
responder([
|
||||
'ok' => true,
|
||||
'estado' => 'firmado',
|
||||
'turno_id' => (int)$turno['id'],
|
||||
'codigo' => $turno['codigo'],
|
||||
]);
|
||||
}
|
||||
|
||||
// Si el consentimiento todavía no existe se crea aquí. Es lo que permite que
|
||||
// la tablet aparezca sola, sin que la recepcionista tenga que mandarlo.
|
||||
if (!$consent) {
|
||||
$tokenFirma = sprintf(
|
||||
'%04x%04x-%04x-%04x-%04x-%04x%04x%04x',
|
||||
mt_rand(0, 0xffff), mt_rand(0, 0xffff),
|
||||
mt_rand(0, 0xffff),
|
||||
mt_rand(0, 0x0fff) | 0x4000,
|
||||
mt_rand(0, 0x3fff) | 0x8000,
|
||||
mt_rand(0, 0xffff), mt_rand(0, 0xffff), mt_rand(0, 0xffff)
|
||||
);
|
||||
$ins = $pdo->prepare(
|
||||
"INSERT IGNORE INTO turnero_consentimientos
|
||||
(turno_id, formulario_id, token, estado, creado_por)
|
||||
VALUES (?, ?, ?, 'pendiente', NULL)"
|
||||
);
|
||||
$ins->execute([(int)$turno['id'], FORMULARIO_BIENVENIDA, $tokenFirma]);
|
||||
|
||||
// INSERT IGNORE puede no haber insertado si otra petición se adelantó:
|
||||
// se relee para quedarse con el token que realmente quedó guardado.
|
||||
$stmt->execute([(int)$turno['id'], FORMULARIO_BIENVENIDA]);
|
||||
$consent = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
if (!$consent) {
|
||||
responder(['ok' => false, 'motivo' => 'no_se_pudo_crear']);
|
||||
}
|
||||
}
|
||||
|
||||
responder([
|
||||
'ok' => true,
|
||||
'estado' => 'por_firmar',
|
||||
'turno_id' => (int)$turno['id'],
|
||||
'codigo' => $turno['codigo'],
|
||||
'paciente' => $turno['paciente_nombre'],
|
||||
'url' => BASE_URL . 'ver_formulario_enviado.php?token=' . urlencode($consent['token']),
|
||||
]);
|
||||
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
/**
|
||||
* GET ?paciente_id=X&formulario_id=Y&exclude_turno_id=Z
|
||||
* Devuelve las respuestas del último formulario firmado de ese tipo para el paciente
|
||||
* (ej. "Datos Toma de Muestras"), para precargarlo en una visita de seguimiento
|
||||
* sin obligar a re-digitar la historia clínica. No modifica el registro original.
|
||||
*/
|
||||
require_once __DIR__ . '/_helpers.php';
|
||||
requireMethod('GET');
|
||||
requireTurnero();
|
||||
|
||||
$pacienteId = (int)($_GET['paciente_id'] ?? 0);
|
||||
$formularioId = (int)($_GET['formulario_id'] ?? 0);
|
||||
$excludeTurno = (int)($_GET['exclude_turno_id'] ?? 0);
|
||||
|
||||
if (!$pacienteId) jsonError('paciente_id requerido.');
|
||||
if (!$formularioId) jsonError('formulario_id requerido.');
|
||||
|
||||
$pdo = db();
|
||||
|
||||
$stmt = $pdo->prepare(
|
||||
"SELECT tc.datos_respuestas, tc.firmado_at, t.codigo AS turno_codigo
|
||||
FROM turnero_consentimientos tc
|
||||
JOIN turnero_turnos t ON t.id = tc.turno_id
|
||||
LEFT JOIN turnero_solicitudes ts ON ts.turno_id = t.id
|
||||
WHERE tc.formulario_id = ?
|
||||
AND tc.estado = 'firmado'
|
||||
AND COALESCE(ts.paciente_id, t.paciente_id) = ?
|
||||
AND t.id != ?
|
||||
ORDER BY tc.firmado_at DESC
|
||||
LIMIT 1"
|
||||
);
|
||||
$stmt->execute([$formularioId, $pacienteId, $excludeTurno]);
|
||||
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
||||
|
||||
if (!$row) {
|
||||
jsonOk(['encontrado' => false]);
|
||||
}
|
||||
|
||||
$datos = json_decode($row['datos_respuestas'] ?? '{}', true) ?: [];
|
||||
// No traer firmas ni identidad del profesional anterior: cada visita se firma de nuevo
|
||||
foreach (array_keys($datos) as $k) {
|
||||
if (str_contains($k, 'firma') || $k === '_pro_nombre' || $k === '_pro_cedula') {
|
||||
unset($datos[$k]);
|
||||
}
|
||||
}
|
||||
|
||||
jsonOk([
|
||||
'encontrado' => true,
|
||||
'datos_respuestas'=> $datos,
|
||||
'firmado_at' => $row['firmado_at'],
|
||||
'turno_codigo' => $row['turno_codigo'],
|
||||
]);
|
||||
@@ -21,12 +21,11 @@ requireMethod('GET');
|
||||
$pdo = db();
|
||||
|
||||
// ── Parámetros ────────────────────────────────────────────────
|
||||
$hoy = date('Y-m-d');
|
||||
$hace7 = date('Y-m-d', strtotime('-6 days'));
|
||||
$hoy = date('Y-m-d');
|
||||
|
||||
$fechaDesde = trim($_GET['fecha_desde'] ?? '');
|
||||
$fechaHasta = trim($_GET['fecha_hasta'] ?? '');
|
||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $fechaDesde)) $fechaDesde = $hace7;
|
||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $fechaDesde)) $fechaDesde = $hoy;
|
||||
if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $fechaHasta)) $fechaHasta = $hoy;
|
||||
if ($fechaDesde > $fechaHasta) $fechaDesde = $fechaHasta;
|
||||
|
||||
@@ -108,6 +107,8 @@ $sqlTurnos = "
|
||||
p.color AS prioridad_color,
|
||||
l.nombre AS lugar_nombre,
|
||||
s2.nombre_completo AS paciente_bd,
|
||||
s2.tipo_documento AS paciente_tipo_doc,
|
||||
s2.numero_documento AS paciente_doc,
|
||||
rd.nombre AS recepcion_desk_nombre,
|
||||
ur.full_name AS atendido_recepcion_nombre,
|
||||
ul.full_name AS atendido_lugar_nombre,
|
||||
@@ -117,7 +118,9 @@ $sqlTurnos = "
|
||||
AS espera_min,
|
||||
ROUND(TIMESTAMPDIFF(SECOND, t.inicio_lugar_at,
|
||||
COALESCE(t.fin_lugar_at, NOW())) / 60.0, 1)
|
||||
AS servicio_min
|
||||
AS servicio_min,
|
||||
ROUND(TIMESTAMPDIFF(SECOND, t.creado_at, t.fin_lugar_at) / 60.0, 1)
|
||||
AS total_min
|
||||
FROM turnero_turnos t
|
||||
JOIN turnero_sesiones s ON s.id = t.sesion_id
|
||||
JOIN turnero_prioridades p ON p.id = t.prioridad_id
|
||||
@@ -136,42 +139,107 @@ $stmtTurnos = $pdo->prepare($sqlTurnos);
|
||||
$stmtTurnos->execute($bindsPage);
|
||||
$turnos = $stmtTurnos->fetchAll(PDO::FETCH_ASSOC);
|
||||
|
||||
// ── Enriquecer con exámenes y consentimientos ──────────────────
|
||||
foreach ($turnos as &$turno) {
|
||||
// Exámenes
|
||||
// ── Enriquecer con exámenes, consentimientos, comentarios y tomas (batch) ─
|
||||
$turnoIds = array_column($turnos, 'id');
|
||||
$examenes = [];
|
||||
$consentimientos = [];
|
||||
$comentarios = [];
|
||||
$relacionados = [];
|
||||
|
||||
if ($turnoIds) {
|
||||
$ph = implode(',', array_fill(0, count($turnoIds), '?'));
|
||||
|
||||
// Exámenes — batch
|
||||
$stmtExam = $pdo->prepare(
|
||||
"SELECT DISTINCT et.nombre, et.codigo
|
||||
FROM turnero_examen_items tei
|
||||
"SELECT ts.turno_id, et.nombre, et.codigo
|
||||
FROM turnero_solicitudes ts
|
||||
JOIN turnero_examen_items tei ON tei.solicitud_id = ts.id
|
||||
JOIN exam_tipos et ON et.id = tei.exam_tipo_id
|
||||
WHERE tei.solicitud_id = (
|
||||
SELECT id FROM turnero_solicitudes WHERE turno_id = ? LIMIT 1
|
||||
)
|
||||
WHERE ts.turno_id IN ($ph)
|
||||
ORDER BY et.nombre ASC"
|
||||
);
|
||||
$stmtExam->execute([(int)$turno['id']]);
|
||||
$turno['examenes'] = $stmtExam->fetchAll(PDO::FETCH_ASSOC);
|
||||
$stmtExam->execute($turnoIds);
|
||||
foreach ($stmtExam->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$examenes[(int)$row['turno_id']][] = ['nombre' => $row['nombre'], 'codigo' => $row['codigo']];
|
||||
}
|
||||
|
||||
// Consentimientos
|
||||
// Consentimientos — batch
|
||||
$stmtCons = $pdo->prepare(
|
||||
"SELECT tc.id, f.nombre, tc.estado, tc.firmado_at
|
||||
"SELECT tc.turno_id, tc.id, f.nombre, tc.estado, tc.firmado_at
|
||||
FROM turnero_consentimientos tc
|
||||
LEFT JOIN lab_formularios f ON f.id = tc.formulario_id
|
||||
WHERE tc.turno_id = ?
|
||||
WHERE tc.turno_id IN ($ph)
|
||||
ORDER BY tc.estado DESC, tc.firmado_at DESC"
|
||||
);
|
||||
$stmtCons->execute([(int)$turno['id']]);
|
||||
$turno['consentimientos'] = $stmtCons->fetchAll(PDO::FETCH_ASSOC);
|
||||
$stmtCons->execute($turnoIds);
|
||||
foreach ($stmtCons->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$tid = (int)$row['turno_id'];
|
||||
unset($row['turno_id']);
|
||||
$consentimientos[$tid][] = $row;
|
||||
}
|
||||
|
||||
// Comentarios
|
||||
// Comentarios — batch (últimos 3 por turno via subquery)
|
||||
$stmtCom = $pdo->prepare(
|
||||
"SELECT usuario_nombre, comentario, tipo, creado_at
|
||||
FROM turnero_comentarios
|
||||
WHERE turno_id = ?
|
||||
ORDER BY creado_at DESC
|
||||
LIMIT 3"
|
||||
"SELECT tc.turno_id, tc.usuario_nombre, tc.comentario, tc.tipo, tc.creado_at
|
||||
FROM turnero_comentarios tc
|
||||
WHERE tc.turno_id IN ($ph)
|
||||
ORDER BY tc.turno_id, tc.creado_at ASC"
|
||||
);
|
||||
$stmtCom->execute([(int)$turno['id']]);
|
||||
$turno['comentarios'] = $stmtCom->fetchAll(PDO::FETCH_ASSOC);
|
||||
$stmtCom->execute($turnoIds);
|
||||
foreach ($stmtCom->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$tid = (int)$row['turno_id'];
|
||||
if (!isset($comentarios[$tid])) $comentarios[$tid] = [];
|
||||
// Se traen todos: cortar en 3 escondía notas —entre ellas el motivo de
|
||||
// una ausencia— sin que nada indicara que faltaban.
|
||||
if (count($comentarios[$tid]) < 20) {
|
||||
unset($row['turno_id']);
|
||||
$comentarios[$tid][] = $row;
|
||||
}
|
||||
}
|
||||
|
||||
// Turnos vinculados por muestras pendientes completadas en otra visita — batch (ambos sentidos)
|
||||
// Sentido "seguimiento": este turno tenía una muestra pendiente que se completó en OTRO turno
|
||||
$stmtRelFwd = $pdo->prepare(
|
||||
"SELECT DISTINCT ts.turno_id AS turno_id, t2.id AS rel_id, t2.codigo AS rel_codigo, s2.fecha AS rel_fecha
|
||||
FROM turnero_muestras tm
|
||||
JOIN turnero_solicitudes ts ON ts.id = tm.solicitud_id
|
||||
JOIN turnero_turnos t2 ON t2.id = tm.recibida_en_turno_id
|
||||
JOIN turnero_sesiones s2 ON s2.id = t2.sesion_id
|
||||
WHERE ts.turno_id IN ($ph) AND tm.recibida_en_turno_id IS NOT NULL"
|
||||
);
|
||||
$stmtRelFwd->execute($turnoIds);
|
||||
foreach ($stmtRelFwd->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$tid = (int)$row['turno_id'];
|
||||
$relacionados[$tid][] = [
|
||||
'turno_id' => (int)$row['rel_id'], 'codigo' => $row['rel_codigo'],
|
||||
'fecha' => $row['rel_fecha'], 'direccion' => 'seguimiento',
|
||||
];
|
||||
}
|
||||
// Sentido "origen": este turno completó una muestra pendiente de OTRO turno anterior
|
||||
$stmtRelBack = $pdo->prepare(
|
||||
"SELECT DISTINCT tm.recibida_en_turno_id AS turno_id, t1.id AS rel_id, t1.codigo AS rel_codigo, s1.fecha AS rel_fecha
|
||||
FROM turnero_muestras tm
|
||||
JOIN turnero_solicitudes ts ON ts.id = tm.solicitud_id
|
||||
JOIN turnero_turnos t1 ON t1.id = ts.turno_id
|
||||
JOIN turnero_sesiones s1 ON s1.id = t1.sesion_id
|
||||
WHERE tm.recibida_en_turno_id IN ($ph)"
|
||||
);
|
||||
$stmtRelBack->execute($turnoIds);
|
||||
foreach ($stmtRelBack->fetchAll(PDO::FETCH_ASSOC) as $row) {
|
||||
$tid = (int)$row['turno_id'];
|
||||
$relacionados[$tid][] = [
|
||||
'turno_id' => (int)$row['rel_id'], 'codigo' => $row['rel_codigo'],
|
||||
'fecha' => $row['rel_fecha'], 'direccion' => 'origen',
|
||||
];
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($turnos as &$turno) {
|
||||
$tid = (int)$turno['id'];
|
||||
$turno['examenes'] = $examenes[$tid] ?? [];
|
||||
$turno['consentimientos'] = $consentimientos[$tid] ?? [];
|
||||
$turno['comentarios'] = $comentarios[$tid] ?? [];
|
||||
$turno['relacionados'] = $relacionados[$tid] ?? [];
|
||||
}
|
||||
|
||||
// ── Resumen por estado (del rango filtrado) ───────────────────
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user