Files
whatsapp/modules/turnero/api/get_dashboard.php
T
Lizandro GuarnizoandClaude Opus 5 336836f06d Dashboard: los protocolos prolongados se cuentan hasta la primera toma
Una curva de glucosa dura dos horas, pero de esas el puesto solo trabaja unos
minutos al principio y otros al final: el resto el paciente está esperando el
examen. El promedio contaba esas horas como tiempo de atención.

Ahora, cuando el turno tiene un formulario de tomas prolongadas, el servicio
se corta en la primera toma (toma_inicio_at). Sin protocolo, nada cambia.

El efecto es grande y explica por qué los números no cuadraban:

  promedio general      21,9 min → 7,4 min
  Omaira Limas          50,9 min → 22,6 min
  Luisa Gil             32,0 min → 7,9 min
  Yesica Morales        23,5 min → 6,5 min

Lo importante no es que el número baje sino que era injusto: quien atendía
protocolos prolongados aparecía como la más lenta cuando estaba haciendo el
mismo trabajo que las demás. El promedio corregido coincide con el de los
turnos que nunca tuvieron protocolo (8,2 min), que es la comprobación de que
el cálculo ahora mide lo que dice medir.

Se aplica a los tres promedios: general, por puesto y por profesional. El
detalle de cada turno sigue mostrando el tiempo real transcurrido.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-19 17:18:04 -05:00

317 lines
13 KiB
PHP

<?php
/**
* modules/turnero/api/get_dashboard.php
* GET ?fecha=YYYY-MM-DD (por defecto = hoy)
*
* Devuelve métricas del día para el dashboard del turnero:
* - resumen global (total, atendidos, pendientes, ausentes, tiempo promedio)
* - desglose por prioridad
* - desglose por lugar
* - desglose por recepcionista y por bacteriólogo (quién atendió a cuántos)
* - lista de turnos del día (para tabla detalle)
*/
require_once __DIR__ . '/_helpers.php';
requireTurnero();
requireMethod('GET');
$fecha = trim($_GET['fecha'] ?? '');
if ($fecha === '' || !preg_match('/^\d{4}-\d{2}-\d{2}$/', $fecha)) {
$fecha = date('Y-m-d');
}
$pdo = db();
// ── 1. Sesión del día ─────────────────────────────────────────
$stmtSesion = $pdo->prepare(
'SELECT id, inicio_at, fin_at FROM turnero_sesiones WHERE fecha = ? LIMIT 1'
);
$stmtSesion->execute([$fecha]);
$sesion = $stmtSesion->fetch(PDO::FETCH_ASSOC);
if (!$sesion) {
jsonOk([
'fecha' => $fecha,
'sesion' => null,
'resumen' => [
'total' => 0,
'atendidos' => 0,
'en_espera' => 0,
'ausentes' => 0,
'cancelados' => 0,
'tiempo_espera_promedio_min' => null,
'tiempo_servicio_promedio_min' => null,
],
'por_prioridad' => [],
'por_lugar' => [],
'por_recepcionista' => [],
'por_bacteriologo' => [],
'consent_stats' => [],
'turnos' => [],
]);
}
$sesionId = (int)$sesion['id'];
// ── 2. Resumen global ─────────────────────────────────────────
$stmtRes = $pdo->prepare(
"SELECT
COUNT(*) AS total,
SUM(t.estado IN ('finalizado','en_servicio')) AS atendidos,
SUM(t.estado IN ('espera','en_recepcion','en_espera_lugar')) AS en_espera,
SUM(t.estado = 'ausente') AS ausentes,
SUM(t.estado = 'cancelado') AS cancelados,
ROUND(
AVG(
CASE
WHEN t.inicio_recepcion_at IS NOT NULL AND t.creado_at IS NOT NULL
THEN TIMESTAMPDIFF(SECOND, t.creado_at, t.inicio_recepcion_at) / 60.0
END
), 1
) AS tiempo_espera_promedio_min,
-- En los protocolos prolongados la atención se corta en la PRIMERA toma:
-- lo que sigue son horas de espera del examen (una curva de glucosa son
-- 2 horas), no tiempo de trabajo. Contarlas triplicaba el promedio —21,9
-- min contra 7,4— y hacía ver los puestos mucho más lentos de lo que son.
ROUND(
AVG(
CASE
WHEN t.inicio_lugar_at IS NOT NULL
AND COALESCE(prot.toma_inicio_at, t.fin_lugar_at) IS NOT NULL
THEN TIMESTAMPDIFF(SECOND, t.inicio_lugar_at,
COALESCE(prot.toma_inicio_at, t.fin_lugar_at)) / 60.0
END
), 1
) AS tiempo_servicio_promedio_min,
ROUND(
AVG(
CASE
WHEN t.creado_at IS NOT NULL
AND COALESCE(prot.toma_inicio_at, t.fin_lugar_at) IS NOT NULL
THEN TIMESTAMPDIFF(SECOND, t.creado_at,
COALESCE(prot.toma_inicio_at, t.fin_lugar_at)) / 60.0
END
), 1
) AS tiempo_total_promedio_min
FROM turnero_turnos t
-- Un turno tiene a lo sumo un protocolo prolongado, así que el join no duplica
LEFT JOIN (
SELECT tc.turno_id, MIN(tc.toma_inicio_at) AS toma_inicio_at
FROM turnero_consentimientos tc
JOIN lab_formularios f ON f.id = tc.formulario_id AND f.es_toma_progresiva = 1
GROUP BY tc.turno_id
) prot ON prot.turno_id = t.id
WHERE t.sesion_id = ?"
);
$stmtRes->execute([$sesionId]);
$resumen = $stmtRes->fetch(PDO::FETCH_ASSOC);
// ── 3. Desglose por prioridad ─────────────────────────────────
$stmtPri = $pdo->prepare(
"SELECT p.codigo, p.nombre, p.color,
COUNT(t.id) AS total,
SUM(t.estado IN ('finalizado','en_servicio')) AS atendidos,
SUM(t.estado IN ('espera','en_recepcion','en_espera_lugar')) AS pendientes,
SUM(t.estado = 'ausente') AS ausentes
FROM turnero_turnos t
JOIN turnero_prioridades p ON p.id = t.prioridad_id
WHERE t.sesion_id = ?
GROUP BY p.id
ORDER BY p.orden_peso ASC"
);
$stmtPri->execute([$sesionId]);
$porPrioridad = $stmtPri->fetchAll(PDO::FETCH_ASSOC);
// ── 4. Desglose por lugar ─────────────────────────────────────
$stmtLug = $pdo->prepare(
"SELECT l.nombre AS lugar,
COUNT(t.id) AS total,
SUM(t.estado IN ('finalizado','en_servicio')) AS atendidos,
SUM(t.estado IN ('en_espera_lugar')) AS en_espera,
ROUND(AVG(
CASE
WHEN t.inicio_lugar_at IS NOT NULL
AND COALESCE(prot.toma_inicio_at, t.fin_lugar_at) IS NOT NULL
THEN TIMESTAMPDIFF(SECOND, t.inicio_lugar_at,
COALESCE(prot.toma_inicio_at, t.fin_lugar_at)) / 60.0
END
), 1) AS tiempo_servicio_promedio_min
FROM turnero_turnos t
JOIN turnero_lugares l ON l.id = t.lugar_destino_id
LEFT JOIN (
SELECT tc.turno_id, MIN(tc.toma_inicio_at) AS toma_inicio_at
FROM turnero_consentimientos tc
JOIN lab_formularios f ON f.id = tc.formulario_id AND f.es_toma_progresiva = 1
GROUP BY tc.turno_id
) prot ON prot.turno_id = t.id
WHERE t.sesion_id = ?
GROUP BY l.id
ORDER BY l.sort_order ASC, l.nombre ASC"
);
$stmtLug->execute([$sesionId]);
$porLugar = $stmtLug->fetchAll(PDO::FETCH_ASSOC);
// ── 4b. Desglose por recepcionista ────────────────────────────
// Mismo molde que el ranking de enfermeras de lab_domicilios
// (api/lab/get_metricas.php). atendido_recepcion_por guarda al PRIMER
// recepcionista que tomó el turno (COALESCE en cambiar_estado.php).
$stmtRec = $pdo->prepare(
"SELECT u.id,
COALESCE(NULLIF(u.full_name, ''), u.username) AS nombre,
COUNT(t.id) AS total,
SUM(t.estado IN ('finalizado','en_servicio','en_espera_lugar')) AS atendidos,
SUM(t.estado = 'ausente') AS ausentes,
SUM(t.estado = 'cancelado') AS cancelados,
ROUND(AVG(
CASE
WHEN t.fin_recepcion_at IS NOT NULL AND t.inicio_recepcion_at IS NOT NULL
THEN TIMESTAMPDIFF(SECOND, t.inicio_recepcion_at, t.fin_recepcion_at) / 60.0
END
), 1) AS tiempo_promedio_min
FROM turnero_turnos t
JOIN admin_users u ON u.id = t.atendido_recepcion_por
WHERE t.sesion_id = ?
GROUP BY u.id, u.full_name, u.username
ORDER BY atendidos DESC, total DESC"
);
$stmtRec->execute([$sesionId]);
$porRecepcionista = $stmtRec->fetchAll(PDO::FETCH_ASSOC);
// ── 4c. Desglose por bacteriólogo (quien atendió en el lugar) ──
$stmtBac = $pdo->prepare(
"SELECT u.id,
COALESCE(NULLIF(u.full_name, ''), u.username) AS nombre,
COUNT(t.id) AS total,
SUM(t.estado = 'finalizado') AS finalizados,
SUM(t.estado = 'en_servicio') AS en_servicio,
SUM(t.estado = 'ausente') AS ausentes,
ROUND(AVG(
CASE
WHEN t.inicio_lugar_at IS NOT NULL
AND COALESCE(prot.toma_inicio_at, t.fin_lugar_at) IS NOT NULL
THEN TIMESTAMPDIFF(SECOND, t.inicio_lugar_at,
COALESCE(prot.toma_inicio_at, t.fin_lugar_at)) / 60.0
END
), 1) AS tiempo_promedio_min
FROM turnero_turnos t
JOIN admin_users u ON u.id = t.atendido_lugar_por
LEFT JOIN (
SELECT tc.turno_id, MIN(tc.toma_inicio_at) AS toma_inicio_at
FROM turnero_consentimientos tc
JOIN lab_formularios f ON f.id = tc.formulario_id AND f.es_toma_progresiva = 1
GROUP BY tc.turno_id
) prot ON prot.turno_id = t.id
WHERE t.sesion_id = ?
GROUP BY u.id, u.full_name, u.username
ORDER BY finalizados DESC, total DESC"
);
$stmtBac->execute([$sesionId]);
$porBacteriologo = $stmtBac->fetchAll(PDO::FETCH_ASSOC);
// ── 5. Lista de turnos del día ────────────────────────────────
$stmtTurnos = $pdo->prepare(
"SELECT t.id, t.codigo, t.numero, t.estado,
t.paciente_nombre, t.paciente_cel,
t.creado_at, t.inicio_recepcion_at, t.fin_recepcion_at,
t.inicio_lugar_at, t.fin_lugar_at,
p.codigo AS prioridad_codigo, p.nombre AS prioridad_nombre, p.color AS prioridad_color,
l.nombre AS lugar_nombre,
s.nombre_completo AS paciente_bd,
s.numero_documento AS paciente_doc,
s.tipo_documento AS paciente_tipo_doc,
rd.nombre AS recepcion_desk_nombre,
ur.full_name AS atendido_recepcion_nombre,
ul.full_name AS atendido_lugar_nombre,
ts.observaciones AS notas,
ROUND(TIMESTAMPDIFF(SECOND, t.creado_at, COALESCE(t.inicio_recepcion_at, NOW())) / 60.0, 1) AS espera_min,
ROUND(TIMESTAMPDIFF(SECOND, t.inicio_lugar_at, COALESCE(t.fin_lugar_at, NOW())) / 60.0, 1) AS servicio_min,
ROUND(TIMESTAMPDIFF(SECOND, t.creado_at, t.fin_lugar_at) / 60.0, 1) AS total_min
FROM turnero_turnos t
JOIN turnero_prioridades p ON p.id = t.prioridad_id
LEFT JOIN turnero_lugares l ON l.id = t.lugar_destino_id
LEFT JOIN turnero_lugares rd ON rd.id = t.recepcion_desk_id
LEFT JOIN admin_users ur ON ur.id = t.atendido_recepcion_por
LEFT JOIN admin_users ul ON ul.id = t.atendido_lugar_por
LEFT JOIN turnero_solicitudes ts ON ts.turno_id = t.id
LEFT JOIN lab_pacientes s ON s.id = ts.paciente_id
WHERE t.sesion_id = ?
ORDER BY t.numero ASC"
);
$stmtTurnos->execute([$sesionId]);
$turnos = $stmtTurnos->fetchAll(PDO::FETCH_ASSOC);
// Enriquecer con exámenes y comentarios — en lote.
// Antes eran 2 consultas por turno dentro del foreach (N+1): con 80 turnos en
// el día eran 160 consultas evitables. Mismo patrón que get_historial.php.
$turnoIds = array_column($turnos, 'id');
$examenesPorTurno = [];
$comentariosPorTurno = [];
if ($turnoIds) {
$ph = implode(',', array_fill(0, count($turnoIds), '?'));
$stmtEx = $pdo->prepare(
"SELECT DISTINCT ts.turno_id, et.nombre
FROM turnero_solicitudes ts
JOIN turnero_examen_items tei ON tei.solicitud_id = ts.id
JOIN exam_tipos et ON et.id = tei.exam_tipo_id
WHERE ts.turno_id IN ($ph)
ORDER BY et.nombre ASC"
);
$stmtEx->execute($turnoIds);
foreach ($stmtEx->fetchAll(PDO::FETCH_ASSOC) as $row) {
$examenesPorTurno[(int)$row['turno_id']][] = ['nombre' => $row['nombre']];
}
$stmtCom = $pdo->prepare(
"SELECT turno_id, usuario_nombre, comentario, tipo, creado_at
FROM turnero_comentarios
WHERE turno_id IN ($ph)
ORDER BY turno_id, creado_at ASC"
);
$stmtCom->execute($turnoIds);
foreach ($stmtCom->fetchAll(PDO::FETCH_ASSOC) as $row) {
$tid = (int)$row['turno_id'];
unset($row['turno_id']);
$comentariosPorTurno[$tid][] = $row;
}
}
foreach ($turnos as &$turno) {
$tid = (int)$turno['id'];
$turno['examenes'] = $examenesPorTurno[$tid] ?? [];
$turno['comentarios'] = $comentariosPorTurno[$tid] ?? [];
}
unset($turno);
// ── 6. Consentimientos del día ────────────────────────────────
$stmtcons = $pdo->prepare(
"SELECT tc.estado, COUNT(*) AS cnt
FROM turnero_consentimientos tc
JOIN turnero_turnos t ON t.id = tc.turno_id
WHERE t.sesion_id = ?
GROUP BY tc.estado"
);
$stmtcons->execute([$sesionId]);
$consentStats = [];
foreach ($stmtcons->fetchAll(PDO::FETCH_ASSOC) as $r) {
$consentStats[$r['estado']] = (int)$r['cnt'];
}
jsonOk([
'fecha' => $fecha,
'sesion' => [
'id' => $sesionId,
'inicio' => $sesion['inicio_at'] ? date('H:i', strtotime($sesion['inicio_at'])) : null,
'fin' => $sesion['fin_at'] ? date('H:i', strtotime($sesion['fin_at'])) : null,
'abierta' => $sesion['fin_at'] === null,
],
'resumen' => $resumen,
'por_prioridad' => $porPrioridad,
'por_lugar' => $porLugar,
'por_recepcionista' => $porRecepcionista,
'por_bacteriologo' => $porBacteriologo,
'consent_stats' => $consentStats,
'turnos' => $turnos,
]);