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:
co-authored by
Claude Sonnet 4.6
parent
86ed70db34
commit
5fedd233f4
@@ -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');
|
||||
@@ -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');
|
||||
|
||||
Reference in New Issue
Block a user