diff --git a/modules/lab_examenes/api/_helpers.php b/modules/lab_examenes/api/_helpers.php
new file mode 100644
index 0000000..2bba773
--- /dev/null
+++ b/modules/lab_examenes/api/_helpers.php
@@ -0,0 +1,55 @@
+ false, 'error' => 'Error interno: ' . $err['message']]);
+ }
+});
+
+header('Content-Type: application/json; charset=utf-8');
+if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') { http_response_code(204); exit; }
+
+function jsonOk(array $data = [], string $msg = ''): never {
+ ob_clean();
+ $r = ['ok' => true];
+ if ($msg) $r['message'] = $msg;
+ if ($data) $r = array_merge($r, $data);
+ echo json_encode($r);
+ exit;
+}
+
+function jsonError(string $msg, int $code = 400): never {
+ ob_clean();
+ http_response_code($code);
+ echo json_encode(['ok' => false, 'error' => $msg]);
+ exit;
+}
+
+function requireAdmin(): void {
+ if (!isUserLoggedIn()) jsonError('No autorizado', 401);
+ $role = $_SESSION['admin_user']['role'] ?? '';
+ if (!in_array($role, ['superadmin', 'admin', 'supervisor'], true)) jsonError('Sin permiso', 403);
+}
+
+function requireLogin(): void {
+ if (!isUserLoggedIn()) jsonError('No autorizado', 401);
+}
+
+function inputJson(): array {
+ return json_decode(file_get_contents('php://input'), true) ?? [];
+}
+
+function db(): PDO {
+ $pdo = Database::getInstance()->getConnection();
+ $pdo->exec("SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci");
+ return $pdo;
+}
diff --git a/modules/lab_examenes/api/get.php b/modules/lab_examenes/api/get.php
new file mode 100644
index 0000000..da063f9
--- /dev/null
+++ b/modules/lab_examenes/api/get.php
@@ -0,0 +1,20 @@
+prepare('SELECT * FROM exam_tipos WHERE id = ?');
+$s->execute([$id]);
+$exam = $s->fetch(PDO::FETCH_ASSOC);
+if (!$exam) jsonError('Examen no encontrado', 404);
+
+$items = $pdo->prepare(
+ 'SELECT * FROM lab_items_resultado WHERE cod_protocolo = ? ORDER BY orden, id'
+);
+$items->execute([$exam['cod_protocolo'] ?? $exam['codigo']]);
+
+jsonOk(['data' => $exam, 'items' => $items->fetchAll(PDO::FETCH_ASSOC)]);
diff --git a/modules/lab_examenes/api/get_tarifas.php b/modules/lab_examenes/api/get_tarifas.php
new file mode 100644
index 0000000..6d36deb
--- /dev/null
+++ b/modules/lab_examenes/api/get_tarifas.php
@@ -0,0 +1,23 @@
+prepare('SELECT 1 FROM exam_tipos WHERE id = ?');
+$s->execute([$examId]);
+if (!$s->fetchColumn()) jsonError('Examen no encontrado', 404);
+
+$s = $pdo->prepare(
+ "SELECT ti.id AS tarifa_id, ti.nombre AS tarifa_nombre,
+ lt.id AS precio_id, lt.valor, lt.recargo_urg, lt.recargo_fes, lt.recargo_esp
+ FROM lab_tarifas_id ti
+ LEFT JOIN lab_tarifas lt ON lt.tarifa_id = ti.id AND lt.exam_tipo_id = ?
+ ORDER BY ti.id"
+);
+$s->execute([$examId]);
+
+jsonOk(['data' => $s->fetchAll(PDO::FETCH_ASSOC)]);
diff --git a/modules/lab_examenes/api/list.php b/modules/lab_examenes/api/list.php
new file mode 100644
index 0000000..3b5acb1
--- /dev/null
+++ b/modules/lab_examenes/api/list.php
@@ -0,0 +1,37 @@
+prepare("SELECT COUNT(*) FROM exam_tipos $wSql");
+$s->execute($params);
+$total = (int)$s->fetchColumn();
+
+$s = $pdo->prepare(
+ "SELECT id, codigo, nombre, categoria, activo, cups, seremite, cod_protocolo, tipo_muestra
+ FROM exam_tipos $wSql ORDER BY categoria, nombre LIMIT $limit OFFSET $offset"
+);
+$s->execute($params);
+
+jsonOk(['data' => $s->fetchAll(PDO::FETCH_ASSOC), 'total' => $total, 'page' => $page, 'limit' => $limit]);
diff --git a/modules/lab_examenes/api/save.php b/modules/lab_examenes/api/save.php
new file mode 100644
index 0000000..f7658a0
--- /dev/null
+++ b/modules/lab_examenes/api/save.php
@@ -0,0 +1,73 @@
+prepare('SELECT COUNT(*) FROM turnero_examen_items WHERE exam_tipo_id = ?');
+ $s->execute([$id]);
+ if ($s->fetchColumn() > 0) jsonError('Hay solicitudes con este examen. Desactívelo en su lugar.');
+ $pdo->prepare('DELETE FROM exam_tipo_consentimientos WHERE exam_tipo_id = ?')->execute([$id]);
+ $pdo->prepare('DELETE FROM exam_tipos WHERE id = ?')->execute([$id]);
+ jsonOk([], 'Examen eliminado');
+}
+
+$codigo = strtoupper(trim($d['codigo'] ?? ''));
+$nombre = trim($d['nombre'] ?? '');
+$categoria = trim($d['categoria'] ?? '') ?: null;
+$cups = trim($d['cups'] ?? '') ?: null;
+$cod_prot = trim($d['cod_protocolo'] ?? '') ?: null;
+$tipo_m = trim($d['tipo_muestra'] ?? '') ?: null;
+$nivel = isset($d['nivel']) && $d['nivel'] !== '' ? (int)$d['nivel'] : null;
+$abrev = trim($d['abreviatura'] ?? '') ?: null;
+$seremite = (int)($d['seremite'] ?? 0);
+$serecibe = trim($d['serecibe'] ?? '') ?: null;
+$activo = (int)($d['activo'] ?? 1);
+$req_ayuno = (int)($d['requiere_ayuno'] ?? 0);
+$horas_ayuno = isset($d['horas_ayuno']) && $d['horas_ayuno'] !== '' ? (int)$d['horas_ayuno'] : null;
+$instruc = trim($d['instrucciones'] ?? '') ?: null;
+$formId = (int)($d['formulario_id'] ?? 0) ?: null;
+
+if ($codigo === '') jsonError('El código es requerido');
+if ($nombre === '') jsonError('El nombre es requerido');
+if (strlen($codigo) > 20) jsonError('Código máximo 20 caracteres');
+
+$pdo = db();
+$pdo->beginTransaction();
+try {
+ if ($id) {
+ $pdo->prepare(
+ 'UPDATE exam_tipos SET codigo=?,nombre=?,categoria=?,cups=?,cod_protocolo=?,tipo_muestra=?,
+ nivel=?,abreviatura=?,seremite=?,serecibe=?,activo=?,requiere_ayuno=?,horas_ayuno=?,instrucciones=?
+ WHERE id=?'
+ )->execute([$codigo,$nombre,$categoria,$cups,$cod_prot,$tipo_m,
+ $nivel,$abrev,$seremite,$serecibe,$activo,$req_ayuno,$horas_ayuno,$instruc,$id]);
+ $examId = $id;
+ } else {
+ $pdo->prepare(
+ 'INSERT INTO exam_tipos (codigo,nombre,categoria,cups,cod_protocolo,tipo_muestra,
+ nivel,abreviatura,seremite,serecibe,activo,codigo_legacy,requiere_ayuno,horas_ayuno,instrucciones)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)'
+ )->execute([$codigo,$nombre,$categoria,$cups,$cod_prot,$tipo_m,
+ $nivel,$abrev,$seremite,$serecibe,$activo,$codigo,$req_ayuno,$horas_ayuno,$instruc]);
+ $examId = (int)$pdo->lastInsertId();
+ }
+
+ $pdo->prepare('DELETE FROM exam_tipo_consentimientos WHERE exam_tipo_id = ?')->execute([$examId]);
+ if ($formId) {
+ $pdo->prepare('INSERT INTO exam_tipo_consentimientos (exam_tipo_id, formulario_id) VALUES (?,?)')
+ ->execute([$examId, $formId]);
+ }
+
+ $pdo->commit();
+ jsonOk(['id' => $examId], $id ? 'Examen actualizado' : 'Examen creado');
+} catch (\Throwable $e) {
+ $pdo->rollBack();
+ if ($e->getCode() === '23000') jsonError('Ya existe un examen con ese código');
+ jsonError('Error: ' . $e->getMessage(), 500);
+}
diff --git a/modules/lab_examenes/api/save_item.php b/modules/lab_examenes/api/save_item.php
new file mode 100644
index 0000000..499fec8
--- /dev/null
+++ b/modules/lab_examenes/api/save_item.php
@@ -0,0 +1,44 @@
+prepare('DELETE FROM lab_items_resultado WHERE id = ?')->execute([$id]);
+ jsonOk([], 'Item eliminado');
+}
+
+$codProt = trim($d['cod_protocolo'] ?? '');
+$nombre = trim($d['nombre'] ?? '');
+if (!$codProt) jsonError('cod_protocolo requerido');
+if (!$nombre) jsonError('Nombre requerido');
+
+$tipoSexo = in_array($d['tipo_sexo'] ?? '', ['M','F']) ? $d['tipo_sexo'] : null;
+$tipo = in_array($d['tipo'] ?? '', ['N','T']) ? $d['tipo'] : 'T';
+$medida = trim($d['medida'] ?? '') ?: null;
+$abrev = trim($d['abreviatura'] ?? '') ?: null;
+$vmin = $d['vmin_ref'] !== '' && $d['vmin_ref'] !== null ? (float)$d['vmin_ref'] : null;
+$vmax = $d['vmax_ref'] !== '' && $d['vmax_ref'] !== null ? (float)$d['vmax_ref'] : null;
+$orden = (int)($d['orden'] ?? 0);
+$formula = trim($d['formula'] ?? '') ?: null;
+$cups_d = trim($d['cups_detalle'] ?? '') ?: null;
+
+$pdo = db();
+if ($id) {
+ $pdo->prepare(
+ 'UPDATE lab_items_resultado SET cod_protocolo=?,nombre=?,tipo_sexo=?,tipo=?,medida=?,
+ abreviatura=?,vmin_ref=?,vmax_ref=?,orden=?,formula=?,cups_detalle=? WHERE id=?'
+ )->execute([$codProt,$nombre,$tipoSexo,$tipo,$medida,$abrev,$vmin,$vmax,$orden,$formula,$cups_d,$id]);
+ jsonOk(['id' => $id], 'Item actualizado');
+} else {
+ $pdo->prepare(
+ 'INSERT INTO lab_items_resultado
+ (cod_protocolo,nombre,tipo_sexo,tipo,medida,abreviatura,vmin_ref,vmax_ref,orden,formula,cups_detalle)
+ VALUES (?,?,?,?,?,?,?,?,?,?,?)'
+ )->execute([$codProt,$nombre,$tipoSexo,$tipo,$medida,$abrev,$vmin,$vmax,$orden,$formula,$cups_d]);
+ jsonOk(['id' => (int)$pdo->lastInsertId()], 'Item creado');
+}
diff --git a/modules/lab_examenes/api/save_tarifa.php b/modules/lab_examenes/api/save_tarifa.php
new file mode 100644
index 0000000..1e81d4f
--- /dev/null
+++ b/modules/lab_examenes/api/save_tarifa.php
@@ -0,0 +1,45 @@
+prepare('DELETE FROM lab_tarifas WHERE exam_tipo_id = ? AND tarifa_id = ?')
+ ->execute([$examId, $tarifaId]);
+ jsonOk([], 'Precio eliminado');
+}
+
+$valor = (float)($d['valor'] ?? 0);
+$rec_urg = (float)($d['recargo_urg'] ?? 0);
+$rec_fes = (float)($d['recargo_fes'] ?? 0);
+$rec_esp = (float)($d['recargo_esp'] ?? 0);
+
+$s = $pdo->prepare('SELECT COALESCE(codigo_legacy, codigo) FROM exam_tipos WHERE id = ?');
+$s->execute([$examId]);
+$leg = $s->fetchColumn();
+if (!$leg) jsonError('Examen no encontrado', 404);
+
+// Check if price row exists (no unique key in schema, so do it manually)
+$s = $pdo->prepare('SELECT id FROM lab_tarifas WHERE exam_tipo_id = ? AND tarifa_id = ?');
+$s->execute([$examId, $tarifaId]);
+$precioId = $s->fetchColumn();
+
+if ($precioId) {
+ $pdo->prepare(
+ 'UPDATE lab_tarifas SET valor=?,recargo_urg=?,recargo_fes=?,recargo_esp=?,cod_examen_legacy=? WHERE id=?'
+ )->execute([$valor, $rec_urg, $rec_fes, $rec_esp, $leg, $precioId]);
+} else {
+ $pdo->prepare(
+ 'INSERT INTO lab_tarifas (cod_examen_legacy,exam_tipo_id,tarifa_id,valor,recargo_urg,recargo_fes,recargo_esp)
+ VALUES (?,?,?,?,?,?,?)'
+ )->execute([$leg, $examId, $tarifaId, $valor, $rec_urg, $rec_fes, $rec_esp]);
+}
+
+jsonOk([], 'Precio guardado');
diff --git a/modules/lab_examenes/module.php b/modules/lab_examenes/module.php
new file mode 100644
index 0000000..69c3008
--- /dev/null
+++ b/modules/lab_examenes/module.php
@@ -0,0 +1,15 @@
+ 'lab_examenes',
+ 'name' => 'Catálogo de Exámenes',
+ 'icon' => 'fas fa-flask',
+ 'category' => 'clinico',
+ 'route' => '/erp.php?m=lab_examenes&v=index',
+ 'is_active' => true,
+ 'sort_order' => 60,
+ 'oleada' => 2,
+ 'description' => 'Gestión del catálogo de exámenes, valores de referencia y tarifas.',
+ 'links' => [
+ ['name' => 'Exámenes', 'icon' => 'fas fa-flask', 'route' => '/erp.php?m=lab_examenes&v=index'],
+ ],
+];
diff --git a/modules/lab_examenes/views/examen.php b/modules/lab_examenes/views/examen.php
new file mode 100644
index 0000000..199cc30
--- /dev/null
+++ b/modules/lab_examenes/views/examen.php
@@ -0,0 +1,503 @@
+getConnection();
+
+$exam = null;
+$items = [];
+if ($id) {
+ $s = $pdo->prepare('SELECT * FROM exam_tipos WHERE id=?');
+ $s->execute([$id]);
+ $exam = $s->fetch(PDO::FETCH_ASSOC);
+ if (!$exam) { header('Location: ' . BASE_URL . 'erp.php?m=lab_examenes&v=index'); exit; }
+
+ $s = $pdo->prepare('SELECT * FROM lab_items_resultado WHERE cod_protocolo=? ORDER BY orden,id');
+ $s->execute([$exam['cod_protocolo'] ?? $exam['codigo']]);
+ $items = $s->fetchAll(PDO::FETCH_ASSOC);
+}
+
+$protocolos = $pdo->query('SELECT codigo, nombre FROM lab_protocolos ORDER BY nombre')->fetchAll(PDO::FETCH_ASSOC);
+$muestras = $pdo->query('SELECT codigo, nombre FROM lab_tipos_muestra ORDER BY nombre')->fetchAll(PDO::FETCH_ASSOC);
+$formularios = $pdo->query('SELECT id, nombre FROM lab_formularios ORDER BY nombre')->fetchAll(PDO::FETCH_ASSOC);
+$formActual = null;
+if ($id) {
+ $s = $pdo->prepare('SELECT formulario_id FROM exam_tipo_consentimientos WHERE exam_tipo_id=? LIMIT 1');
+ $s->execute([$id]);
+ $formActual = $s->fetchColumn() ?: null;
+}
+
+$titulo = $exam ? 'Editar: ' . htmlspecialchars($exam['nombre']) : 'Nuevo Examen';
+Layout::open($titulo, 'fas fa-flask');
+$API = BASE_URL . 'modules/lab_examenes/api/';
+?>
+
+
+
+
+
+
+
+
+
+
+
+
Datos generales
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Items / Valores de referencia
+
+
+
+
+
+
+ | # |
+ Nombre item |
+ Sexo |
+ Tipo |
+ Medida |
+ V.Min |
+ V.Max |
+ Ord. |
+ Fórmula |
+ |
+
+
+
+
+
+ = _itemRow($it) ?>
+
+
+
+
+
+
+
+
+
+
+
+
Precios por tarifa
+ Clic en el valor para editar
+
+
+
+
+
+ | # |
+ Tarifa |
+ Valor |
+ R. Urgencia |
+ R. Festivo |
+ R. Especial |
+ |
+
+
+
+ | Cargando tarifas… |
+
+
+
+
+
+
+
+'Ambos','M'=>'M','F'=>'F'];
+ $tipos = ['T'=>'Texto','N'=>'Numérico'];
+ $sOpts = ''; foreach($sexos as $v=>$l) $sOpts .= "";
+ $tOpts = ''; foreach($tipos as $v=>$l) $tOpts .= "";
+ return "
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+ |
+
+ |
+ ";
+}
+?>
+
+
+
+
+
+
+
diff --git a/modules/lab_examenes/views/index.php b/modules/lab_examenes/views/index.php
new file mode 100644
index 0000000..ce277d1
--- /dev/null
+++ b/modules/lab_examenes/views/index.php
@@ -0,0 +1,167 @@
+getConnection();
+$categorias = $pdo->query(
+ "SELECT DISTINCT categoria FROM exam_tipos WHERE categoria IS NOT NULL ORDER BY categoria"
+)->fetchAll(PDO::FETCH_COLUMN);
+
+$API = BASE_URL . 'modules/lab_examenes/api/';
+$DETAIL = BASE_URL . 'erp.php?m=lab_examenes&v=examen&id=';
+?>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ | Código |
+ Nombre |
+ Categoría |
+ CUPS |
+ Protocolo |
+ Se remite |
+ Activo |
+ |
+
+
+
+ | Cargando… |
+
+
+
+
+
+
+
+
+
diff --git a/scripts/etl_examenes.php b/scripts/etl_examenes.php
new file mode 100644
index 0000000..923f649
--- /dev/null
+++ b/scripts/etl_examenes.php
@@ -0,0 +1,303 @@
+getConnection();
+$pdo->setAttribute(PDO::ATTR_ERRMODE, PDO::ERRMODE_EXCEPTION);
+// MariaDB defaultea utf8mb4 a uca1400_ai_ci; forzamos unicode_ci para que
+// los literales de string en queries no colisionen con columnas de producción
+$pdo->exec("SET NAMES utf8mb4 COLLATE utf8mb4_unicode_ci");
+
+$doLoad = in_array('--load', $argv);
+$doClean = in_array('--clean', $argv);
+
+// ── helpers ──────────────────────────────────────────────────
+function run(PDO $pdo, string $sql, string $label): void {
+ $t = microtime(true);
+ $rows = $pdo->exec($sql);
+ printf(" %-45s %6d filas %.2fs\n", $label, $rows, microtime(true) - $t);
+}
+
+function step(string $msg): void {
+ echo "\n── $msg\n";
+}
+
+// ── limpiar staging si se pide ────────────────────────────────
+if ($doClean) {
+ $staging = ['SECCION','PROTOCOLO','MUESTRA','EXAMEN','ITEM',
+ 'PERFIL','PERFIL_EXA','TARIFAID','TARIFA','LAB_REFER',
+ 'stg_empresa','stg_examen_emp'];
+ $pdo->exec('SET FOREIGN_KEY_CHECKS=0');
+ foreach ($staging as $t) {
+ $pdo->exec("DROP TABLE IF EXISTS `$t`");
+ echo " DROP $t\n";
+ }
+ $pdo->exec('SET FOREIGN_KEY_CHECKS=1');
+ echo "Staging eliminado.\n";
+ exit(0);
+}
+
+// ── cargar staging desde examenes_completo.sql ────────────────
+if ($doLoad) {
+ $sqlFile = realpath(__DIR__ . '/../database/examenes_completo.sql');
+ if (!$sqlFile) { echo "ERROR: No se encuentra examenes_completo.sql\n"; exit(1); }
+
+ $tmpFile = sys_get_temp_dir() . '/examenes_staging_' . getmypid() . '.sql';
+ echo "Preparando staging SQL...\n";
+
+ $sql = file_get_contents($sqlFile);
+ $sql = preg_replace('/\blab_examenes_empresa\b/', 'stg_examen_emp', $sql);
+ $sql = preg_replace('/\blab_empresas\b/', 'stg_empresa', $sql);
+ $sql = preg_replace('/\bINSERT INTO\b/', 'INSERT IGNORE INTO', $sql);
+ file_put_contents($tmpFile, $sql);
+ unset($sql);
+
+ $host = DB_HOST; $port = DB_PORT; $user = DB_USER; $pass = DB_PASS; $db = DB_NAME;
+ echo "Cargando staging vía mysql CLI (puede tardar 2-3 min)...\n";
+ $cmd = "mysql -h " . escapeshellarg($host)
+ . " -P " . escapeshellarg($port)
+ . " -u " . escapeshellarg($user)
+ . " -p" . escapeshellarg($pass)
+ . " --default-character-set=utf8mb4"
+ . " " . escapeshellarg($db)
+ . " < " . escapeshellarg($tmpFile)
+ . " 2>&1";
+ passthru($cmd, $ret);
+ unlink($tmpFile);
+ if ($ret !== 0) { echo "ERROR cargando staging.\n"; exit(1); }
+ echo "Staging cargado.\n";
+}
+
+// ── verificar staging ─────────────────────────────────────────
+try {
+ $pdo->query("SELECT 1 FROM EXAMEN LIMIT 1");
+} catch (\Throwable $e) {
+ echo "ERROR: Tablas staging no encontradas. Ejecuta: php etl_examenes.php --load\n";
+ exit(1);
+}
+
+echo "ETL Firebird → MySQL\n";
+echo str_repeat('─', 60) . "\n";
+
+// convertir staging a misma collation que producción (MariaDB usa uca1400 por defecto)
+$pdo->exec('SET FOREIGN_KEY_CHECKS = 0');
+foreach (['SECCION','PROTOCOLO','MUESTRA','EXAMEN','ITEM',
+ 'PERFIL','PERFIL_EXA','TARIFAID','TARIFA','LAB_REFER',
+ 'stg_empresa','stg_examen_emp'] as $_t) {
+ try {
+ $pdo->exec("ALTER TABLE `$_t` CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci");
+ } catch (\Throwable $_e) { /* tabla no existe aún, ok */ }
+}
+$pdo->exec('SET UNIQUE_CHECKS = 0');
+
+// ── 1. SECCION → lab_secciones ───────────────────────────────
+step('1. SECCION → lab_secciones');
+run($pdo, "
+ INSERT IGNORE INTO lab_secciones (codigo, nombre)
+ SELECT CODIGO, NOMBRE FROM SECCION
+", 'lab_secciones');
+
+// ── 2. PROTOCOLO → lab_protocolos ────────────────────────────
+step('2. PROTOCOLO → lab_protocolos');
+run($pdo, "
+ INSERT IGNORE INTO lab_protocolos (codigo, nombre, cod_seccion)
+ SELECT CODIGO, NOMBRE, COD_SECCION FROM PROTOCOLO
+", 'lab_protocolos');
+
+// ── 3. MUESTRA → lab_tipos_muestra ───────────────────────────
+step('3. MUESTRA → lab_tipos_muestra');
+run($pdo, "
+ INSERT IGNORE INTO lab_tipos_muestra (codigo, nombre)
+ SELECT CODIGO, NOMBRE FROM MUESTRA
+", 'lab_tipos_muestra');
+
+// ── 4. EXAMEN → exam_tipos ───────────────────────────────────
+step('4. EXAMEN → exam_tipos');
+// codigo = EXAMEN.CODIGO (unique en Firebird, usamos como código interno también)
+// categoria = nombre de la sección del protocolo
+run($pdo, "
+ INSERT IGNORE INTO exam_tipos
+ (codigo, nombre, categoria, activo,
+ codigo_legacy, cups, cod_protocolo, tipo_muestra,
+ nivel, abreviatura, seremite, serecibe)
+ SELECT
+ e.CODIGO,
+ e.NOMBRE,
+ s.NOMBRE,
+ 1,
+ e.CODIGO,
+ NULLIF(e.CUPS, ''),
+ NULLIF(e.COD_PROTOCOLO, ''),
+ NULLIF(e.TIPOMUESTRA, ''),
+ e.NIVEL,
+ NULLIF(e.ABREVIATURA, ''),
+ IF(e.SEREMITE = 'T', 1, 0),
+ NULLIF(e.SERECIBE, '')
+ FROM EXAMEN e
+ LEFT JOIN PROTOCOLO p ON p.CODIGO = e.COD_PROTOCOLO
+ LEFT JOIN SECCION s ON s.CODIGO = p.COD_SECCION
+", 'exam_tipos');
+
+// ── 5. ITEM → lab_items_resultado ────────────────────────────
+step('5. ITEM → lab_items_resultado');
+// ponytail: truncate antes de insertar — ITEM no tiene UK en MySQL
+$pdo->exec('DELETE FROM lab_items_resultado');
+run($pdo, "
+ INSERT INTO lab_items_resultado
+ (cod_protocolo, nombre, tipo_sexo, tipo,
+ medida, abreviatura, vmin_ref, vmax_ref,
+ orden, formula, cups_detalle)
+ SELECT
+ i.COD_PROTOCOLO,
+ i.NOM_ITEM,
+ CASE i.TIPO_SEXO WHEN 'M' THEN 'M' WHEN 'F' THEN 'F' ELSE NULL END,
+ CASE WHEN i.TIPO = '2' THEN 'N' ELSE 'T' END,
+ NULLIF(i.MEDIDA, ''),
+ NULLIF(i.ABREVIATURA, ''),
+ NULLIF(i.VMINREF, 0),
+ NULLIF(i.VMAXREF, 0),
+ COALESCE(i.ORDEN, 0),
+ NULLIF(i.FORMULA, ''),
+ NULLIF(i.CUPS_DETALLE, '')
+ FROM ITEM i
+ WHERE EXISTS (SELECT 1 FROM lab_protocolos p WHERE p.codigo = i.COD_PROTOCOLO)
+", 'lab_items_resultado');
+
+// ── 6. PERFIL → lab_perfiles ─────────────────────────────────
+step('6. PERFIL → lab_perfiles');
+run($pdo, "
+ INSERT IGNORE INTO lab_perfiles (id, nombre, activo)
+ SELECT COD_PERFIL, NOM_PERFIL, 1 FROM PERFIL
+", 'lab_perfiles');
+
+// ── 7. PERFIL_EXA → lab_perfil_examenes ──────────────────────
+step('7. PERFIL_EXA → lab_perfil_examenes');
+run($pdo, "
+ INSERT IGNORE INTO lab_perfil_examenes (perfil_id, exam_tipo_id)
+ SELECT pe.COD_PERFIL, et.id
+ FROM PERFIL_EXA pe
+ JOIN exam_tipos et ON et.codigo_legacy = pe.COD_EXA
+", 'lab_perfil_examenes');
+
+// ── 8. TARIFAID → lab_tarifas_id ─────────────────────────────
+step('8. TARIFAID → lab_tarifas_id');
+run($pdo, "
+ INSERT IGNORE INTO lab_tarifas_id (id, nombre, tarifa_origen, porcentaje)
+ SELECT
+ COD_TARIFA,
+ NOMBRE,
+ NULLIF(TARIFAORIGEN, 0),
+ PORCENTAJE
+ FROM TARIFAID
+", 'lab_tarifas_id');
+
+// ── 9. TARIFA → lab_tarifas (104 788 filas) ──────────────────
+step('9. TARIFA → lab_tarifas [puede tardar ~30s]');
+run($pdo, "
+ INSERT IGNORE INTO lab_tarifas
+ (cod_examen_legacy, exam_tipo_id, tarifa_id,
+ valor, recargo_urg, recargo_fes, recargo_esp)
+ SELECT
+ t.COD_EXAMEN,
+ et.id,
+ t.TARIFA,
+ COALESCE(t.VALOR, 0),
+ COALESCE(t.RECARGO_URG, 0),
+ COALESCE(t.RECARGO_FES, 0),
+ COALESCE(t.RECARGO_ESP, 0)
+ FROM TARIFA t
+ LEFT JOIN exam_tipos et ON et.codigo_legacy = t.COD_EXAMEN
+", 'lab_tarifas');
+
+// ── 10. LAB_REFER → lab_laboratorios_externos ────────────────
+step('10. LAB_REFER → lab_laboratorios_externos');
+$pdo->exec("
+ CREATE TABLE IF NOT EXISTS lab_laboratorios_externos (
+ codigo VARCHAR(20) NOT NULL,
+ nombre VARCHAR(100),
+ nit_lab VARCHAR(30),
+ direccion VARCHAR(200),
+ telefonos VARCHAR(50),
+ email VARCHAR(100),
+ activo TINYINT(1) NOT NULL DEFAULT 1,
+ PRIMARY KEY (codigo)
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
+ COMMENT='Laboratorios externos de referencia — replica LAB_REFER Firebird'
+");
+run($pdo, "
+ INSERT IGNORE INTO lab_laboratorios_externos
+ (codigo, nombre, nit_lab, direccion, telefonos, email, activo)
+ SELECT
+ CODIGO, NOMBRE, NIT_LAB, DIRECCION, TELEFONOS, EMAIL,
+ IF(ACTIVADO = 'T', 1, 0)
+ FROM LAB_REFER
+", 'lab_laboratorios_externos');
+
+// ── 11. EMPRESA (staging) → lab_empresas ─────────────────────
+step('11. EMPRESA → lab_empresas');
+// ponytail: staging usa columnas Firebird, producción tiene columnas en snake_case
+run($pdo, "
+ INSERT IGNORE INTO lab_empresas
+ (nit, nombre, razon_social, tarifa_id, descuento_pct,
+ codigo_eps, tipo_usuario, tipo_usuario_sispro,
+ cod_contrato, cod_tercero, centro_costo,
+ req_autoriza, activa)
+ SELECT
+ NIT,
+ NOMBRE,
+ NULLIF(RAZONSOCIAL, ''),
+ NULLIF(TARIFA, 0),
+ COALESCE(DSCTO, 0),
+ NULLIF(CODIGOEPS, ''),
+ NULLIF(TIPOUSUARIO, ''),
+ NULLIF(TIPOUSUARIOSISPRO, ''),
+ NULLIF(CODCONTRATO, ''),
+ NULLIF(CODTERCERO, ''),
+ NULLIF(CENTROCOSTO, ''),
+ IF(REQAUTORIZA = 'T', 1, 0),
+ IF(ACTIVADA = 'T', 1, 0)
+ FROM stg_empresa
+", 'lab_empresas');
+
+// ── 12. EXAMEN_EMP (staging) → lab_examenes_empresa ──────────
+step('12. EXAMEN_EMP → lab_examenes_empresa');
+run($pdo, "
+ INSERT IGNORE INTO lab_examenes_empresa
+ (nit_empresa, cod_examen_legacy, exam_tipo_id, codigo_empresa)
+ SELECT
+ ee.NIT_EMP,
+ ee.COD_EXA,
+ et.id,
+ NULLIF(ee.EXA_EMP, '')
+ FROM stg_examen_emp ee
+ LEFT JOIN exam_tipos et ON et.codigo_legacy = ee.COD_EXA
+", 'lab_examenes_empresa');
+
+// ── fin ───────────────────────────────────────────────────────
+$pdo->exec('SET FOREIGN_KEY_CHECKS = 1');
+$pdo->exec('SET UNIQUE_CHECKS = 1');
+
+echo "\n" . str_repeat('─', 60) . "\n";
+echo "ETL completado.\n\n";
+
+// resumen rápido
+$tablas = [
+ 'lab_secciones', 'lab_protocolos', 'lab_tipos_muestra', 'exam_tipos',
+ 'lab_items_resultado', 'lab_perfiles', 'lab_perfil_examenes',
+ 'lab_tarifas_id', 'lab_tarifas', 'lab_laboratorios_externos',
+ 'lab_empresas', 'lab_examenes_empresa',
+];
+echo "Totales en producción:\n";
+foreach ($tablas as $t) {
+ $n = $pdo->query("SELECT COUNT(*) FROM `$t`")->fetchColumn();
+ printf(" %-35s %7d\n", $t, $n);
+}
+echo "\nListo.\n";