feat(lis): migración completa Firebird→MySQL + tracking de muestras en turnero

- Migraciones LIS 01-08: schema completo del nuevo LIS (secciones, protocolos,
  ítems de resultado, perfiles, tarifas, empresas, histórico transaccional)
- ETL Firebird→MySQL: script CLI con conversión WIN1252→UTF-8, batches de 500,
  resolución de FKs y deduplicación de pacientes
- turnero_muestras: tracking pendiente/recibida/rechazada por tipo de tubo
- lugar.php: widget de recepción de muestras (solo tipo=muestras)
- update_muestra_estado.php: API para marcar estado de muestra
- create_solicitud.php: auto-crea muestras al guardar solicitud
- get_consentimientos.php: incluye muestras[] en el response
- 6 vistas SQL: v_muestras_hoy, v_recepcion_completa, v_examen_precio, etc.
- numero_orden en encabezado del formulario firmado (D-/F- color diferenciado)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-07-04 21:44:22 -05:00
co-authored by Claude Sonnet 4.6
parent 86ed70db34
commit 5fedd233f4
18 changed files with 1754 additions and 4 deletions
@@ -0,0 +1,32 @@
-- =============================================================
-- LIS 01 — Catálogos base
-- Crea: lab_secciones, lab_especialidades, lab_tipos_muestra
-- =============================================================
CREATE TABLE IF NOT EXISTS lab_secciones (
codigo VARCHAR(10) NOT NULL,
nombre VARCHAR(100) NOT NULL,
PRIMARY KEY (codigo)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Secciones del laboratorio (Hematología, Química, etc.)';
-- -----------------------------------------------------------
CREATE TABLE IF NOT EXISTS lab_especialidades (
codigo VARCHAR(10) NOT NULL,
nombre VARCHAR(100) NOT NULL,
PRIMARY KEY (codigo)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Especialidades médicas — replica ESPECIALIDAD Firebird';
-- -----------------------------------------------------------
CREATE TABLE IF NOT EXISTS lab_tipos_muestra (
codigo VARCHAR(20) NOT NULL,
nombre VARCHAR(100) NOT NULL,
color_hex VARCHAR(7) DEFAULT NULL COMMENT 'Color del tubo para UI (#RRGGBB)',
requiere_ayuno TINYINT(1) NOT NULL DEFAULT 0,
activo TINYINT(1) NOT NULL DEFAULT 1,
PRIMARY KEY (codigo)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Tipos de muestra derivados de EXAMEN.TIPOMUESTRA Firebird';
+43
View File
@@ -0,0 +1,43 @@
-- =============================================================
-- LIS 02 — Protocolos e ítems de resultado (catálogos)
-- Crea: lab_protocolos, lab_items_resultado
-- Depende de: lab_secciones (LIS 01)
-- =============================================================
CREATE TABLE IF NOT EXISTS lab_protocolos (
codigo VARCHAR(20) NOT NULL,
nombre VARCHAR(150) NOT NULL,
cod_seccion VARCHAR(10) DEFAULT NULL,
id_planilla VARCHAR(30) DEFAULT NULL COMMENT 'Identificador de plantilla de impresión',
only_show_items TINYINT(1) NOT NULL DEFAULT 0,
PRIMARY KEY (codigo),
KEY idx_seccion (cod_seccion),
CONSTRAINT fk_proto_seccion
FOREIGN KEY (cod_seccion) REFERENCES lab_secciones (codigo)
ON DELETE SET NULL ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Plantillas de resultado — replica PROTOCOLO Firebird';
-- -----------------------------------------------------------
CREATE TABLE IF NOT EXISTS lab_items_resultado (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
cod_protocolo VARCHAR(20) NOT NULL,
nombre VARCHAR(150) NOT NULL,
tipo_sexo ENUM('M','F') DEFAULT NULL COMMENT 'NULL = aplica a ambos sexos',
tipo ENUM('N','T') DEFAULT NULL COMMENT 'N=numérico T=texto',
medida VARCHAR(30) DEFAULT NULL,
abreviatura VARCHAR(30) DEFAULT NULL,
vmin_ref DECIMAL(12,4) DEFAULT NULL,
vmax_ref DECIMAL(12,4) DEFAULT NULL,
orden SMALLINT NOT NULL DEFAULT 0,
formula VARCHAR(500) DEFAULT NULL COMMENT 'Fórmula de cálculo automático',
cups_detalle VARCHAR(20) DEFAULT NULL,
PRIMARY KEY (id),
KEY idx_protocolo (cod_protocolo),
KEY idx_orden (cod_protocolo, orden),
CONSTRAINT fk_item_proto
FOREIGN KEY (cod_protocolo) REFERENCES lab_protocolos (codigo)
ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Analitos por protocolo — replica ITEM Firebird';
+26
View File
@@ -0,0 +1,26 @@
-- =============================================================
-- LIS 03 — Perfiles de examen (paquetes)
-- Crea: lab_perfiles, lab_perfil_examenes
-- Depende de: exam_tipos (migración 003)
-- =============================================================
CREATE TABLE IF NOT EXISTS lab_perfiles (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
nombre VARCHAR(150) NOT NULL,
activo TINYINT(1) NOT NULL DEFAULT 1,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Paquetes de exámenes — replica PERFIL Firebird';
-- -----------------------------------------------------------
CREATE TABLE IF NOT EXISTS lab_perfil_examenes (
perfil_id INT UNSIGNED NOT NULL,
exam_tipo_id INT UNSIGNED NOT NULL,
PRIMARY KEY (perfil_id, exam_tipo_id),
CONSTRAINT fk_pe_perfil FOREIGN KEY (perfil_id)
REFERENCES lab_perfiles (id) ON DELETE CASCADE,
CONSTRAINT fk_pe_exam FOREIGN KEY (exam_tipo_id)
REFERENCES exam_tipos (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Exámenes por perfil — replica PERFIL_EXA Firebird';
@@ -0,0 +1,50 @@
-- =============================================================
-- LIS 04 — Ampliar exam_tipos y lab_pacientes con campos legacy
-- Depende de: lab_tipos_muestra (LIS 01), lab_protocolos (LIS 02)
-- =============================================================
-- -----------------------------------------------------------
-- exam_tipos: agregar campos del EXAMEN Firebird
-- -----------------------------------------------------------
ALTER TABLE exam_tipos
ADD COLUMN IF NOT EXISTS codigo_legacy VARCHAR(20) DEFAULT NULL
COMMENT 'Código original Firebird (CODIGO). Usado para ETL y mapeos.',
ADD COLUMN IF NOT EXISTS cups VARCHAR(20) DEFAULT NULL
COMMENT 'Código CUPS colombiano',
ADD COLUMN IF NOT EXISTS cod_protocolo VARCHAR(20) DEFAULT NULL
COMMENT 'FK lab_protocolos.codigo',
ADD COLUMN IF NOT EXISTS tipo_muestra VARCHAR(20) DEFAULT NULL
COMMENT 'FK lab_tipos_muestra.codigo',
ADD COLUMN IF NOT EXISTS nivel TINYINT DEFAULT NULL
COMMENT 'Nivel de complejidad (1/2/3)',
ADD COLUMN IF NOT EXISTS abreviatura VARCHAR(30) DEFAULT NULL,
ADD COLUMN IF NOT EXISTS seremite TINYINT(1) NOT NULL DEFAULT 0
COMMENT '1 = se remite a laboratorio externo',
ADD COLUMN IF NOT EXISTS serecibe VARCHAR(50) DEFAULT NULL
COMMENT 'Nombre del laboratorio donde se recibe si seremite=1';
-- Índice único para buscar por código legacy durante el ETL
ALTER TABLE exam_tipos
ADD UNIQUE KEY IF NOT EXISTS uq_codigo_legacy (codigo_legacy);
-- FK suave (no FK real para facilitar carga masiva del ETL)
ALTER TABLE exam_tipos
ADD KEY IF NOT EXISTS idx_cod_protocolo (cod_protocolo),
ADD KEY IF NOT EXISTS idx_tipo_muestra (tipo_muestra);
-- -----------------------------------------------------------
-- lab_pacientes: agregar campos del PACIENTE Firebird
-- -----------------------------------------------------------
ALTER TABLE lab_pacientes
ADD COLUMN IF NOT EXISTS codigo_legacy VARCHAR(20) DEFAULT NULL
COMMENT 'CODPAC Firebird',
ADD COLUMN IF NOT EXISTS codetnia VARCHAR(10) DEFAULT NULL
COMMENT 'Código de etnia (RIPS/SISPRO)',
ADD COLUMN IF NOT EXISTS tipores VARCHAR(10) DEFAULT NULL
COMMENT 'Tipo de residencia (RIPS/SISPRO)',
ADD COLUMN IF NOT EXISTS ocupacion VARCHAR(100) DEFAULT NULL,
ADD COLUMN IF NOT EXISTS es_historico TINYINT(1) NOT NULL DEFAULT 0
COMMENT '1 = migrado solo de Firebird, sin cuenta en nuevo sistema';
ALTER TABLE lab_pacientes
ADD KEY IF NOT EXISTS idx_codigo_legacy (codigo_legacy);
+97
View File
@@ -0,0 +1,97 @@
-- =============================================================
-- LIS 05 — Motor de precios y convenios
-- Crea: lab_tarifas_id, lab_tarifas, lab_empresas,
-- lab_empresa_subgrupos, lab_examenes_empresa
-- =============================================================
-- -----------------------------------------------------------
-- Catálogo de tarifas
-- -----------------------------------------------------------
CREATE TABLE IF NOT EXISTS lab_tarifas_id (
id INT NOT NULL,
nombre VARCHAR(150) NOT NULL,
tarifa_origen INT DEFAULT NULL
COMMENT 'Si != NULL, esta tarifa = tarifa_origen * (1 + porcentaje/100)',
porcentaje DECIMAL(8,4) NOT NULL DEFAULT 0
COMMENT '0 = precios fijos, >0 = derivada porcentualmente',
PRIMARY KEY (id),
KEY idx_origen (tarifa_origen)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Catálogo de tarifas — replica TARIFAID Firebird';
-- -----------------------------------------------------------
-- Precios por examen y tarifa
-- -----------------------------------------------------------
CREATE TABLE IF NOT EXISTS lab_tarifas (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
cod_examen_legacy VARCHAR(20) NOT NULL COMMENT 'CODIGO original Firebird para trazabilidad',
exam_tipo_id INT UNSIGNED DEFAULT NULL,
tarifa_id INT NOT NULL,
valor DECIMAL(12,2) NOT NULL,
recargo_urg DECIMAL(12,2) NOT NULL DEFAULT 0,
recargo_fes DECIMAL(12,2) NOT NULL DEFAULT 0,
recargo_esp DECIMAL(12,2) NOT NULL DEFAULT 0,
PRIMARY KEY (id),
UNIQUE KEY uq_examen_tarifa (exam_tipo_id, tarifa_id),
KEY idx_legacy (cod_examen_legacy),
KEY idx_tarifa (tarifa_id),
CONSTRAINT fk_tar_tarifa FOREIGN KEY (tarifa_id)
REFERENCES lab_tarifas_id (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Precios por examen y tarifa — replica TARIFA Firebird';
-- -----------------------------------------------------------
-- Empresas / convenios
-- -----------------------------------------------------------
CREATE TABLE IF NOT EXISTS lab_empresas (
nit VARCHAR(20) NOT NULL,
nombre VARCHAR(200) NOT NULL,
razon_social VARCHAR(200) DEFAULT NULL,
tarifa_id INT DEFAULT NULL,
descuento_pct DECIMAL(8,4) NOT NULL DEFAULT 0,
codigo_eps VARCHAR(20) DEFAULT NULL,
tipo_usuario VARCHAR(10) DEFAULT NULL COMMENT 'Tipo usuario para facturación',
tipo_usuario_sispro VARCHAR(10) DEFAULT NULL,
cod_contrato VARCHAR(50) DEFAULT NULL,
cod_tercero VARCHAR(50) DEFAULT NULL,
centro_costo VARCHAR(50) DEFAULT NULL,
req_autoriza TINYINT(1) NOT NULL DEFAULT 0
COMMENT '1 = exige número de autorización en recepción',
activa TINYINT(1) NOT NULL DEFAULT 1,
PRIMARY KEY (nit),
KEY idx_tarifa (tarifa_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Empresas y convenios — replica EMPRESA Firebird';
-- -----------------------------------------------------------
-- Subgrupos de empresa
-- -----------------------------------------------------------
CREATE TABLE IF NOT EXISTS lab_empresa_subgrupos (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
nit_empresa VARCHAR(20) NOT NULL,
subgrupo VARCHAR(100) NOT NULL,
tarifa_id INT DEFAULT NULL,
ref_subgrupo VARCHAR(50) DEFAULT NULL,
cod_contrato VARCHAR(50) DEFAULT NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_empresa_sub (nit_empresa, subgrupo),
KEY idx_tarifa (tarifa_id),
CONSTRAINT fk_esub_empresa FOREIGN KEY (nit_empresa)
REFERENCES lab_empresas (nit) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Subgrupos por empresa — replica EMPRESA_SUB Firebird';
-- -----------------------------------------------------------
-- Códigos de examen alternos por empresa (para interfaces)
-- -----------------------------------------------------------
CREATE TABLE IF NOT EXISTS lab_examenes_empresa (
nit_empresa VARCHAR(20) NOT NULL,
cod_examen_legacy VARCHAR(20) NOT NULL,
exam_tipo_id INT UNSIGNED DEFAULT NULL,
codigo_empresa VARCHAR(50) NOT NULL,
PRIMARY KEY (nit_empresa, cod_examen_legacy),
KEY idx_exam (exam_tipo_id),
CONSTRAINT fk_ee_empresa FOREIGN KEY (nit_empresa)
REFERENCES lab_empresas (nit) ON DELETE CASCADE ON UPDATE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Códigos alternos por empresa — replica EXAMEN_EMP Firebird';
+90
View File
@@ -0,0 +1,90 @@
-- =============================================================
-- LIS 06 — Histórico transaccional (solo lectura post-migración)
-- Crea: lab_recepciones, lab_relaciones, lab_pagos, lab_pagos_det
-- Depende de: lab_pacientes, medicos, lab_empresas
-- =============================================================
-- -----------------------------------------------------------
-- Recepciones (equivalente a facturas del legacy)
-- -----------------------------------------------------------
CREATE TABLE IF NOT EXISTS lab_recepciones (
id INT NOT NULL COMMENT 'IDRECEPCION original Firebird',
cod_paciente_legacy VARCHAR(20) DEFAULT NULL,
paciente_id INT DEFAULT NULL,
cod_medico_legacy VARCHAR(20) DEFAULT NULL,
medico_id INT UNSIGNED DEFAULT NULL,
nit_empresa VARCHAR(20) DEFAULT NULL,
subgrupo VARCHAR(100) DEFAULT NULL,
fecha_recepcion DATE NOT NULL,
hora_inicio TIME DEFAULT NULL,
prefijo VARCHAR(5) DEFAULT NULL,
num_factura INT DEFAULT NULL,
valor_total DECIMAL(12,2) NOT NULL DEFAULT 0,
valor_desc DECIMAL(12,2) NOT NULL DEFAULT 0,
diag_ppal VARCHAR(10) DEFAULT NULL COMMENT 'Código diagnóstico CIE-10',
tipo_usuario VARCHAR(10) DEFAULT NULL,
autorizacion VARCHAR(50) DEFAULT NULL,
usuario VARCHAR(50) DEFAULT NULL COMMENT 'Login del operador en Firebird',
es_historico TINYINT(1) NOT NULL DEFAULT 1,
migrado_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
KEY idx_paciente (paciente_id),
KEY idx_fecha (fecha_recepcion),
KEY idx_empresa (nit_empresa),
KEY idx_factura (prefijo, num_factura)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Recepciones históricas — solo lectura, migrado de Firebird';
-- -----------------------------------------------------------
-- Exámenes por recepción
-- -----------------------------------------------------------
CREATE TABLE IF NOT EXISTS lab_relaciones (
recepcion_id INT NOT NULL,
cod_examen_legacy VARCHAR(20) NOT NULL,
exam_tipo_id INT UNSIGNED DEFAULT NULL,
precio DECIMAL(12,2) NOT NULL DEFAULT 0,
fecha_reportado DATE DEFAULT NULL,
reportado TINYINT(1) NOT NULL DEFAULT 0,
reportado_por VARCHAR(50) DEFAULT NULL,
validado TINYINT(1) NOT NULL DEFAULT 0,
usuario_valida VARCHAR(50) DEFAULT NULL,
fecha_valida DATE DEFAULT NULL,
PRIMARY KEY (recepcion_id, cod_examen_legacy),
KEY idx_exam_tipo (exam_tipo_id),
CONSTRAINT fk_rel_recepcion FOREIGN KEY (recepcion_id)
REFERENCES lab_recepciones (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Exámenes por recepción histórica — replica RELACION Firebird';
-- -----------------------------------------------------------
-- Pagos
-- -----------------------------------------------------------
CREATE TABLE IF NOT EXISTS lab_pagos (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
numcaja_legacy INT NOT NULL UNIQUE COMMENT 'NUMCAJA original Firebird',
recepcion_id INT NOT NULL,
valor DECIMAL(12,2) NOT NULL,
fecha DATE NOT NULL,
usuario VARCHAR(50) DEFAULT NULL,
PRIMARY KEY (id),
KEY idx_recepcion (recepcion_id),
CONSTRAINT fk_pago_recep FOREIGN KEY (recepcion_id)
REFERENCES lab_recepciones (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Pagos históricos — replica PAGOS Firebird';
-- -----------------------------------------------------------
-- Detalle de formas de pago por transacción
-- -----------------------------------------------------------
CREATE TABLE IF NOT EXISTS lab_pagos_det (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
pago_id INT UNSIGNED NOT NULL,
tipo_pago VARCHAR(30) NOT NULL COMMENT 'efectivo, cheque, tarjeta, etc.',
valor DECIMAL(12,2) NOT NULL,
num_doc VARCHAR(50) DEFAULT NULL,
PRIMARY KEY (id),
KEY idx_pago (pago_id),
CONSTRAINT fk_pagdet_pago FOREIGN KEY (pago_id)
REFERENCES lab_pagos (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Detalle de formas de pago — replica PAGOS_DET Firebird';
@@ -0,0 +1,28 @@
-- =============================================================
-- LIS 07 — Tracking de muestras por turnero (solicitud)
-- Crea: turnero_muestras
-- Depende de: turnero_solicitudes (003), lab_tipos_muestra (LIS 01)
-- =============================================================
CREATE TABLE IF NOT EXISTS turnero_muestras (
id INT UNSIGNED NOT NULL AUTO_INCREMENT,
solicitud_id INT UNSIGNED NOT NULL
COMMENT 'FK turnero_solicitudes.id',
tipo_muestra VARCHAR(20) NOT NULL
COMMENT 'FK lab_tipos_muestra.codigo (ej: SANGRE_VENOSA, ORINA)',
estado ENUM('pendiente','recibida','rechazada')
NOT NULL DEFAULT 'pendiente',
motivo_rechazo VARCHAR(200) DEFAULT NULL
COMMENT 'Razón de rechazo (hemólisis, coagulado, volumen insuficiente…)',
recibida_por INT DEFAULT NULL
COMMENT 'FK admin_users.id — quién marcó recibida',
recibida_at DATETIME DEFAULT NULL,
creado_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uq_solicitud_tipo (solicitud_id, tipo_muestra),
KEY idx_estado (estado),
KEY idx_solicitud (solicitud_id),
CONSTRAINT fk_tm_solicitud FOREIGN KEY (solicitud_id)
REFERENCES turnero_solicitudes (id) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
COMMENT='Estado de muestras físicas por turno — solo lugar toma de muestras';
+186
View File
@@ -0,0 +1,186 @@
-- =============================================================
-- LIS 08 — Vistas del sistema
-- Todas usan CREATE OR REPLACE para poder re-ejecutar sin error.
-- =============================================================
-- -----------------------------------------------------------
-- v_muestras_hoy
-- Muestras del día en el turnero (estado en tiempo real).
-- Usada por: dashboard, widget lugar.php
-- -----------------------------------------------------------
CREATE OR REPLACE VIEW v_muestras_hoy AS
SELECT
tm.id,
tm.solicitud_id,
tm.tipo_muestra,
COALESCE(lt.nombre, tm.tipo_muestra) AS tipo_muestra_label,
lt.color_hex AS tipo_muestra_color,
tm.estado,
tm.motivo_rechazo,
tm.recibida_at,
ts.turno_id,
tt.codigo AS turno_codigo,
ts.lugar_id,
tl.nombre AS lugar_nombre,
ts.paciente_id,
lp.nombre_completo AS paciente_nombre,
lp.numero_documento AS paciente_documento,
DATE(tt.creado_at) AS fecha
FROM turnero_muestras tm
JOIN turnero_solicitudes ts ON ts.id = tm.solicitud_id
JOIN turnero_turnos tt ON tt.id = ts.turno_id
JOIN turnero_lugares tl ON tl.id = ts.lugar_id
LEFT JOIN lab_pacientes lp ON lp.id = ts.paciente_id
LEFT JOIN lab_tipos_muestra lt ON lt.codigo = tm.tipo_muestra;
-- -----------------------------------------------------------
-- v_muestras_pendientes_hoy
-- Solo las que faltan recibir hoy.
-- Usada por: contador en dashboard, alerta visual
-- -----------------------------------------------------------
CREATE OR REPLACE VIEW v_muestras_pendientes_hoy AS
SELECT *
FROM v_muestras_hoy
WHERE estado = 'pendiente'
AND fecha = CURDATE();
-- -----------------------------------------------------------
-- v_recepcion_completa
-- Histórico Firebird con todas las FK resueltas.
-- Usada por: módulo de consulta histórica (solo lectura)
-- -----------------------------------------------------------
CREATE OR REPLACE VIEW v_recepcion_completa AS
SELECT
r.id,
r.fecha_recepcion,
r.hora_inicio,
r.prefijo,
r.num_factura,
CONCAT(COALESCE(r.prefijo,''), '-',
LPAD(COALESCE(r.num_factura, 0), 6, '0')) AS factura,
r.valor_total,
r.valor_desc,
(r.valor_total - r.valor_desc) AS valor_neto,
r.paciente_id,
r.cod_paciente_legacy,
COALESCE(lp.nombre_completo, r.cod_paciente_legacy) AS paciente_nombre,
lp.numero_documento AS paciente_documento,
lp.tipo_documento AS paciente_tipo_doc,
r.medico_id,
r.cod_medico_legacy,
CONCAT(COALESCE(m.nombres,''), ' ', COALESCE(m.apellidos,'')) AS medico_nombre,
m.cod_especialidad AS medico_especialidad,
r.nit_empresa,
COALESCE(e.nombre, r.nit_empresa) AS empresa_nombre,
r.subgrupo,
r.diag_ppal,
r.tipo_usuario,
r.autorizacion,
r.usuario
FROM lab_recepciones r
LEFT JOIN lab_pacientes lp ON lp.id = r.paciente_id
LEFT JOIN medicos m ON m.id = r.medico_id
LEFT JOIN lab_empresas e ON e.nit = r.nit_empresa;
-- -----------------------------------------------------------
-- v_relacion_completa
-- Exámenes por recepción con nombre resuelto.
-- -----------------------------------------------------------
CREATE OR REPLACE VIEW v_relacion_completa AS
SELECT
lr.recepcion_id,
r.fecha_recepcion,
r.prefijo,
r.num_factura,
lr.cod_examen_legacy,
lr.exam_tipo_id,
et.nombre AS examen_nombre,
et.categoria AS examen_categoria,
et.tipo_muestra AS tipo_muestra,
lr.precio,
lr.reportado,
lr.fecha_reportado,
lr.reportado_por,
lr.validado,
lr.usuario_valida,
lr.fecha_valida
FROM lab_relaciones lr
JOIN lab_recepciones r ON r.id = lr.recepcion_id
LEFT JOIN exam_tipos et ON et.id = lr.exam_tipo_id;
-- -----------------------------------------------------------
-- v_examen_precio
-- Precio efectivo de cada examen en cada tarifa.
-- Resuelve tarifas derivadas por porcentaje.
-- Usada por: motor de precios en nueva recepción
-- -----------------------------------------------------------
CREATE OR REPLACE VIEW v_examen_precio AS
SELECT
et.id AS exam_tipo_id,
et.codigo,
et.codigo_legacy,
et.nombre AS examen_nombre,
et.categoria,
et.tipo_muestra,
ti.id AS tarifa_id,
ti.nombre AS tarifa_nombre,
ti.porcentaje,
ti.tarifa_origen,
lt.valor AS valor_almacenado,
CASE
WHEN ti.porcentaje > 0 AND ti.tarifa_origen IS NOT NULL
AND lt_base.valor IS NOT NULL
THEN ROUND(lt_base.valor * (1 + ti.porcentaje / 100), 0)
ELSE lt.valor
END AS valor_efectivo,
lt.recargo_urg,
lt.recargo_fes,
lt.recargo_esp
FROM exam_tipos et
JOIN lab_tarifas lt ON lt.exam_tipo_id = et.id
JOIN lab_tarifas_id ti ON ti.id = lt.tarifa_id
LEFT JOIN lab_tarifas lt_base ON lt_base.exam_tipo_id = et.id
AND lt_base.tarifa_id = ti.tarifa_origen;
-- -----------------------------------------------------------
-- v_paciente_resumen
-- Vista unificada: pacientes del nuevo sistema + migrados.
-- -----------------------------------------------------------
CREATE OR REPLACE VIEW v_paciente_resumen AS
SELECT
id,
nombre_completo,
tipo_documento,
numero_documento,
telefono,
email,
fecha_nacimiento,
genero,
ciudad,
eps,
es_historico,
codigo_legacy,
created_at
FROM lab_pacientes
WHERE is_active = 1;
-- -----------------------------------------------------------
-- v_turno_muestras_estado
-- Estado agregado de muestras por turno (para cola y dashboard).
-- -----------------------------------------------------------
CREATE OR REPLACE VIEW v_turno_muestras_estado AS
SELECT
ts.turno_id,
COUNT(*) AS total_muestras,
SUM(tm.estado = 'pendiente') AS pendientes,
SUM(tm.estado = 'recibida') AS recibidas,
SUM(tm.estado = 'rechazada') AS rechazadas,
CASE
WHEN SUM(tm.estado = 'pendiente') = 0 THEN 'completo'
WHEN SUM(tm.estado = 'recibida') = 0 AND SUM(tm.estado = 'rechazada') = 0
THEN 'sin_recibir'
ELSE 'parcial'
END AS estado_global
FROM turnero_muestras tm
JOIN turnero_solicitudes ts ON ts.id = tm.solicitud_id
GROUP BY ts.turno_id;
+2
View File
@@ -0,0 +1,2 @@
config.local.php
etl_*.log
+42
View File
@@ -0,0 +1,42 @@
<?php
/**
* Configuración de conexiones para el ETL Firebird → MySQL
*
* Antes de ejecutar:
* 1. Copiar este archivo como config.local.php (está en .gitignore)
* 2. Llenar los valores reales
* 3. Ejecutar: php run_etl.php
*/
return [
// ── Firebird (origen) ────────────────────────────────────────────────
'firebird' => [
// DSN para PDO: "firebird:dbname=HOST:RUTA_AL_FDB;charset=WIN1252"
// Si el FDB está en la misma máquina: "firebird:dbname=localhost:/opt/firebird/data/DBLAB.FDB"
// Si es una ruta Windows remota: "firebird:dbname=192.168.1.10:C:/datos/DBLAB_XIMENA_FB25.FDB"
'dsn' => 'firebird:dbname=localhost:/ruta/DBLAB_XIMENA_FB25.FDB;charset=WIN1252',
'user' => 'SYSDBA',
'password' => 'masterkey',
// Encoding declarado en el FDB (para convertir a UTF-8 durante la extracción)
'charset' => 'WIN1252',
],
// ── MySQL (destino) ──────────────────────────────────────────────────
'mysql' => [
'host' => '127.0.0.1',
'port' => 3306,
'dbname' => 'whatsapp', // nombre de la BD del nuevo sistema
'user' => 'root',
'password' => '',
'charset' => 'utf8mb4',
],
// ── Opciones de migración ────────────────────────────────────────────
'options' => [
'batch_size' => 500, // registros por INSERT batch
'dry_run' => false, // true = solo leer, no insertar
'skip_historico' => false, // true = saltar RECEPCION/RELACION/PAGOS
'log_file' => __DIR__ . '/etl_' . date('Ymd_His') . '.log',
],
];
+90
View File
@@ -0,0 +1,90 @@
<?php
/**
* Funciones auxiliares compartidas por el ETL
*/
/**
* Convierte un string de WIN1252 a UTF-8.
* Si el valor ya es UTF-8 válido, lo devuelve sin cambios.
*/
function toUtf8(?string $val, string $srcEncoding = 'WIN1252'): ?string {
if ($val === null) return null;
if (mb_check_encoding($val, 'UTF-8')) return $val;
return iconv($srcEncoding, 'UTF-8//TRANSLIT//IGNORE', $val);
}
/**
* Convierte un array completo de strings (resultado de Firebird) a UTF-8.
*/
function rowToUtf8(array $row, string $srcEncoding = 'WIN1252'): array {
foreach ($row as $k => $v) {
if (is_string($v)) {
$row[$k] = toUtf8($v, $srcEncoding);
}
}
return $row;
}
/**
* Limpia un VARCHAR de Firebird: recorta espacios y convierte encoding.
*/
function cleanStr(?string $val, string $srcEncoding = 'WIN1252'): ?string {
if ($val === null) return null;
$val = trim(toUtf8($val, $srcEncoding) ?? '');
return $val === '' ? null : $val;
}
/**
* Convierte fecha Firebird (puede ser DATE o string 'YYYY-MM-DD') a MySQL DATE.
* Devuelve null si la fecha es inválida o vacía.
*/
function fbDate($val): ?string {
if ($val === null) return null;
if ($val instanceof DateTime) return $val->format('Y-m-d');
$str = trim((string)$val);
if ($str === '' || $str === '0000-00-00') return null;
try {
return (new DateTime($str))->format('Y-m-d');
} catch (\Exception $e) {
return null;
}
}
/**
* INSERT en batch.
* $pdo → conexión MySQL
* $table → nombre de la tabla
* $rows → array de arrays asociativos con los mismos keys
* $ignore → usa INSERT IGNORE para saltar duplicados
*/
function batchInsert(PDO $pdo, string $table, array $rows, bool $ignore = false): int {
if (empty($rows)) return 0;
$cols = array_keys($rows[0]);
$colList = implode(', ', array_map(fn($c) => "`$c`", $cols));
$phRow = '(' . implode(', ', array_fill(0, count($cols), '?')) . ')';
$inserted = 0;
foreach (array_chunk($rows, 500) as $chunk) {
$placeholders = implode(', ', array_fill(0, count($chunk), $phRow));
$keyword = $ignore ? 'INSERT IGNORE' : 'INSERT';
$sql = "$keyword INTO `$table` ($colList) VALUES $placeholders";
$flat = array_merge(...array_map('array_values', $chunk));
$stmt = $pdo->prepare($sql);
$stmt->execute($flat);
$inserted += $stmt->rowCount();
}
return $inserted;
}
/** Escribe al log y a stdout simultáneamente. */
function etlLog(string $msg, $logFp = null): void {
$line = '[' . date('H:i:s') . '] ' . $msg . PHP_EOL;
echo $line;
if ($logFp) fwrite($logFp, $line);
}
/** Devuelve conteo de filas de una tabla Firebird. */
function fbCount(PDO $fb, string $table): int {
return (int) $fb->query("SELECT COUNT(*) FROM $table")->fetchColumn();
}
+715
View File
@@ -0,0 +1,715 @@
#!/usr/bin/env php
<?php
/**
* ETL Firebird 2.5 → MySQL (utf8mb4)
* Sistema: DBLAB_XIMENA_FB25 → nuevo sistema WhatsApp-Lab
*
* Uso:
* php run_etl.php [--dry-run] [--skip-historico] [--only=PASO]
*
* Pasos disponibles (--only):
* secciones | especialidades | tipos_muestra | protocolos | items |
* perfiles | exam_tipos | tarifas | empresas |
* medicos | pacientes | recepciones | relaciones | pagos
*
* Requerimientos:
* - PHP 8.0+ con extensión PDO_Firebird (php-firebird) instalada
* - Archivo config.local.php con credenciales reales
* - Las migraciones LIS 01-07 ya ejecutadas en MySQL
*/
declare(strict_types=1);
set_time_limit(0);
ini_set('memory_limit', '512M');
require_once __DIR__ . '/helpers.php';
// ── Configuración ────────────────────────────────────────────────────────────
$cfgFile = file_exists(__DIR__ . '/config.local.php')
? __DIR__ . '/config.local.php'
: __DIR__ . '/config.php';
$cfg = require $cfgFile;
// Argumentos CLI
$args = array_slice($argv ?? [], 1);
$dryRun = in_array('--dry-run', $args, true) || $cfg['options']['dry_run'];
$skipHist = in_array('--skip-historico', $args, true) || $cfg['options']['skip_historico'];
$onlyPaso = null;
foreach ($args as $arg) {
if (str_starts_with($arg, '--only=')) {
$onlyPaso = strtolower(substr($arg, 7));
}
}
$logFp = fopen($cfg['options']['log_file'], 'w');
etlLog('=== ETL Firebird → MySQL ===', $logFp);
etlLog("DRY_RUN: " . ($dryRun ? 'SÍ' : 'NO'), $logFp);
if ($onlyPaso) etlLog("Solo paso: $onlyPaso", $logFp);
// ── Conexiones ───────────────────────────────────────────────────────────────
try {
$fb = new PDO(
$cfg['firebird']['dsn'],
$cfg['firebird']['user'],
$cfg['firebird']['password'],
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION]
);
etlLog('Conexión Firebird OK', $logFp);
} catch (\Throwable $e) {
etlLog('ERROR conectando Firebird: ' . $e->getMessage(), $logFp);
exit(1);
}
try {
$my = new PDO(
"mysql:host={$cfg['mysql']['host']};port={$cfg['mysql']['port']};dbname={$cfg['mysql']['dbname']};charset=utf8mb4",
$cfg['mysql']['user'],
$cfg['mysql']['password'],
[PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION, PDO::ATTR_EMULATE_PREPARES => true]
);
$my->exec("SET NAMES utf8mb4");
$my->exec("SET foreign_key_checks = 0");
etlLog('Conexión MySQL OK', $logFp);
} catch (\Throwable $e) {
etlLog('ERROR conectando MySQL: ' . $e->getMessage(), $logFp);
exit(1);
}
$charset = $cfg['firebird']['charset'];
// ── Función auxiliar: ¿ejecutar este paso? ──────────────────────────────────
function shouldRun(string $paso, ?string $only): bool {
return $only === null || $only === $paso;
}
// ════════════════════════════════════════════════════════════════════════════
// PASO 1 — SECCION → lab_secciones
// ════════════════════════════════════════════════════════════════════════════
if (shouldRun('secciones', $onlyPaso)) {
etlLog('--- SECCION → lab_secciones ---', $logFp);
$total = fbCount($fb, 'SECCION');
etlLog(" Origen: $total registros", $logFp);
$rows = [];
foreach ($fb->query('SELECT CODSECCION, NOMBSECCION FROM SECCION') as $r) {
$rows[] = [
'codigo' => cleanStr($r['CODSECCION'], $charset),
'nombre' => cleanStr($r['NOMBSECCION'], $charset),
];
}
if (!$dryRun) {
$n = batchInsert($my, 'lab_secciones', $rows, true);
etlLog(" Insertados: $n", $logFp);
} else {
etlLog(" [DRY] Se insertarían " . count($rows), $logFp);
}
}
// ════════════════════════════════════════════════════════════════════════════
// PASO 2 — ESPECIALIDAD → lab_especialidades
// ════════════════════════════════════════════════════════════════════════════
if (shouldRun('especialidades', $onlyPaso)) {
etlLog('--- ESPECIALIDAD → lab_especialidades ---', $logFp);
$rows = [];
foreach ($fb->query('SELECT CODESPECIA, NOMBESPECIA FROM ESPECIALIDAD') as $r) {
$rows[] = [
'codigo' => cleanStr($r['CODESPECIA'], $charset),
'nombre' => cleanStr($r['NOMBESPECIA'], $charset),
];
}
if (!$dryRun) {
$n = batchInsert($my, 'lab_especialidades', $rows, true);
etlLog(" Insertados: $n / " . count($rows), $logFp);
}
}
// ════════════════════════════════════════════════════════════════════════════
// PASO 3 — EXAMEN.TIPOMUESTRA → lab_tipos_muestra
// ════════════════════════════════════════════════════════════════════════════
if (shouldRun('tipos_muestra', $onlyPaso)) {
etlLog('--- EXAMEN.TIPOMUESTRA → lab_tipos_muestra ---', $logFp);
$rows = [];
$seen = [];
foreach ($fb->query('SELECT DISTINCT TIPOMUESTRA FROM EXAMEN WHERE TIPOMUESTRA IS NOT NULL') as $r) {
$cod = cleanStr($r['TIPOMUESTRA'], $charset);
if (!$cod || isset($seen[$cod])) continue;
$seen[$cod] = true;
$rows[] = [
'codigo' => $cod,
'nombre' => ucwords(strtolower($cod)), // nombre provisional, editar luego
];
}
etlLog(" Tipos únicos encontrados: " . count($rows), $logFp);
if (!$dryRun) {
$n = batchInsert($my, 'lab_tipos_muestra', $rows, true);
etlLog(" Insertados: $n", $logFp);
}
}
// ════════════════════════════════════════════════════════════════════════════
// PASO 4 — TARIFAID → lab_tarifas_id
// ════════════════════════════════════════════════════════════════════════════
if (shouldRun('tarifas', $onlyPaso)) {
etlLog('--- TARIFAID → lab_tarifas_id ---', $logFp);
$rows = [];
foreach ($fb->query('SELECT IDTARIFA, NOMTARIFA, IDTARIFABASE, PORCENTAJE FROM TARIFAID') as $r) {
$rows[] = [
'id' => (int)$r['IDTARIFA'],
'nombre' => cleanStr($r['NOMTARIFA'], $charset),
'tarifa_origen' => $r['IDTARIFABASE'] ? (int)$r['IDTARIFABASE'] : null,
'porcentaje' => (float)($r['PORCENTAJE'] ?? 0),
];
}
if (!$dryRun) {
$n = batchInsert($my, 'lab_tarifas_id', $rows, true);
etlLog(" Insertados: $n / " . count($rows), $logFp);
}
// ── TARIFA → lab_tarifas (104k registros, se hace en batches) ────────
etlLog('--- TARIFA → lab_tarifas ---', $logFp);
$total = fbCount($fb, 'TARIFA');
etlLog(" Origen: $total registros", $logFp);
$stmt = $fb->query('SELECT CODIGO, IDTARIFA, VALOR, RECARGO_URG, RECARGO_FES, RECARGO_ESP FROM TARIFA');
$batch = [];
$count = 0;
while ($r = $stmt->fetch(PDO::FETCH_ASSOC)) {
$batch[] = [
'cod_examen_legacy' => cleanStr($r['CODIGO'], $charset),
'exam_tipo_id' => null, // se resuelve en paso 6 (exam_tipos)
'tarifa_id' => (int)$r['IDTARIFA'],
'valor' => (float)($r['VALOR'] ?? 0),
'recargo_urg' => (float)($r['RECARGO_URG'] ?? 0),
'recargo_fes' => (float)($r['RECARGO_FES'] ?? 0),
'recargo_esp' => (float)($r['RECARGO_ESP'] ?? 0),
];
if (count($batch) >= 500) {
if (!$dryRun) $count += batchInsert($my, 'lab_tarifas', $batch, true);
$batch = [];
}
}
if ($batch && !$dryRun) $count += batchInsert($my, 'lab_tarifas', $batch, true);
etlLog(" Insertados: $count", $logFp);
}
// ════════════════════════════════════════════════════════════════════════════
// PASO 5 — PROTOCOLO + ITEM → lab_protocolos + lab_items_resultado
// ════════════════════════════════════════════════════════════════════════════
if (shouldRun('protocolos', $onlyPaso)) {
etlLog('--- PROTOCOLO → lab_protocolos ---', $logFp);
$rows = [];
foreach ($fb->query('SELECT CODPROTOCOLO, NOMPROTOCOLO, CODSECCION, IDPLANILLA, ONLYITEMS FROM PROTOCOLO') as $r) {
$rows[] = [
'codigo' => cleanStr($r['CODPROTOCOLO'], $charset),
'nombre' => cleanStr($r['NOMPROTOCOLO'], $charset),
'cod_seccion' => cleanStr($r['CODSECCION'], $charset),
'id_planilla' => cleanStr($r['IDPLANILLA'], $charset),
'only_show_items' => $r['ONLYITEMS'] ? 1 : 0,
];
}
if (!$dryRun) {
$n = batchInsert($my, 'lab_protocolos', $rows, true);
etlLog(" Protocolos insertados: $n / " . count($rows), $logFp);
}
}
if (shouldRun('items', $onlyPaso)) {
etlLog('--- ITEM → lab_items_resultado ---', $logFp);
$total = fbCount($fb, 'ITEM');
etlLog(" Origen: $total registros", $logFp);
$stmt = $fb->query('SELECT CODPROTOCOLO,NOMITEM,TIPOSEXO,TIPO,MEDIDA,ABREVITEM,VMINREF,VMAXREF,ORDEN,FORMULA,CUPS FROM ITEM ORDER BY CODPROTOCOLO, ORDEN');
$batch = [];
$count = 0;
while ($r = $stmt->fetch(PDO::FETCH_ASSOC)) {
$batch[] = [
'cod_protocolo' => cleanStr($r['CODPROTOCOLO'], $charset),
'nombre' => cleanStr($r['NOMITEM'], $charset),
'tipo_sexo' => cleanStr($r['TIPOSEXO'], $charset),
'tipo' => cleanStr($r['TIPO'], $charset),
'medida' => cleanStr($r['MEDIDA'], $charset),
'abreviatura' => cleanStr($r['ABREVITEM'], $charset),
'vmin_ref' => is_numeric($r['VMINREF']) ? (float)$r['VMINREF'] : null,
'vmax_ref' => is_numeric($r['VMAXREF']) ? (float)$r['VMAXREF'] : null,
'orden' => (int)($r['ORDEN'] ?? 0),
'formula' => cleanStr($r['FORMULA'], $charset),
'cups_detalle' => cleanStr($r['CUPS'], $charset),
];
if (count($batch) >= 500) {
if (!$dryRun) $count += batchInsert($my, 'lab_items_resultado', $batch, true);
$batch = [];
}
}
if ($batch && !$dryRun) $count += batchInsert($my, 'lab_items_resultado', $batch, true);
etlLog(" Insertados: $count", $logFp);
}
// ════════════════════════════════════════════════════════════════════════════
// PASO 6 — PERFIL + PERFIL_EXA → lab_perfiles + lab_perfil_examenes
// ════════════════════════════════════════════════════════════════════════════
if (shouldRun('perfiles', $onlyPaso)) {
etlLog('--- PERFIL → lab_perfiles ---', $logFp);
$rows = [];
foreach ($fb->query('SELECT CODPERFIL, NOMPERFIL FROM PERFIL') as $r) {
$rows[] = [
'nombre' => cleanStr($r['NOMPERFIL'], $charset),
];
}
// Firebird usa VARCHAR código; MySQL usa INT AUTO_INCREMENT
// Guardamos el código viejo → id nuevo en memoria para PERFIL_EXA
if (!$dryRun) {
// Insertar uno a uno para mapear código → id nuevo
$mapaPerfiles = [];
foreach ($fb->query('SELECT CODPERFIL, NOMPERFIL FROM PERFIL') as $r) {
$stmt2 = $my->prepare('INSERT IGNORE INTO lab_perfiles (nombre) VALUES (?)');
$stmt2->execute([cleanStr($r['NOMPERFIL'], $charset)]);
$newId = (int)$my->lastInsertId();
if ($newId) $mapaPerfiles[cleanStr($r['CODPERFIL'], $charset)] = $newId;
}
etlLog(" Perfiles insertados: " . count($mapaPerfiles), $logFp);
// PERFIL_EXA — mapear cod_examen → exam_tipo_id
etlLog('--- PERFIL_EXA → lab_perfil_examenes ---', $logFp);
$examMap = [];
foreach ($my->query('SELECT id, codigo_legacy FROM exam_tipos WHERE codigo_legacy IS NOT NULL') as $r) {
$examMap[$r['codigo_legacy']] = (int)$r['id'];
}
$peBatch = [];
foreach ($fb->query('SELECT CODPERFIL, CODEXAMEN FROM PERFIL_EXA') as $r) {
$pId = $mapaPerfiles[cleanStr($r['CODPERFIL'], $charset)] ?? null;
$eId = $examMap[cleanStr($r['CODEXAMEN'], $charset)] ?? null;
if ($pId && $eId) {
$peBatch[] = ['perfil_id' => $pId, 'exam_tipo_id' => $eId];
}
}
$n = batchInsert($my, 'lab_perfil_examenes', $peBatch, true);
etlLog(" Relaciones perfil-examen: $n", $logFp);
}
}
// ════════════════════════════════════════════════════════════════════════════
// PASO 7 — EXAMEN → exam_tipos (ampliar con campos legacy)
// ════════════════════════════════════════════════════════════════════════════
if (shouldRun('exam_tipos', $onlyPaso)) {
etlLog('--- EXAMEN → exam_tipos ---', $logFp);
$total = fbCount($fb, 'EXAMEN');
etlLog(" Origen: $total registros", $logFp);
$insertados = 0;
$actualizados = 0;
$stmt = $fb->query(
'SELECT CODIGO, NOMEXAMEN, CODPROT, TIPOMUESTRA, NIVEL, CUPS,
ABREVEXAMEN, SEREMITE, SERECIBE, CODSECCION
FROM EXAMEN'
);
$checkStmt = $my->prepare('SELECT id FROM exam_tipos WHERE codigo_legacy = ?');
$updStmt = $my->prepare(
'UPDATE exam_tipos SET
cod_protocolo = ?, tipo_muestra = ?, nivel = ?, cups = ?,
abreviatura = ?, seremite = ?, serecibe = ?
WHERE codigo_legacy = ?'
);
$insStmt = $my->prepare(
'INSERT IGNORE INTO exam_tipos
(codigo, nombre, categoria, codigo_legacy, cod_protocolo,
tipo_muestra, nivel, cups, abreviatura, seremite, serecibe)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)'
);
while ($r = $stmt->fetch(PDO::FETCH_ASSOC)) {
$codLeg = cleanStr($r['CODIGO'], $charset);
$nombre = cleanStr($r['NOMEXAMEN'], $charset);
$proto = cleanStr($r['CODPROT'], $charset);
$tMuest = cleanStr($r['TIPOMUESTRA'], $charset);
$nivel = $r['NIVEL'] ? (int)$r['NIVEL'] : null;
$cups = cleanStr($r['CUPS'], $charset);
$abrev = cleanStr($r['ABREVEXAMEN'], $charset);
$serem = $r['SEREMITE'] ? 1 : 0;
$serec = cleanStr($r['SERECIBE'], $charset);
$seccion = cleanStr($r['CODSECCION'], $charset);
if (!$codLeg || !$nombre) continue;
$checkStmt->execute([$codLeg]);
$existing = $checkStmt->fetchColumn();
if ($dryRun) continue;
if ($existing) {
$updStmt->execute([$proto, $tMuest, $nivel, $cups, $abrev, $serem, $serec, $codLeg]);
$actualizados++;
} else {
// Usar el código Firebird como código del nuevo sistema (si no hay conflicto)
$insStmt->execute([$codLeg, $nombre, $seccion, $codLeg, $proto, $tMuest, $nivel, $cups, $abrev, $serem, $serec]);
$insertados++;
}
}
etlLog(" Nuevos: $insertados | Actualizados: $actualizados", $logFp);
// Resolver exam_tipo_id en lab_tarifas (ahora que los exámenes ya están)
if (!$dryRun) {
etlLog(' Resolviendo exam_tipo_id en lab_tarifas...', $logFp);
$updated = $my->exec(
'UPDATE lab_tarifas t
JOIN exam_tipos e ON e.codigo_legacy = t.cod_examen_legacy
SET t.exam_tipo_id = e.id
WHERE t.exam_tipo_id IS NULL'
);
etlLog(" lab_tarifas actualizadas: $updated filas", $logFp);
}
}
// ════════════════════════════════════════════════════════════════════════════
// PASO 8 — EMPRESA + EMPRESA_SUB + EXAMEN_EMP
// ════════════════════════════════════════════════════════════════════════════
if (shouldRun('empresas', $onlyPaso)) {
etlLog('--- EMPRESA → lab_empresas ---', $logFp);
$rows = [];
foreach ($fb->query(
'SELECT NIT,NOMBRE,RAZSOCIAL,IDTARIFA,DESCUENTO,CODEEPS,
TIPOUSUARIO,TIPOUSUARIOSISPRO,CODCONTRATO,CODTERCERO,
CENTROCOSTO,REQAUTORIZA,ACTIVA
FROM EMPRESA'
) as $r) {
$rows[] = [
'nit' => cleanStr($r['NIT'], $charset),
'nombre' => cleanStr($r['NOMBRE'], $charset),
'razon_social' => cleanStr($r['RAZSOCIAL'], $charset),
'tarifa_id' => $r['IDTARIFA'] ? (int)$r['IDTARIFA'] : null,
'descuento_pct' => (float)($r['DESCUENTO'] ?? 0),
'codigo_eps' => cleanStr($r['CODEEPS'], $charset),
'tipo_usuario' => cleanStr($r['TIPOUSUARIO'], $charset),
'tipo_usuario_sispro' => cleanStr($r['TIPOUSUARIOSISPRO'],$charset),
'cod_contrato' => cleanStr($r['CODCONTRATO'], $charset),
'cod_tercero' => cleanStr($r['CODTERCERO'], $charset),
'centro_costo' => cleanStr($r['CENTROCOSTO'], $charset),
'req_autoriza' => $r['REQAUTORIZA'] ? 1 : 0,
'activa' => $r['ACTIVA'] ? 1 : 0,
];
}
if (!$dryRun) {
$n = batchInsert($my, 'lab_empresas', $rows, true);
etlLog(" Empresas: $n / " . count($rows), $logFp);
}
etlLog('--- EMPRESA_SUB → lab_empresa_subgrupos ---', $logFp);
$rows = [];
foreach ($fb->query('SELECT NIT,SUBGRUPO,IDTARIFA,REF_SUBGRUPO,CODCONTRATO FROM EMPRESA_SUB') as $r) {
$rows[] = [
'nit_empresa' => cleanStr($r['NIT'], $charset),
'subgrupo' => cleanStr($r['SUBGRUPO'], $charset),
'tarifa_id' => $r['IDTARIFA'] ? (int)$r['IDTARIFA'] : null,
'ref_subgrupo' => cleanStr($r['REF_SUBGRUPO'],$charset),
'cod_contrato' => cleanStr($r['CODCONTRATO'], $charset),
];
}
if (!$dryRun) {
$n = batchInsert($my, 'lab_empresa_subgrupos', $rows, true);
etlLog(" Subgrupos: $n", $logFp);
}
etlLog('--- EXAMEN_EMP → lab_examenes_empresa ---', $logFp);
$rows = [];
foreach ($fb->query('SELECT NIT,CODIGO,CODIGOEMP FROM EXAMEN_EMP') as $r) {
$rows[] = [
'nit_empresa' => cleanStr($r['NIT'], $charset),
'cod_examen_legacy' => cleanStr($r['CODIGO'], $charset),
'exam_tipo_id' => null,
'codigo_empresa' => cleanStr($r['CODIGOEMP'],$charset),
];
}
if (!$dryRun) {
$n = batchInsert($my, 'lab_examenes_empresa', $rows, true);
// Resolver exam_tipo_id
$my->exec(
'UPDATE lab_examenes_empresa ee
JOIN exam_tipos e ON e.codigo_legacy = ee.cod_examen_legacy
SET ee.exam_tipo_id = e.id
WHERE ee.exam_tipo_id IS NULL'
);
etlLog(" Examenes-empresa: $n", $logFp);
}
}
// ════════════════════════════════════════════════════════════════════════════
// PASO 9 — MEDICO → medicos
// ════════════════════════════════════════════════════════════════════════════
if (shouldRun('medicos', $onlyPaso)) {
etlLog('--- MEDICO → medicos ---', $logFp);
$rows = [];
foreach ($fb->query(
'SELECT CODMEDICO,NOMBRES,APELLIDOS,CODESPECIA,TELEFONO1,EMAIL,DOCIDMEDICO,ACTIVO
FROM MEDICO'
) as $r) {
$rows[] = [
'codigo' => cleanStr($r['CODMEDICO'], $charset),
'nombres' => cleanStr($r['NOMBRES'], $charset),
'apellidos' => cleanStr($r['APELLIDOS'], $charset),
'cod_especialidad'=> cleanStr($r['CODESPECIA'], $charset),
'telefonos' => cleanStr($r['TELEFONO1'], $charset),
'email' => cleanStr($r['EMAIL'], $charset),
'docidmedico' => cleanStr($r['DOCIDMEDICO'], $charset),
'activo' => $r['ACTIVO'] ? 1 : 0,
];
}
if (!$dryRun) {
$n = batchInsert($my, 'medicos', $rows, true);
etlLog(" Médicos: $n / " . count($rows), $logFp);
}
}
// ════════════════════════════════════════════════════════════════════════════
// PASO 10 — PACIENTE → lab_pacientes
// ════════════════════════════════════════════════════════════════════════════
if (shouldRun('pacientes', $onlyPaso)) {
etlLog('--- PACIENTE → lab_pacientes ---', $logFp);
$total = fbCount($fb, 'PACIENTE');
etlLog(" Origen: $total registros", $logFp);
$stmt = $fb->query(
'SELECT CODPAC,DOCIDENT,TIPOIDENT,NOMBRES,APELLIDOS,
TELEFONO1,EMAIL,FECHANAC,SEXO,CIUDAD,
OCUPACION,CODETNIA,TIPORES
FROM PACIENTE
ORDER BY CODPAC'
);
$checkStmt = $my->prepare('SELECT id FROM lab_pacientes WHERE numero_documento = ?');
$insStmt = $my->prepare(
'INSERT IGNORE INTO lab_pacientes
(numero_documento, tipo_documento, nombre_completo, telefono,
email, fecha_nacimiento, genero, ciudad,
ocupacion, codetnia, tipores, codigo_legacy, es_historico)
VALUES (?,?,?,?,?,?,?,?,?,?,?,?,1)'
);
$updLegacy = $my->prepare(
'UPDATE lab_pacientes SET codigo_legacy = ? WHERE numero_documento = ? AND codigo_legacy IS NULL'
);
$nuevos = $coincidentes = 0;
while ($r = $stmt->fetch(PDO::FETCH_ASSOC)) {
$doc = cleanStr($r['DOCIDENT'], $charset);
if (!$doc) continue;
$nombres = cleanStr($r['NOMBRES'], $charset);
$apellidos = cleanStr($r['APELLIDOS'], $charset);
$nombre = trim("$nombres $apellidos");
if ($dryRun) continue;
$checkStmt->execute([$doc]);
$existeId = $checkStmt->fetchColumn();
if ($existeId) {
// Ya existe → solo actualizar codigo_legacy si falta
$updLegacy->execute([cleanStr($r['CODPAC'], $charset), $doc]);
$coincidentes++;
} else {
$tipoDoc = match(strtoupper(cleanStr($r['TIPOIDENT'], $charset) ?? '')) {
'CC' => 'CC',
'CE' => 'CE',
'TI' => 'TI',
'PA' => 'PA',
'NIT' => 'NIT',
'RC' => 'RC',
'MS' => 'MS',
default => 'CC',
};
$genero = match(strtoupper(cleanStr($r['SEXO'], $charset) ?? '')) {
'M' => 'M', 'F' => 'F', default => null
};
$insStmt->execute([
$doc,
$tipoDoc,
$nombre ?: 'Sin nombre',
cleanStr($r['TELEFONO1'], $charset),
cleanStr($r['EMAIL'], $charset),
fbDate($r['FECHANAC']),
$genero,
cleanStr($r['CIUDAD'], $charset),
cleanStr($r['OCUPACION'], $charset),
cleanStr($r['CODETNIA'], $charset),
cleanStr($r['TIPORES'], $charset),
cleanStr($r['CODPAC'], $charset),
]);
$nuevos++;
}
}
etlLog(" Nuevos: $nuevos | Coincidentes (codigo_legacy actualizado): $coincidentes", $logFp);
}
// ════════════════════════════════════════════════════════════════════════════
// PASO 11-13 — Histórico transaccional (RECEPCION / RELACION / PAGOS)
// ════════════════════════════════════════════════════════════════════════════
if (!$skipHist) {
// ── 11. RECEPCION → lab_recepciones ──────────────────────────────────
if (shouldRun('recepciones', $onlyPaso)) {
etlLog('--- RECEPCION → lab_recepciones ---', $logFp);
$total = fbCount($fb, 'RECEPCION');
etlLog(" Origen: $total registros", $logFp);
// Mapa paciente legacy → id nuevo
$pacMap = [];
foreach ($my->query('SELECT id, codigo_legacy FROM lab_pacientes WHERE codigo_legacy IS NOT NULL') as $r) {
$pacMap[$r['codigo_legacy']] = (int)$r['id'];
}
// Mapa médico legacy → id nuevo
$medMap = [];
foreach ($my->query('SELECT id, codigo FROM medicos WHERE codigo IS NOT NULL') as $r) {
$medMap[$r['codigo']] = (int)$r['id'];
}
$stmt = $fb->query(
'SELECT IDRECEPCION,CODPAC,CODMEDICO,NIT,SUBGRUPO,
FECHA,HORAINICIO,PREFIJO,NUMFACTURA,
VALORTOTAL,VALORDESC,DIAGPPAL,TIPOUSUARIO,AUTORIZACION,USUARIO
FROM RECEPCION
ORDER BY IDRECEPCION'
);
$batch = [];
$count = 0;
while ($r = $stmt->fetch(PDO::FETCH_ASSOC)) {
$codPac = cleanStr($r['CODPAC'], $charset);
$codMed = cleanStr($r['CODMEDICO'],$charset);
$batch[] = [
'id' => (int)$r['IDRECEPCION'],
'cod_paciente_legacy' => $codPac,
'paciente_id' => $pacMap[$codPac] ?? null,
'cod_medico_legacy' => $codMed,
'medico_id' => $medMap[$codMed] ?? null,
'nit_empresa' => cleanStr($r['NIT'], $charset),
'subgrupo' => cleanStr($r['SUBGRUPO'], $charset),
'fecha_recepcion' => fbDate($r['FECHA']),
'hora_inicio' => cleanStr($r['HORAINICIO'], $charset),
'prefijo' => cleanStr($r['PREFIJO'], $charset),
'num_factura' => $r['NUMFACTURA'] ? (int)$r['NUMFACTURA'] : null,
'valor_total' => (float)($r['VALORTOTAL'] ?? 0),
'valor_desc' => (float)($r['VALORDESC'] ?? 0),
'diag_ppal' => cleanStr($r['DIAGPPAL'], $charset),
'tipo_usuario' => cleanStr($r['TIPOUSUARIO'], $charset),
'autorizacion' => cleanStr($r['AUTORIZACION'], $charset),
'usuario' => cleanStr($r['USUARIO'], $charset),
];
if (count($batch) >= 500) {
if (!$dryRun) $count += batchInsert($my, 'lab_recepciones', $batch, true);
$batch = [];
if ($count % 5000 === 0) etlLog(" ... $count procesadas", $logFp);
}
}
if ($batch && !$dryRun) $count += batchInsert($my, 'lab_recepciones', $batch, true);
etlLog(" Insertadas: $count", $logFp);
}
// ── 12. RELACION → lab_relaciones ─────────────────────────────────────
if (shouldRun('relaciones', $onlyPaso)) {
etlLog('--- RELACION → lab_relaciones ---', $logFp);
$total = fbCount($fb, 'RELACION');
etlLog(" Origen: $total registros", $logFp);
// Mapa cod_examen_legacy → exam_tipo_id
$examMap = [];
foreach ($my->query('SELECT id, codigo_legacy FROM exam_tipos WHERE codigo_legacy IS NOT NULL') as $r) {
$examMap[$r['codigo_legacy']] = (int)$r['id'];
}
$stmt = $fb->query(
'SELECT IDRECEPCION,CODIGO,PRECIO,FECHAREPORT,REPORTADO,REPORPOR,VALIDADO,USRVALIDA,FECHAVALIDA
FROM RELACION'
);
$batch = [];
$count = 0;
while ($r = $stmt->fetch(PDO::FETCH_ASSOC)) {
$codExam = cleanStr($r['CODIGO'], $charset);
$batch[] = [
'recepcion_id' => (int)$r['IDRECEPCION'],
'cod_examen_legacy' => $codExam,
'exam_tipo_id' => $examMap[$codExam] ?? null,
'precio' => (float)($r['PRECIO'] ?? 0),
'fecha_reportado' => fbDate($r['FECHAREPORT']),
'reportado' => $r['REPORTADO'] ? 1 : 0,
'reportado_por' => cleanStr($r['REPORPOR'], $charset),
'validado' => $r['VALIDADO'] ? 1 : 0,
'usuario_valida' => cleanStr($r['USRVALIDA'], $charset),
'fecha_valida' => fbDate($r['FECHAVALIDA']),
];
if (count($batch) >= 500) {
if (!$dryRun) $count += batchInsert($my, 'lab_relaciones', $batch, true);
$batch = [];
if ($count % 10000 === 0) etlLog(" ... $count", $logFp);
}
}
if ($batch && !$dryRun) $count += batchInsert($my, 'lab_relaciones', $batch, true);
etlLog(" Insertadas: $count", $logFp);
}
// ── 13. PAGOS + PAGOS_DET ─────────────────────────────────────────────
if (shouldRun('pagos', $onlyPaso)) {
etlLog('--- PAGOS → lab_pagos ---', $logFp);
$rows = [];
foreach ($fb->query('SELECT NUMCAJA,IDRECEPCION,VALOR,FECHAPAGO,USUARIO FROM PAGOS') as $r) {
$rows[] = [
'numcaja_legacy' => (int)$r['NUMCAJA'],
'recepcion_id' => (int)$r['IDRECEPCION'],
'valor' => (float)($r['VALOR'] ?? 0),
'fecha' => fbDate($r['FECHAPAGO']),
'usuario' => cleanStr($r['USUARIO'], $charset),
];
}
if (!$dryRun) {
$n = batchInsert($my, 'lab_pagos', $rows, true);
etlLog(" Pagos: $n / " . count($rows), $logFp);
}
etlLog('--- PAGOS_DET → lab_pagos_det ---', $logFp);
// Mapa numcaja → id nuevo
$pagoMap = [];
if (!$dryRun) {
foreach ($my->query('SELECT id, numcaja_legacy FROM lab_pagos') as $r) {
$pagoMap[$r['numcaja_legacy']] = (int)$r['id'];
}
}
$rows = [];
foreach ($fb->query('SELECT NUMCAJA,TIPOPAGO,VALOR,NUMDOC FROM PAGOS_DET') as $r) {
$pagoId = $pagoMap[(int)$r['NUMCAJA']] ?? null;
if (!$pagoId) continue;
$rows[] = [
'pago_id' => $pagoId,
'tipo_pago' => cleanStr($r['TIPOPAGO'], $charset),
'valor' => (float)($r['VALOR'] ?? 0),
'num_doc' => cleanStr($r['NUMDOC'], $charset),
];
}
if (!$dryRun) {
$n = batchInsert($my, 'lab_pagos_det', $rows, true);
etlLog(" Detalles de pago: $n", $logFp);
}
}
} else {
etlLog('--- Histórico omitido (--skip-historico) ---', $logFp);
}
// ── Restaurar FK checks ──────────────────────────────────────────────────────
if (!$dryRun) {
$my->exec("SET foreign_key_checks = 1");
}
etlLog('=== ETL COMPLETADO ===', $logFp);
fclose($logFp);
echo "Log guardado en: {$cfg['options']['log_file']}\n";
+24
View File
@@ -166,6 +166,30 @@ try {
}
unset($c);
// ── Auto-crear muestras por tipo_muestra de los exámenes ──
try {
if (!$soloMuestras && !empty($examIds)) {
$ph = implode(',', array_fill(0, count($examIds), '?'));
$stmtTm = $pdo->prepare(
"SELECT DISTINCT COALESCE(NULLIF(TRIM(tipo_muestra),''), codigo) AS tipo_key
FROM exam_tipos WHERE id IN ($ph) AND activo = 1"
);
$stmtTm->execute($examIds);
$stmtIm = $pdo->prepare(
"INSERT IGNORE INTO turnero_muestras (solicitud_id, tipo_muestra) VALUES (?, ?)"
);
foreach ($stmtTm->fetchAll(PDO::FETCH_COLUMN) as $tipoKey) {
$stmtIm->execute([$solicitudId, $tipoKey]);
}
} elseif ($soloMuestras) {
$pdo->prepare(
"INSERT IGNORE INTO turnero_muestras (solicitud_id, tipo_muestra) VALUES (?, 'MUESTRA')"
)->execute([$solicitudId]);
}
} catch (\Throwable $_) {
// Tabla aún no existe (migración pendiente) — continuar sin muestras
}
$pdo->commit();
notificarSSE(obtenerOCrearSesionHoy());
@@ -217,9 +217,29 @@ if ($incluirSolicitud) {
}
}
// Muestras pendientes / recibidas del turno
$muestras = [];
if ($solicitud) {
try {
$stmtM = $pdo->prepare(
"SELECT tm.id, tm.tipo_muestra, tm.estado, tm.motivo_rechazo, tm.recibida_at,
COALESCE(lt.nombre, tm.tipo_muestra) AS label
FROM turnero_muestras tm
LEFT JOIN lab_tipos_muestra lt ON lt.codigo = tm.tipo_muestra
WHERE tm.solicitud_id = ?
ORDER BY tm.id ASC"
);
$stmtM->execute([$solicitud['id']]);
$muestras = $stmtM->fetchAll(PDO::FETCH_ASSOC);
} catch (\Throwable $_) {
// Tabla aún no existe
}
}
$respuesta['solicitud'] = $solicitud;
$respuesta['paciente'] = $paciente;
$respuesta['examenes'] = $examenes;
$respuesta['muestras'] = $muestras;
}
jsonOk($respuesta);
@@ -0,0 +1,69 @@
<?php
/**
* POST /modules/turnero/api/update_muestra_estado.php
* Marca una muestra como recibida o rechazada.
* Solo accesible desde estaciones tipo "muestras".
*
* Body JSON:
* muestra_id int requerido
* estado string requerido 'recibida' | 'rechazada' | 'pendiente'
* motivo_rechazo string opcional requerido si estado = 'rechazada'
*/
require_once __DIR__ . '/_helpers.php';
requireMethod('POST');
requireTurnero();
$datos = inputJson();
$muestraId = isset($datos['muestra_id']) ? (int)$datos['muestra_id'] : 0;
$estado = trim($datos['estado'] ?? '');
$motivo = isset($datos['motivo_rechazo']) ? trim($datos['motivo_rechazo']) : null;
if ($muestraId <= 0) jsonError('muestra_id inválido.');
if (!in_array($estado, ['recibida', 'rechazada', 'pendiente'], true)) {
jsonError('estado debe ser recibida, rechazada o pendiente.');
}
if ($motivo === '') $motivo = null;
$pdo = db();
// Verificar que la muestra existe
$stmt = $pdo->prepare(
"SELECT tm.id, tm.solicitud_id, tm.estado
FROM turnero_muestras tm
WHERE tm.id = ?"
);
$stmt->execute([$muestraId]);
$muestra = $stmt->fetch(PDO::FETCH_ASSOC);
if (!$muestra) jsonError('Muestra no encontrada.', 404);
// Construir campos a actualizar
$ahora = date('Y-m-d H:i:s');
$adminI = adminId();
if ($estado === 'recibida') {
$pdo->prepare(
"UPDATE turnero_muestras
SET estado = 'recibida', recibida_por = ?, recibida_at = ?,
motivo_rechazo = NULL
WHERE id = ?"
)->execute([$adminI, $ahora, $muestraId]);
} elseif ($estado === 'rechazada') {
$pdo->prepare(
"UPDATE turnero_muestras
SET estado = 'rechazada', recibida_por = ?, recibida_at = ?,
motivo_rechazo = ?
WHERE id = ?"
)->execute([$adminI, $ahora, $motivo, $muestraId]);
} else {
// Revertir a pendiente
$pdo->prepare(
"UPDATE turnero_muestras
SET estado = 'pendiente', recibida_por = NULL,
recibida_at = NULL, motivo_rechazo = NULL
WHERE id = ?"
)->execute([$muestraId]);
}
jsonOk(['muestra_id' => $muestraId, 'estado' => $estado], 'Muestra actualizada');
+162 -1
View File
@@ -12,7 +12,7 @@ if (!isUserLoggedIn()) {
try {
$pdo = Database::getInstance()->getConnection();
$lugares = $pdo->query(
"SELECT id, nombre, descripcion, formulario_modo FROM turnero_lugares WHERE activo = 1 ORDER BY sort_order ASC"
"SELECT id, nombre, descripcion, formulario_modo, tipo FROM turnero_lugares WHERE activo = 1 ORDER BY sort_order ASC"
)->fetchAll(PDO::FETCH_ASSOC);
} catch (\Throwable) {
$lugares = [];
@@ -22,10 +22,12 @@ $lugarIdParam = isset($_GET['lugar_id']) ? (int)$_GET['lugar_id'] : 0;
$lugarNombre = 'Estación de Servicio';
$lugarFormModo = 'link';
$lugarTipo = 'muestras';
foreach ($lugares as $l) {
if ((int)$l['id'] === $lugarIdParam) {
$lugarNombre = $l['nombre'];
$lugarFormModo = $l['formulario_modo'] ?? 'link';
$lugarTipo = $l['tipo'] ?? 'muestras';
break;
}
}
@@ -387,6 +389,38 @@ $adminNombre = $_SESSION['admin_user']['full_name'] ?? $_SESSION['admin_user']['
.rec-toast.info .ico { color: #3b82f6; }
.rec-toast.warn .ico { color: #f59e0b; }
.rec-toast.error .ico { color: #ef4444; }
/* ── Widget muestras ────────────────────────────────────── */
.muestra-row {
display: flex; align-items: center; gap: .6rem;
padding: .5rem .7rem; border-radius: 10px; margin-bottom: .35rem;
font-size: .84rem; border: 1px solid transparent;
}
.muestra-row.pendiente { background: #fffbeb; border-color: #fde68a; }
.muestra-row.recibida { background: #f0fdf4; border-color: #bbf7d0; color: #166534; }
.muestra-row.rechazada { background: #fef2f2; border-color: #fca5a5; color: #991b1b; }
.muestra-info { flex: 1; min-width: 0; }
.muestra-label { font-weight: 600; font-size: .82rem; font-family: monospace; }
.muestra-motivo { font-size: .7rem; margin-top: 2px; opacity: .85; }
.muestra-ts { font-size: .68rem; color: #94a3b8; flex-shrink: 0; }
.btn-muestra-accion {
border: none; border-radius: 8px; padding: 3px 11px;
font-size: .75rem; font-weight: 700; cursor: pointer;
display: flex; align-items: center; gap: .25rem;
transition: opacity .12s;
}
.btn-muestra-accion:active { opacity: .75; }
.btn-muestra-accion.recibir { background: #16a34a; color: #fff; }
.btn-muestra-accion.rechazar { background: #fff; color: #991b1b; border: 1.5px solid #fca5a5; }
.muestras-summary {
font-size: .72rem; color: #64748b; margin-bottom: .5rem;
display: flex; align-items: center; gap: .5rem; flex-wrap: wrap;
}
.muestras-summary .mc { font-weight: 700; border-radius: 99px; padding: 1px 8px;
font-size: .68rem; }
.muestras-summary .mc.pend { background: #fef9c3; color: #854d0e; }
.muestras-summary .mc.rec { background: #dcfce7; color: #166534; }
.muestras-summary .mc.rech { background: #fee2e2; color: #991b1b; }
</style>
</head>
<body>
@@ -540,6 +574,16 @@ require_once __DIR__ . '/../../../shared/components/sidebar.php';
</div>
</div>
<!-- ── Muestras (solo estaciones tipo muestras) ── -->
<div class="ficha-sec d-none" id="sec-muestras">
<div class="ficha-sec-hdr">
<i class="fas fa-flask" style="color:#ea580c"></i>
Recepción de muestras
</div>
<div id="muestras-summary" class="muestras-summary"></div>
<div id="lista-muestras"></div>
</div>
<!-- ── Formulario embebido (iframe, modo link) ── -->
<div class="ficha-sec d-none" id="sec-form-embebido">
<div class="ficha-sec-hdr"><i class="fas fa-file-alt"></i>Formulario de consentimiento</div>
@@ -697,11 +741,13 @@ let pollingConsentId = null;
let _consentTokenCache = null;
let _pacienteActivo = null;
let _solicitudActiva = null;
let _muestrasActivas = [];
const API = '<?= BASE_URL ?>modules/turnero/api/';
const BASE_WA = '<?= BASE_URL ?>';
const LUGAR_NOMBRE = document.getElementById('lbl-lugar-titulo')?.textContent || '<?= addslashes($lugarNombre) ?>';
const LUGAR_FORM_MODO = '<?= $lugarFormModo ?>';
const LUGAR_TIPO = '<?= $lugarTipo ?>';
// ── Arranque ──────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', () => {
@@ -1011,6 +1057,7 @@ async function cargarFichaSolicitud(turnoId) {
}
renderConsentimientos(consts);
renderMuestras(json.muestras || []);
cargarComentarios(turnoId);
} catch (_) {}
@@ -1265,6 +1312,9 @@ function resetFicha() {
_consentTokenCache = null;
_pacienteActivo = null;
_solicitudActiva = null;
_muestrasActivas = [];
const secMuestras = document.getElementById('sec-muestras');
if (secMuestras) secMuestras.classList.add('d-none');
document.getElementById('ficha-orden').classList.add('d-none');
cerrarModalPaciente();
document.getElementById('btn-ver-paciente').classList.add('d-none');
@@ -1518,6 +1568,117 @@ function renderHistorialTimeline(turnos, total) {
return `<div class="mpac-timeline">${items}</div>${totalLabel}`;
}
// ── Muestras ──────────────────────────────────────────────────
function renderMuestras(lista) {
_muestrasActivas = lista || [];
const sec = document.getElementById('sec-muestras');
const cont = document.getElementById('lista-muestras');
const summary = document.getElementById('muestras-summary');
if (!sec) return;
// Solo mostrar en estaciones tipo "muestras"
if (LUGAR_TIPO !== 'muestras' || !lista.length) {
sec.classList.add('d-none');
return;
}
sec.classList.remove('d-none');
const nPend = lista.filter(m => m.estado === 'pendiente').length;
const nRec = lista.filter(m => m.estado === 'recibida').length;
const nRech = lista.filter(m => m.estado === 'rechazada').length;
let chips = '';
if (nPend) chips += `<span class="mc pend">${nPend} pendiente${nPend > 1 ? 's' : ''}</span>`;
if (nRec) chips += `<span class="mc rec">${nRec} recibida${nRec > 1 ? 's' : ''}</span>`;
if (nRech) chips += `<span class="mc rech">${nRech} rechazada${nRech > 1 ? 's' : ''}</span>`;
summary.innerHTML = chips;
cont.innerHTML = lista.map(m => {
const esPend = m.estado === 'pendiente';
const label = escHtml(m.label || m.tipo_muestra || 'MUESTRA');
const tsHtml = m.recibida_at
? `<span class="muestra-ts">${formatHora(m.recibida_at)}</span>`
: '';
const motivoHtml = m.motivo_rechazo
? `<div class="muestra-motivo">${escHtml(m.motivo_rechazo)}</div>` : '';
const acciones = esPend
? `<div style="display:flex;gap:5px;flex-shrink:0">
<button class="btn-muestra-accion recibir"
onclick="marcarMuestra(${m.id},'recibida')">
<i class="fas fa-check"></i> Recibida
</button>
<button class="btn-muestra-accion rechazar"
onclick="pedirRechazo(${m.id},'${label.replace(/'/g,'\\\'')}')" >
<i class="fas fa-times"></i> Rechazar
</button>
</div>`
: `${tsHtml}
<button class="btn-muestra-accion"
style="background:#f1f5f9;color:#64748b;border:1px solid #e2e8f0"
onclick="marcarMuestra(${m.id},'pendiente')" title="Revertir a pendiente">
<i class="fas fa-undo"></i>
</button>`;
const ico = m.estado === 'recibida'
? '<i class="fas fa-check-circle" style="color:#16a34a;flex-shrink:0"></i>'
: m.estado === 'rechazada'
? '<i class="fas fa-times-circle" style="color:#dc2626;flex-shrink:0"></i>'
: '<i class="fas fa-clock" style="color:#d97706;flex-shrink:0"></i>';
return `<div class="muestra-row ${m.estado}" data-muestra-id="${m.id}">
${ico}
<div class="muestra-info">
<span class="muestra-label">${label}</span>
${motivoHtml}
</div>
${acciones}
</div>`;
}).join('');
}
async function marcarMuestra(muestraId, estado, motivo = null) {
try {
const body = { muestra_id: muestraId, estado };
if (motivo) body.motivo_rechazo = motivo;
const res = await fetch(API + 'update_muestra_estado.php', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
const json = await res.json();
if (!json.ok) { mostrarError(json.error); return; }
// Actualizar estado local sin re-fetch
const m = _muestrasActivas.find(x => x.id == muestraId);
if (m) {
m.estado = estado;
m.motivo_rechazo = motivo;
m.recibida_at = estado !== 'pendiente' ? new Date().toISOString() : null;
}
renderMuestras(_muestrasActivas);
const msg = estado === 'recibida'
? 'Muestra recibida ✓'
: estado === 'rechazada' ? 'Muestra rechazada' : 'Revertida a pendiente';
mostrarToast(msg, estado === 'recibida' ? 'success' : estado === 'rechazada' ? 'warn' : 'info', 2000);
} catch (err) {
mostrarError(err.message);
}
}
function pedirRechazo(muestraId, tipoMuestra) {
const motivo = prompt(`Motivo de rechazo para "${tipoMuestra}":\n(hemólisis, coagulado, volumen insuficiente…)`);
if (motivo === null) return;
marcarMuestra(muestraId, 'rechazada', motivo.trim() || null);
}
function formatHora(isoStr) {
if (!isoStr) return '';
try {
return new Date(isoStr).toLocaleTimeString('es-CO', { hour: '2-digit', minute: '2-digit' });
} catch (_) { return ''; }
}
// ── Helpers ───────────────────────────────────────────────────
function escHtml(str) {
const d = document.createElement('div');
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env php
<?php
/**
* Aplica las migraciones LIS 01-07 en orden de dependencias.
* Ejecutar UNA VEZ antes del ETL.
*
* Uso:
* php run_lis_migrations.php
*/
require_once __DIR__ . '/config/database.php'; // $pdo o Database::getInstance()
require_once __DIR__ . '/classes/Database.php';
$pdo = Database::getInstance()->getConnection();
$migrations = [
'20260704_lis_01_catalogos_base.sql',
'20260704_lis_02_protocolos.sql',
'20260704_lis_03_perfiles.sql',
'20260704_lis_04_exam_ampliar.sql',
'20260704_lis_05_tarifas.sql',
'20260704_lis_06_historico.sql',
'20260704_lis_07_turnero_muestras.sql',
'20260704_lis_08_vistas.sql',
];
$dir = __DIR__ . '/migrations/';
foreach ($migrations as $file) {
$path = $dir . $file;
echo "Ejecutando $file... ";
try {
$sql = file_get_contents($path);
// Separar por ; para ejecutar sentencia a sentencia
foreach (array_filter(array_map('trim', explode(';', $sql))) as $stmt) {
if ($stmt !== '') $pdo->exec($stmt);
}
echo "OK\n";
} catch (\Throwable $e) {
echo "ERROR: " . $e->getMessage() . "\n";
}
}
echo "\nMigraciones LIS completadas. Ahora ejecuta el ETL:\n";
echo " cd migrations/etl && cp config.php config.local.php\n";
echo " # editar config.local.php con credenciales Firebird\n";
echo " php run_etl.php --dry-run # prueba sin insertar\n";
echo " php run_etl.php # migración real\n";
+30 -3
View File
@@ -36,7 +36,8 @@ if ($modoTurnero) {
p.nombre_completo AS paciente_nombre,
p.numero_documento, p.tipo_documento,
p.fecha_nacimiento, p.telefono AS paciente_telefono, p.eps,
t.sesion_id
t.sesion_id,
ts.numero_orden
FROM turnero_consentimientos tc
JOIN lab_formularios f ON f.id = tc.formulario_id
JOIN turnero_turnos t ON t.id = tc.turno_id
@@ -128,11 +129,13 @@ if ($modoTurnero) {
f.doc_encabezado, f.doc_subtitulo, f.doc_logo_base64, f.doc_color, f.doc_pie_pagina,
p.nombre_completo AS paciente_nombre, p.numero_documento, p.tipo_documento,
p.fecha_nacimiento, p.telefono AS paciente_telefono, p.eps,
u.full_name AS enviado_por_nombre, u.email AS enviado_por_email
u.full_name AS enviado_por_nombre, u.email AS enviado_por_email,
ld.numero_orden
FROM lab_form_envios e
JOIN lab_formularios f ON f.id = e.formulario_id
LEFT JOIN lab_pacientes p ON p.id = e.paciente_id
LEFT JOIN admin_users u ON u.id = e.enviado_por
LEFT JOIN lab_domicilios ld ON ld.id = e.domicilio_id
WHERE e.token = ?",
[$tokenPublico]
);
@@ -150,11 +153,13 @@ if ($modoTurnero) {
f.doc_encabezado, f.doc_subtitulo, f.doc_logo_base64, f.doc_color, f.doc_pie_pagina,
p.nombre_completo AS paciente_nombre, p.numero_documento, p.tipo_documento,
p.fecha_nacimiento, p.telefono AS paciente_telefono, p.eps,
u.full_name AS enviado_por_nombre, u.email AS enviado_por_email
u.full_name AS enviado_por_nombre, u.email AS enviado_por_email,
ld.numero_orden
FROM lab_form_envios e
JOIN lab_formularios f ON f.id = e.formulario_id
LEFT JOIN lab_pacientes p ON p.id = e.paciente_id
LEFT JOIN admin_users u ON u.id = e.enviado_por
LEFT JOIN lab_domicilios ld ON ld.id = e.domicilio_id
WHERE e.id = ?",
[$id]
);
@@ -167,6 +172,11 @@ if ($modoTurnero) {
}
}
// ── Consecutivo de orden (D-... domicilio | F-... turnero) ────────────
$numeroOrden = $modoTurnero
? ($tcRow['numero_orden'] ?? null)
: ($envio['numero_orden'] ?? null);
// ── Config global del lab ─────────────────────────────────────────────
$cfgRows = $db->fetchAll('SELECT clave, valor FROM lab_config WHERE valor != ""');
$cfg = [];
@@ -442,6 +452,20 @@ function esc2(mixed $v): string {
<?php endif; ?>
</div>
<div class="doc-header-badge" style="text-align:right">
<?php if ($numeroOrden): ?>
<?php
$esDomicilio = str_starts_with($numeroOrden, 'D-');
$ordenColor = $esDomicilio ? '#bbf7d0' : '#bfdbfe';
$ordenTxt = $esDomicilio ? '#14532d' : '#1e3a8a';
$ordenLabel = $esDomicilio ? 'Domicilio' : 'Turnero';
?>
<div style="background:<?= $ordenColor ?>;color:<?= $ordenTxt ?>;border-radius:8px;
padding:4px 10px;font-size:12px;font-weight:700;letter-spacing:.03em;
margin-bottom:6px;font-family:monospace">
<?= esc2($numeroOrden) ?>
</div>
<div style="font-size:10px;opacity:.75;margin-bottom:4px"><?= $ordenLabel ?></div>
<?php endif; ?>
<?php
$eBadge = match($envio['estado']) {
'firmado' => 'estado-firmado',
@@ -877,6 +901,9 @@ function esc2(mixed $v): string {
<?= esc2($docPiePagina) ?> &nbsp;&bull;&nbsp;
<?php endif; ?>
<i class="fas fa-shield-alt me-1"></i>Generado el <?= date('d/m/Y H:i') ?> &nbsp;&bull;&nbsp; ID #<?= $envio['id'] ?>
<?php if ($numeroOrden): ?>
&nbsp;&bull;&nbsp; Orden: <strong><?= esc2($numeroOrden) ?></strong>
<?php endif; ?>
</span>
<?php if ($envio['hash_verificacion']): ?>
<span class="hash-short" title="Hash SHA-256"><?= substr($envio['hash_verificacion'],0,16) ?>...</span>