siguienteTurnoEnCola ahora acepta sesion_id opcional. llamar_turno.php pasa la sesión de hoy para que nunca agarre turnos viejos de días anteriores que quedaron en estado 'espera'. Sin el filtro, el primer llamado del día podía agarrar un turno de ayer, la ficha abría en recepción pero la pantalla TV (get_display_recepcion.php) filtra por sesion_id=hoy y no lo encontraba → el número no salía en display. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
288 lines
8.5 KiB
PHP
288 lines
8.5 KiB
PHP
<?php
|
|
/**
|
|
* Helpers compartidos para los endpoints del módulo Turnero.
|
|
* Incluido por cada endpoint de modules/turnero/api/.
|
|
*/
|
|
|
|
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');
|
|
header('Access-Control-Allow-Origin: *');
|
|
header('Access-Control-Allow-Methods: GET, POST, OPTIONS');
|
|
header('Access-Control-Allow-Headers: Content-Type');
|
|
|
|
if ($_SERVER['REQUEST_METHOD'] === 'OPTIONS') {
|
|
http_response_code(204);
|
|
exit;
|
|
}
|
|
|
|
// ── Funciones de respuesta ───────────────────────────────────
|
|
|
|
function jsonOk(array $payload = [], string $mensaje = ''): void
|
|
{
|
|
ob_clean();
|
|
$resp = ['ok' => true, 'success' => true];
|
|
if ($mensaje) {
|
|
$resp['message'] = $mensaje;
|
|
}
|
|
echo json_encode(array_merge($resp, $payload), JSON_UNESCAPED_UNICODE);
|
|
exit;
|
|
}
|
|
|
|
function jsonError(string $mensaje, int $code = 400): void
|
|
{
|
|
ob_clean();
|
|
http_response_code($code);
|
|
echo json_encode(['ok' => false, 'success' => false, 'error' => $mensaje], JSON_UNESCAPED_UNICODE);
|
|
exit;
|
|
}
|
|
|
|
function requireMethod(string $method): void
|
|
{
|
|
if ($_SERVER['REQUEST_METHOD'] !== strtoupper($method)) {
|
|
jsonError('Método no permitido', 405);
|
|
}
|
|
}
|
|
|
|
function inputJson(): array
|
|
{
|
|
static $parsed = null;
|
|
if ($parsed === null) {
|
|
$raw = file_get_contents('php://input');
|
|
$parsed = json_decode($raw, true) ?? [];
|
|
}
|
|
return $parsed;
|
|
}
|
|
|
|
// ── Autenticación (kiosko y display son públicos) ────────────
|
|
|
|
/**
|
|
* Exige sesión activa. Para endpoints de login requerido.
|
|
*/
|
|
function requireAuth(): void
|
|
{
|
|
if (empty($_SESSION['admin_user']['id'])) {
|
|
jsonError('No autenticado', 401);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Exige sesión + módulo turnero.
|
|
*/
|
|
function requireTurnero(): void
|
|
{
|
|
requireAuth();
|
|
$modules = $_SESSION['admin_user']['modules'] ?? [];
|
|
$roleId = $_SESSION['admin_user']['role_id'] ?? null;
|
|
|
|
// Superadmin / admin sin role_id = acceso total
|
|
if (empty($roleId)) {
|
|
return;
|
|
}
|
|
|
|
if (!in_array('turnero', (array) $modules, true)) {
|
|
jsonError('Sin acceso al módulo Turnero', 403);
|
|
}
|
|
}
|
|
|
|
function adminId(): ?int
|
|
{
|
|
return isset($_SESSION['admin_user']['id'])
|
|
? (int) $_SESSION['admin_user']['id']
|
|
: null;
|
|
}
|
|
|
|
// ── Helper BD ────────────────────────────────────────────────
|
|
|
|
function db(): PDO
|
|
{
|
|
return Database::getInstance()->getConnection();
|
|
}
|
|
|
|
// ── Motor de cola ────────────────────────────────────────────
|
|
|
|
/**
|
|
* Obtiene o crea la sesión del día actual.
|
|
* Devuelve el id de la sesión.
|
|
*/
|
|
function obtenerOCrearSesionHoy(): int
|
|
{
|
|
$pdo = db();
|
|
$hoy = date('Y-m-d');
|
|
|
|
$stmt = $pdo->prepare('SELECT id FROM turnero_sesiones WHERE fecha = ? LIMIT 1');
|
|
$stmt->execute([$hoy]);
|
|
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
|
|
|
if ($row) {
|
|
return (int) $row['id'];
|
|
}
|
|
|
|
// Crear sesión del día
|
|
$stmt = $pdo->prepare(
|
|
'INSERT INTO turnero_sesiones (fecha, abierto_por, inicio_at) VALUES (?, ?, NOW())'
|
|
);
|
|
$stmt->execute([$hoy, adminId()]);
|
|
return (int) $pdo->lastInsertId();
|
|
}
|
|
|
|
/**
|
|
* Devuelve ['id', 'fin_at'] de la sesión de hoy si existe, o null.
|
|
* Para uso en pantallas: NO crea sesión nueva.
|
|
*/
|
|
function sesionHoy(): ?array
|
|
{
|
|
$pdo = db();
|
|
$stmt = $pdo->prepare('SELECT id, fin_at FROM turnero_sesiones WHERE fecha = CURDATE() LIMIT 1');
|
|
$stmt->execute();
|
|
return $stmt->fetch(PDO::FETCH_ASSOC) ?: null;
|
|
}
|
|
|
|
/**
|
|
* Genera el siguiente número correlativo de turno para una sesión.
|
|
* Usa bloqueo a nivel de fila para evitar duplicados en concurrencia.
|
|
*/
|
|
function siguienteNumero(int $sesionId): int
|
|
{
|
|
$pdo = db();
|
|
$stmt = $pdo->prepare(
|
|
'SELECT COALESCE(MAX(numero), 0) + 1 AS siguiente
|
|
FROM turnero_turnos
|
|
WHERE sesion_id = ?
|
|
FOR UPDATE'
|
|
);
|
|
$stmt->execute([$sesionId]);
|
|
return (int) $stmt->fetchColumn();
|
|
}
|
|
|
|
/**
|
|
* Genera el siguiente número correlativo de turno POR PRIORIDAD para una sesión.
|
|
* Cada prioridad (A, B, C, D, E, F) tiene su propia secuencia independiente.
|
|
* Usa bloqueo a nivel de fila para evitar duplicados en concurrencia.
|
|
*/
|
|
function siguienteNumeroPorPrioridad(int $sesionId, int $prioridadId): int
|
|
{
|
|
$pdo = db();
|
|
$stmt = $pdo->prepare(
|
|
'SELECT COALESCE(MAX(numero), 0) + 1 AS siguiente
|
|
FROM turnero_turnos
|
|
WHERE sesion_id = ?
|
|
AND prioridad_id = ?
|
|
FOR UPDATE'
|
|
);
|
|
$stmt->execute([$sesionId, $prioridadId]);
|
|
return (int) $stmt->fetchColumn();
|
|
}
|
|
|
|
/**
|
|
* Selecciona el siguiente turno a llamar según el motor de prioridades.
|
|
*
|
|
* @param string $estado Estado que deben tener los turnos en cola
|
|
* @param int|null $lugarId Si no es null, filtra por lugar_destino_id (o su grupo)
|
|
* @param array|null $lugarIds Lista explícita de lugar_ids (grupo completo)
|
|
* @return array|null Fila de turnero_turnos o null si cola vacía
|
|
*/
|
|
function siguienteTurnoEnCola(string $estado, ?int $lugarId = null, ?array $lugarIds = null, ?int $sesionId = null): ?array
|
|
{
|
|
$pdo = db();
|
|
|
|
// Resolver grupo si se pasó un lugarId sin lista explícita
|
|
if ($lugarId !== null && $lugarIds === null) {
|
|
$lugarIds = lugarIdsDeGrupo($lugarId);
|
|
}
|
|
|
|
$sesionCond = $sesionId !== null ? 'AND t.sesion_id = ?' : '';
|
|
|
|
if (!empty($lugarIds)) {
|
|
$in = implode(',', array_map('intval', $lugarIds));
|
|
$sql = "
|
|
SELECT t.*, p.orden_peso, p.codigo AS prioridad_codigo, p.color AS prioridad_color
|
|
FROM turnero_turnos t
|
|
JOIN turnero_prioridades p ON p.id = t.prioridad_id
|
|
WHERE t.estado = ?
|
|
$sesionCond
|
|
AND t.lugar_destino_id IN ($in)
|
|
ORDER BY p.orden_peso ASC, t.creado_at ASC
|
|
LIMIT 1
|
|
FOR UPDATE
|
|
";
|
|
$binds = $sesionId !== null ? [$estado, $sesionId] : [$estado];
|
|
$stmt = $pdo->prepare($sql);
|
|
$stmt->execute($binds);
|
|
} else {
|
|
$sql = "
|
|
SELECT t.*, p.orden_peso, p.codigo AS prioridad_codigo, p.color AS prioridad_color
|
|
FROM turnero_turnos t
|
|
JOIN turnero_prioridades p ON p.id = t.prioridad_id
|
|
WHERE t.estado = ?
|
|
$sesionCond
|
|
ORDER BY p.orden_peso ASC, t.creado_at ASC
|
|
LIMIT 1
|
|
FOR UPDATE
|
|
";
|
|
$binds = $sesionId !== null ? [$estado, $sesionId] : [$estado];
|
|
$stmt = $pdo->prepare($sql);
|
|
$stmt->execute($binds);
|
|
}
|
|
|
|
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
|
return $row ?: null;
|
|
}
|
|
|
|
/**
|
|
* Devuelve todos los lugar_ids que comparten grupo con $lugarId.
|
|
* Si no tiene grupo, devuelve [$lugarId].
|
|
*/
|
|
function lugarIdsDeGrupo(int $lugarId): array
|
|
{
|
|
$pdo = db();
|
|
$stmt = $pdo->prepare("SELECT grupo_id FROM turnero_lugares WHERE id = ?");
|
|
$stmt->execute([$lugarId]);
|
|
$row = $stmt->fetch(PDO::FETCH_ASSOC);
|
|
|
|
if (!$row || $row['grupo_id'] === null) {
|
|
return [$lugarId];
|
|
}
|
|
|
|
$grupoId = (int)$row['grupo_id'];
|
|
$stmt2 = $pdo->prepare("SELECT id FROM turnero_lugares WHERE grupo_id = ? AND activo = 1");
|
|
$stmt2->execute([$grupoId]);
|
|
$ids = $stmt2->fetchAll(PDO::FETCH_COLUMN);
|
|
return $ids ?: [$lugarId];
|
|
}
|
|
|
|
/**
|
|
* Emite un evento SSE a todos los clientes suscritos escribiendo
|
|
* un flag en la tabla turnero_sesiones (campo sse_ping_at).
|
|
* Los clientes hacen polling de ese campo o escuchan el SSE directamente.
|
|
*
|
|
* Implementación ligera: actualiza timestamp en sesión para que
|
|
* sse_turno.php lo detecte y reenvíe el estado.
|
|
*/
|
|
function notificarSSE(int $sesionId): void
|
|
{
|
|
try {
|
|
db()->prepare(
|
|
'UPDATE turnero_sesiones SET sse_ping_at = NOW() WHERE id = ?'
|
|
)->execute([$sesionId]);
|
|
} catch (\Throwable $e) {
|
|
// No es crítico si falla; el SSE continuará con polling normal
|
|
}
|
|
}
|