feat: módulo lab_examenes — CRUD catálogo de exámenes, items y tarifas

- Nuevo módulo lab_examenes con lista paginada, buscador y filtro por categoría
- Vista de detalle/edición con campos completos de exam_tipos (CUPS, protocolo,
  tipo muestra, nivel, abreviatura, seremite, ayuno, instrucciones)
- Sub-tabla inline de items de resultado (lab_items_resultado) con edición por fila
- Sub-tabla de precios por tarifa (lab_tarifas) con modal de edición
- APIs: list, get, save, save_item, save_tarifa, get_tarifas
- Script ETL Firebird→MySQL (scripts/etl_examenes.php) para la migración inicial

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Lizandro Guarnizo
2026-07-07 23:06:54 -05:00
co-authored by Claude Sonnet 4.6
parent 229977785b
commit fb0e77523c
11 changed files with 1285 additions and 0 deletions
+55
View File
@@ -0,0 +1,55 @@
<?php
ob_start();
require_once __DIR__ . '/../../../config/config.php';
error_reporting(E_ERROR | E_PARSE);
ini_set('display_errors', '0');
ini_set('html_errors', '0');
register_shutdown_function(function () {
$err = error_get_last();
if ($err && in_array($err['type'], [E_ERROR, E_PARSE, E_CORE_ERROR, E_COMPILE_ERROR])) {
ob_clean();
http_response_code(500);
header('Content-Type: application/json; charset=utf-8');
echo json_encode(['ok' => 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;
}
+20
View File
@@ -0,0 +1,20 @@
<?php
require_once __DIR__ . '/_helpers.php';
requireLogin();
$id = (int)($_GET['id'] ?? 0);
if (!$id) jsonError('ID requerido');
$pdo = db();
$s = $pdo->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)]);
+23
View File
@@ -0,0 +1,23 @@
<?php
require_once __DIR__ . '/_helpers.php';
requireLogin();
$examId = (int)($_GET['exam_id'] ?? 0);
if (!$examId) jsonError('exam_id requerido');
$pdo = db();
$s = $pdo->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)]);
+37
View File
@@ -0,0 +1,37 @@
<?php
require_once __DIR__ . '/_helpers.php';
requireLogin();
$q = trim($_GET['q'] ?? '');
$cat = trim($_GET['categoria'] ?? '');
$page = max(1, (int)($_GET['page'] ?? 1));
$limit = max(1, min(200, (int)($_GET['limit'] ?? 50)));
$offset = ($page - 1) * $limit;
$where = [];
$params = [];
if ($q !== '') {
$like = '%' . $q . '%';
$where[] = '(nombre LIKE ? OR codigo LIKE ? OR cups LIKE ?)';
$params[] = $like; $params[] = $like; $params[] = $like;
}
if ($cat !== '') {
$where[] = 'categoria = ?';
$params[] = $cat;
}
$wSql = $where ? 'WHERE ' . implode(' AND ', $where) : '';
$pdo = db();
$s = $pdo->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]);
+73
View File
@@ -0,0 +1,73 @@
<?php
require_once __DIR__ . '/_helpers.php';
requireAdmin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') jsonError('Método no permitido', 405);
$d = inputJson();
$id = (int)($d['id'] ?? 0);
if (!empty($d['_delete'])) {
if (!$id) jsonError('ID requerido');
$pdo = db();
$s = $pdo->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);
}
+44
View File
@@ -0,0 +1,44 @@
<?php
require_once __DIR__ . '/_helpers.php';
requireAdmin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') jsonError('Método no permitido', 405);
$d = inputJson();
$id = (int)($d['id'] ?? 0);
if (!empty($d['_delete'])) {
if (!$id) jsonError('ID requerido');
db()->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');
}
+45
View File
@@ -0,0 +1,45 @@
<?php
require_once __DIR__ . '/_helpers.php';
requireAdmin();
if ($_SERVER['REQUEST_METHOD'] !== 'POST') jsonError('Método no permitido', 405);
$d = inputJson();
$examId = (int)($d['exam_tipo_id'] ?? 0);
$tarifaId = (int)($d['tarifa_id'] ?? 0);
if (!$examId || !$tarifaId) jsonError('exam_tipo_id y tarifa_id son requeridos');
$pdo = db();
if (!empty($d['_delete'])) {
$pdo->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');