From 0bcb5186193daf797fd199817e5e5068272439e8 Mon Sep 17 00:00:00 2001 From: Lizandro Guarnizo <77708265+lizandrogd@users.noreply.github.com> Date: Thu, 27 Aug 2026 12:56:29 -0500 Subject: [PATCH] Tracking de muestras para el Data Lab: el viaje de vuelta del sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit La ida ya existía: ingest_paciente.php recibe el payload con los exámenes y los guarda (y responde con examenes_guardados como acuse); el ERP genera las muestras solo, a partir del tipo de muestra de su catálogo — por eso el payload NO necesita campos nuevos. Lo que no existía era la vuelta: que el sistema del laboratorio pueda saber qué pasó con cada muestra sin que nadie mire el ERP. get_tracking_muestras.php lo resuelve consultando por rango de fechas (tope 31 días) o por documento, con la misma autenticación X-Lab-Key del ingest. Devuelve, por turno: fecha, código, paciente, exámenes en CÓDIGO LEGACY (el que el Data Lab entiende) y cada muestra con tipo, estado, motivo de rechazo, hora y quién la recibió. Solo lectura. scripts/test_tracking_muestras.php: 11 pruebas ejecutando el endpoint real contra la base real con el caso E018 del 27/08 — sus 10 muestras, el coprológico rechazado con su motivo, el filtro por documento, la clave, y la cordura de parámetros. Pendiente humano (no de código): crear MYBPC3 y SONDA en el módulo Exámenes; son los únicos 2 códigos de 265 vistos en RIPS esta semana sin mapeo, y no los invento porque llevan precio y tipo de muestra clínicos. Co-Authored-By: Claude Fable 5 --- api/lab/get_tracking_muestras.php | 140 +++++++++++++++++++++++++++++ scripts/test_tracking_muestras.php | 74 +++++++++++++++ 2 files changed, 214 insertions(+) create mode 100644 api/lab/get_tracking_muestras.php create mode 100644 scripts/test_tracking_muestras.php diff --git a/api/lab/get_tracking_muestras.php b/api/lab/get_tracking_muestras.php new file mode 100644 index 0000000..11f034c --- /dev/null +++ b/api/lab/get_tracking_muestras.php @@ -0,0 +1,140 @@ + (igual que el ingest). + * + * Parámetros: + * desde Y-m-d opcional (por defecto: hoy) + * hasta Y-m-d opcional (por defecto: igual a desde) + * documento string opcional — solo ese paciente + * + * Respuesta: + * { ok, desde, hasta, total, turnos: [ { fecha, turno, estado_turno, + * numero_documento, paciente, examenes: [cod_legacy…], + * muestras: [ { tipo, nombre_tipo, estado, motivo_rechazo, + * recibida_at, recibida_por } ] } ] } + * + * Solo lectura: no crea, no modifica, no borra. + */ + +ob_start(); +require_once __DIR__ . '/../../config/config.php'; + +header('Content-Type: application/json; charset=utf-8'); + +function salir(array $r, int $code = 200): void { + ob_clean(); + http_response_code($code); + echo json_encode($r, JSON_UNESCAPED_UNICODE); + exit; +} + +if ($_SERVER['REQUEST_METHOD'] !== 'GET') { + salir(['ok' => false, 'error' => 'Método no permitido'], 405); +} + +// ── Autenticación por API key, idéntica al ingest ──────────────────────── +$keyHeader = $_SERVER['HTTP_X_LAB_KEY'] ?? $_SERVER['HTTP_X_LAB_SYNC_KEY'] ?? ''; +if (!defined('LAB_SYNC_KEY') || !$keyHeader || !hash_equals(LAB_SYNC_KEY, $keyHeader)) { + salir(['ok' => false, 'error' => 'No autorizado'], 401); +} + +// ── Parámetros ─────────────────────────────────────────────────────────── +$hoy = date('Y-m-d'); +$desde = $_GET['desde'] ?? $hoy; +$hasta = $_GET['hasta'] ?? $desde; +if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $desde)) $desde = $hoy; +if (!preg_match('/^\d{4}-\d{2}-\d{2}$/', $hasta)) $hasta = $desde; +if ($desde > $hasta) [$desde, $hasta] = [$hasta, $desde]; +// Tope de 31 días por consulta: es un endpoint de sincronización, no de archivo +if ((strtotime($hasta) - strtotime($desde)) > 31 * 86400) { + salir(['ok' => false, 'error' => 'Rango máximo: 31 días'], 400); +} +$documento = trim($_GET['documento'] ?? ''); + +$pdo = Database::getInstance()->getConnection(); + +// ── Turnos del rango con paciente y solicitud ──────────────────────────── +$where = "DATE(t.creado_at) BETWEEN :desde AND :hasta AND ts.id IS NOT NULL"; +$binds = ['desde' => $desde, 'hasta' => $hasta]; +if ($documento !== '') { + $where .= " AND p.numero_documento = :doc"; + $binds['doc'] = $documento; +} + +$stmt = $pdo->prepare( + "SELECT t.id AS turno_id, t.codigo AS turno, t.estado AS estado_turno, + DATE(t.creado_at) AS fecha, + ts.id AS solicitud_id, + p.numero_documento, p.nombre_completo AS paciente + FROM turnero_turnos t + JOIN turnero_solicitudes ts ON ts.turno_id = t.id + LEFT JOIN lab_pacientes p ON p.id = ts.paciente_id + WHERE {$where} + ORDER BY t.creado_at" +); +$stmt->execute($binds); +$turnos = $stmt->fetchAll(PDO::FETCH_ASSOC); + +if (!$turnos) { + salir(['ok' => true, 'desde' => $desde, 'hasta' => $hasta, 'total' => 0, 'turnos' => []]); +} + +$solIds = array_column($turnos, 'solicitud_id'); +$ph = implode(',', array_fill(0, count($solIds), '?')); + +// ── Muestras de esas solicitudes, con nombre del tipo y quién recibió ──── +$stmtM = $pdo->prepare( + "SELECT m.solicitud_id, m.tipo_muestra AS tipo, m.estado, m.motivo_rechazo, + m.recibida_at, u.full_name AS recibida_por, + tm.nombre AS nombre_tipo + FROM turnero_muestras m + LEFT JOIN admin_users u ON u.id = m.recibida_por + LEFT JOIN lab_tipos_muestra tm + ON tm.codigo COLLATE utf8mb4_unicode_ci = m.tipo_muestra COLLATE utf8mb4_unicode_ci + WHERE m.solicitud_id IN ({$ph})" +); +$stmtM->execute($solIds); +$muestrasPorSol = []; +foreach ($stmtM->fetchAll(PDO::FETCH_ASSOC) as $m) { + $sid = (int) $m['solicitud_id']; + unset($m['solicitud_id']); + $muestrasPorSol[$sid][] = $m; +} + +// ── Exámenes (códigos legacy, que son los que el Data Lab entiende) ────── +$stmtE = $pdo->prepare( + "SELECT i.solicitud_id, COALESCE(NULLIF(et.codigo_legacy,''), et.codigo) AS cod + FROM turnero_examen_items i + JOIN exam_tipos et ON et.id = i.exam_tipo_id + WHERE i.solicitud_id IN ({$ph})" +); +$stmtE->execute($solIds); +$examenesPorSol = []; +foreach ($stmtE->fetchAll(PDO::FETCH_ASSOC) as $e) { + $examenesPorSol[(int) $e['solicitud_id']][] = $e['cod']; +} + +$salida = []; +foreach ($turnos as $t) { + $sid = (int) $t['solicitud_id']; + $salida[] = [ + 'fecha' => $t['fecha'], + 'turno' => $t['turno'], + 'estado_turno' => $t['estado_turno'], + 'numero_documento' => $t['numero_documento'], + 'paciente' => $t['paciente'], + 'examenes' => $examenesPorSol[$sid] ?? [], + 'muestras' => $muestrasPorSol[$sid] ?? [], + ]; +} + +salir(['ok' => true, 'desde' => $desde, 'hasta' => $hasta, + 'total' => count($salida), 'turnos' => $salida]); diff --git a/scripts/test_tracking_muestras.php b/scripts/test_tracking_muestras.php new file mode 100644 index 0000000..f27843e --- /dev/null +++ b/scripts/test_tracking_muestras.php @@ -0,0 +1,74 @@ +/dev/null', + escapeshellarg(json_encode($get)), + escapeshellarg($clave ?? ''), + escapeshellarg($code)); + return json_decode(shell_exec($cmd) ?: 'null', true); +} + +require_once __DIR__ . '/../config/config.php'; +$clave = LAB_SYNC_KEY; + +// ── 1. Sin clave: afuera ──────────────────────────────────────────────── +$r = llamar(['desde' => '2026-08-27'], null); +ok('sin la clave responde no-autorizado', is_array($r) && ($r['ok'] ?? true) === false); + +// ── 2. Con clave, el día del E018 conocido ────────────────────────────── +$r = llamar(['desde' => '2026-08-27'], $clave); +ok('con la clave responde ok', is_array($r) && ($r['ok'] ?? false) === true); +ok('trae turnos de ese día', ($r['total'] ?? 0) > 0); + +$e018 = null; +foreach ($r['turnos'] ?? [] as $t) if ($t['turno'] === 'E018') { $e018 = $t; break; } +ok('el E018 está en la respuesta', $e018 !== null); +if ($e018) { + ok('con sus 10 muestras', count($e018['muestras']) === 10); + ok('y sus exámenes en código legacy (49)', count($e018['examenes']) === 49); + $mf = null; + foreach ($e018['muestras'] as $m) if ($m['tipo'] === 'MF') { $mf = $m; break; } + ok('el coprológico aparece RECHAZADO con su motivo', + $mf && $mf['estado'] === 'rechazada' && $mf['motivo_rechazo'] === 'la trae mañana'); + $ss = null; + foreach ($e018['muestras'] as $m) if ($m['tipo'] === 'SS') { $ss = $m; break; } + ok('el suero aparece recibido, con quién y cuándo', + $ss && $ss['estado'] === 'recibida' && !empty($ss['recibida_por']) && !empty($ss['recibida_at'])); +} + +// ── 3. Filtro por documento ───────────────────────────────────────────── +if ($e018) { + $r = llamar(['desde' => '2026-08-27', 'documento' => $e018['numero_documento']], $clave); + $codigos = array_column($r['turnos'] ?? [], 'turno'); + ok('filtrando por documento solo vienen los turnos de ese paciente', + in_array('E018', $codigos, true) + && count($codigos) === count(array_filter($r['turnos'], fn($t) => $t['numero_documento'] === $e018['numero_documento']))); +} + +// ── 4. Cordura de parámetros ──────────────────────────────────────────── +$r = llamar(['desde' => '2026-01-01', 'hasta' => '2026-12-31'], $clave); +ok('un rango de un año se rechaza (tope 31 días)', ($r['ok'] ?? true) === false); +$r = llamar(['desde' => 'basura'], $clave); +ok('una fecha inválida cae al día de hoy sin romperse', ($r['ok'] ?? false) === true); + +echo "\n" . ($fallos === 0 ? "Todo correcto.\n" : "$fallos pruebas fallaron.\n"); +exit($fallos === 0 ? 0 : 1);