Files
Lizandro GuarnizoandClaude Sonnet 4.6 f4da864db7 feat(turnero): toma prolongada secuencial + bloqueo muestras/cierre
- ver_formulario_enviado: auto-fill médico (nombres/especialidad/código) en datos_prefilled desde solicitud
- ver_formulario_enviado: canvases de toma progresiva bloqueados secuencialmente — solo el turno activo es firmable, los siguientes aparecen al expirar el countdown
- ver_formulario_enviado: countdown prominente con reloj grande, color dinámico, sonido (Web Audio) y vibración al llegar la hora
- lugar: bloquear cierre del modal durante toma progresiva activa (botón X deshabilitado hasta mp_completar)
- lugar: bloquear Finalizar atención si hay muestras pendientes sin decisión (recibida/rechazada)
- lugar: _syncBtnFinalizar combina consentimientos + muestras para el aviso y estado del botón
- lugar: restricción de usuario a lugar específico via turnero_lugar_id (admin_users) + bloqueo JS en confirmarLugar()
- migrations: turnero_lugar_id en admin_users para restringir bacteriólogos por usuario

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-07-08 21:16:07 -05:00

406 lines
16 KiB
PHP

<?php
/**
* Domicilio — Gestión de servicios a domicilio del laboratorio.
*
* Flujo de estados:
* programado → confirmado → en_camino → en_domicilio → completado
* └→ cancelado / reprogramado
*/
require_once __DIR__ . '/../../classes/Database.php';
require_once __DIR__ . '/ActividadAdmin.php';
class Domicilio {
const ESTADOS = [
'programado',
'confirmado',
'en_camino',
'en_domicilio',
'completado',
'cancelado',
'reprogramado',
];
private Database $db;
private ActividadAdmin $log;
public function __construct() {
$this->db = Database::getInstance();
$this->log = new ActividadAdmin();
}
// ─────────────────────────────────────────────────────────────────────────
// CRUD
// ─────────────────────────────────────────────────────────────────────────
/**
* Lista de domicilios con filtros.
*
* @param array $filtros fecha, estado, enfermera_id, paciente_id
*/
public function listar(array $filtros = [], int $pagina = 1, int $porPagina = 30): array {
$wheres = [];
$params = [];
if (!empty($filtros['fecha'])) {
$wheres[] = 'd.fecha_programada = ?';
$params[] = $filtros['fecha'];
}
if (!empty($filtros['desde'])) {
$wheres[] = 'd.fecha_programada >= ?';
$params[] = $filtros['desde'];
}
if (!empty($filtros['hasta'])) {
$wheres[] = 'd.fecha_programada <= ?';
$params[] = $filtros['hasta'];
}
if (!empty($filtros['estado'])) {
$wheres[] = 'd.estado = ?';
$params[] = $filtros['estado'];
}
if (!empty($filtros['enfermera_id'])) {
$wheres[] = 'a.enfermera_id = ?';
$params[] = $filtros['enfermera_id'];
}
if (!empty($filtros['paciente_id'])) {
$wheres[] = 'd.paciente_id = ?';
$params[] = $filtros['paciente_id'];
}
$where = $wheres ? 'WHERE ' . implode(' AND ', $wheres) : '';
$offset = ($pagina - 1) * $porPagina;
$total = $this->db->fetch(
"SELECT COUNT(*) AS n
FROM lab_domicilios d
LEFT JOIN lab_asignaciones a ON a.domicilio_id = d.id
$where",
$params
)['n'] ?? 0;
$rows = $this->db->fetchAll("
SELECT
d.*,
p.nombre_completo AS paciente_nombre,
p.telefono AS paciente_telefono,
e.nombre_completo AS enfermera_nombre,
e.telefono AS enfermera_telefono,
a.enfermera_id AS enfermera_lab_id,
a.id AS asignacion_id,
a.estado AS asignacion_estado,
CASE
WHEN d.hora_en_camino IS NULL OR d.hora_llegada IS NULL THEN NULL
ELSE TIMESTAMPDIFF(
MINUTE,
CONCAT(d.fecha_programada, ' ', d.hora_en_camino),
CONCAT(d.fecha_programada, ' ', d.hora_llegada)
)
END AS tiempo_recorrido_min
FROM lab_domicilios d
JOIN lab_pacientes p ON p.id = d.paciente_id
LEFT JOIN lab_asignaciones a ON a.domicilio_id = d.id
LEFT JOIN lab_enfermeras e ON e.id = a.enfermera_id
$where
ORDER BY d.fecha_programada ASC, d.hora_programada ASC
LIMIT ? OFFSET ?
", array_merge($params, [$porPagina, $offset]));
return [
'data' => $rows,
'total' => (int)$total,
'pagina' => $pagina,
'por_pagina' => $porPagina,
'paginas' => (int)ceil($total / $porPagina),
];
}
/**
* Domicilio por ID con todos los datos relacionados.
*/
public function obtener(int $id): ?array {
$dom = $this->db->fetch("
SELECT
d.*,
p.nombre_completo AS paciente_nombre,
p.telefono AS paciente_telefono,
p.email AS paciente_email,
p.fecha_nacimiento AS paciente_fecha_nacimiento,
p.tipo_documento AS paciente_tipo_documento,
p.numero_documento AS paciente_numero_documento,
p.direccion AS paciente_direccion,
o.examenes_solicitados,
o.indicaciones AS indicaciones_orden,
o.local_file AS orden_local_file,
e.nombre_completo AS enfermera_nombre,
e.telefono AS enfermera_telefono,
a.enfermera_id AS enfermera_lab_id,
a.id AS asignacion_id,
a.estado AS asignacion_estado,
adm.full_name AS creado_por_nombre,
CASE
WHEN d.hora_en_camino IS NULL OR d.hora_llegada IS NULL THEN NULL
ELSE TIMESTAMPDIFF(
MINUTE,
CONCAT(d.fecha_programada, ' ', d.hora_en_camino),
CONCAT(d.fecha_programada, ' ', d.hora_llegada)
)
END AS tiempo_recorrido_min
FROM lab_domicilios d
JOIN lab_pacientes p ON p.id = d.paciente_id
LEFT JOIN lab_ordenes_medicas o ON o.id = d.orden_id
LEFT JOIN lab_asignaciones a ON a.domicilio_id = d.id
LEFT JOIN lab_enfermeras e ON e.id = a.enfermera_id
LEFT JOIN admin_users adm ON adm.id = d.creado_por
WHERE d.id = ?
", [$id]);
if (!$dom) return null;
// Todos los archivos de órdenes vinculados a este domicilio
$archivos = $this->db->fetchAll(
"SELECT id AS orden_id, local_file
FROM lab_ordenes_medicas
WHERE domicilio_id = ? AND local_file IS NOT NULL
ORDER BY id ASC",
[$id]
);
// Fallback para domicilios creados antes de la migración (usan orden_id FK)
if (empty($archivos) && !empty($dom['orden_local_file'])) {
$archivos = [['orden_id' => $dom['orden_id'], 'local_file' => $dom['orden_local_file']]];
}
$dom['archivos_ordenes'] = $archivos;
// Servicios extras registrados por el enfermero
$dom['servicios_extra'] = $this->db->fetchAll(
"SELECT id, descripcion, tipo, notas, requiere_pago, valor, created_at
FROM lab_servicios_extra
WHERE domicilio_id = ?
ORDER BY created_at ASC",
[$id]
);
// Pagos parciales por método de pago
$dom['pagos_detalle'] = $this->db->fetchAll(
"SELECT modo, monto, notas FROM lab_domicilio_pagos WHERE domicilio_id = ? ORDER BY id ASC",
[$id]
);
// Vínculo: domicilio origen (este fue reprogramado desde otro)
if (!empty($dom['domicilio_origen_id'])) {
$dom['origen_info'] = $this->db->fetch(
"SELECT id, fecha_programada, hora_programada, estado FROM lab_domicilios WHERE id = ?",
[(int)$dom['domicilio_origen_id']]
);
}
// Vínculo: domicilio(s) reprogramado(s) desde este
$dom['reprogramaciones'] = $this->db->fetchAll(
"SELECT id, fecha_programada, hora_programada, estado FROM lab_domicilios WHERE domicilio_origen_id = ? ORDER BY id ASC",
[$id]
);
return $dom;
}
/**
* Crear nuevo domicilio.
*/
public function crear(array $datos, ?int $adminId = null): int {
$campos = $this->filtrarCampos($datos);
$campos['creado_por'] = $adminId;
// Actualizar orden médica si aplica
if (!empty($campos['orden_id'])) {
$this->db->update(
'lab_ordenes_medicas',
['estado' => 'en_domicilio'],
'id = ?', [$campos['orden_id']]
);
}
$id = (int)$this->db->insert('lab_domicilios', $campos);
$this->log->registrar($adminId, 'domicilios', 'crear', $id, $campos);
return $id;
}
/**
* Actualizar domicilio.
*/
public function actualizar(int $id, array $datos, ?int $adminId = null): bool {
$campos = $this->filtrarCampos($datos);
$ok = $this->db->update('lab_domicilios', $campos, 'id = ?', [$id]);
$this->log->registrar($adminId, 'domicilios', 'editar', $id, $campos);
return $ok > 0;
}
/**
* Cambiar estado del domicilio.
*/
public function cambiarEstado(
int $id,
string $nuevoEstado,
?int $adminId,
array $extras = []
): bool {
if (!in_array($nuevoEstado, self::ESTADOS)) {
throw new InvalidArgumentException("Estado inválido: $nuevoEstado");
}
$campos = array_merge(['estado' => $nuevoEstado], $extras);
$ok = $this->db->update('lab_domicilios', $campos, 'id = ?', [$id]);
$this->log->registrar($adminId, 'domicilios', $nuevoEstado, $id, $extras);
return $ok > 0;
}
/**
* Reprogramar un domicilio:
* 1. Marca el original como 'reprogramado' con fecha_reprogramada.
* 2. Clona el domicilio para la nueva fecha/hora.
* 3. Vincula el clon con domicilio_origen_id.
*
* @return int ID del nuevo domicilio creado.
*/
public function reprogramar(
int $idOrigen,
string $nuevaFecha,
string $nuevaHora = '',
string $motivo = '',
?int $adminId = null
): int {
// 1. Obtener datos del original
$origen = $this->db->fetch(
'SELECT * FROM lab_domicilios WHERE id = ?', [$idOrigen]
);
if (!$origen) {
throw new \RuntimeException('Domicilio no encontrado');
}
// 2. Marcar original como reprogramado
$extrasOrigen = [
'fecha_reprogramada' => $nuevaFecha,
];
if ($motivo) $extrasOrigen['motivo_cancelacion'] = $motivo;
$this->cambiarEstado($idOrigen, 'reprogramado', $adminId, $extrasOrigen);
// 3. Clonar campos relevantes para el nuevo domicilio
$campos = $this->filtrarCampos($origen);
// Resetear campos operativos del original
unset(
$campos['estado'], $campos['motivo_cancelacion'],
$campos['fecha_reprogramada'], $campos['hora_llegada'],
$campos['hora_salida'], $campos['observaciones'],
$campos['muestras_tomadas'], $campos['pago_estado'],
$campos['pago_modo'], $campos['pago_monto'],
$campos['pago_fecha'], $campos['pago_notas'],
$campos['pago_registrado_por'], $campos['temperatura_inicio'],
$campos['temperatura_salida'], $campos['domicilio_origen_id']
);
$campos['fecha_programada'] = $nuevaFecha;
$campos['hora_programada'] = $nuevaHora ?: null;
$campos['estado'] = 'programado';
$campos['domicilio_origen_id']= $idOrigen;
$campos['creado_por'] = $adminId;
$nuevoId = (int)$this->db->insert('lab_domicilios', $campos);
$this->log->registrar($adminId, 'domicilios', 'reprogramar', $nuevoId, [
'origen_id' => $idOrigen,
'nueva_fecha' => $nuevaFecha,
]);
return $nuevoId;
}
// ─────────────────────────────────────────────────────────────────────────
// Agenda del día
// ─────────────────────────────────────────────────────────────────────────
/**
* Todos los domicilios de hoy (o la fecha indicada).
*/
public function agendaDia(string $fecha = ''): array {
$fecha = $fecha ?: date('Y-m-d');
return $this->listar(['fecha' => $fecha], 1, 200)['data'];
}
/**
* Domicilios sin enfermera asignada.
*/
public function sinAsignar(string $fecha = ''): array {
$fecha = $fecha ?: date('Y-m-d');
return $this->db->fetchAll("
SELECT
d.*,
p.nombre_completo AS paciente_nombre
FROM lab_domicilios d
JOIN lab_pacientes p ON p.id = d.paciente_id
LEFT JOIN lab_asignaciones a ON a.domicilio_id = d.id
WHERE d.fecha_programada = ?
AND a.id IS NULL
AND d.estado NOT IN ('cancelado','completado')
ORDER BY d.hora_programada ASC
", [$fecha]);
}
/**
* Estadísticas: conteo de domicilios por estado hoy.
*/
public function estadisticasHoy(): array {
$rows = $this->db->fetchAll("
SELECT estado, COUNT(*) AS total
FROM lab_domicilios
WHERE fecha_programada = CURDATE()
GROUP BY estado
");
$result = array_fill_keys(self::ESTADOS, 0);
foreach ($rows as $row) {
$result[$row['estado']] = (int)$row['total'];
}
return $result;
}
// ─────────────────────────────────────────────────────────────────────────
// Helpers
// ─────────────────────────────────────────────────────────────────────────
private function filtrarCampos(array $datos): array {
$permitidos = [
'orden_id', 'paciente_id', 'numero_orden', 'direccion', 'ciudad', 'barrio',
'indicaciones_dir', 'fecha_programada', 'hora_programada',
'tipo_servicio', 'tipo_cliente', 'examenes_solicitados',
'estado', 'motivo_cancelacion',
'fecha_reprogramada', 'hora_llegada', 'hora_salida',
'observaciones', 'muestras_tomadas', 'notas_admin',
'seguro_nombre', 'autorizacion', 'copago_laboratorio',
'valor_domicilio', 'valor_copago',
'domicilio_origen_id',
// pago
'pago_estado', 'pago_modo', 'pago_monto', 'pago_fecha',
'pago_notas', 'pago_registrado_por',
];
$campos = array_intersect_key($datos, array_flip($permitidos));
// Campos FK que deben ser NULL cuando vienen vacíos o cero
foreach (['orden_id', 'paciente_id'] as $fk) {
if (array_key_exists($fk, $campos) && ($campos[$fk] === '' || $campos[$fk] === '0' || $campos[$fk] === 0)) {
$campos[$fk] = null;
}
}
// Normalizar campos de fecha/hora vacíos a NULL para evitar error SQL (MySQL strict)
foreach (['fecha_programada', 'fecha_reprogramada', 'hora_programada', 'hora_llegada', 'hora_salida', 'pago_fecha'] as $dt) {
if (array_key_exists($dt, $campos) && trim((string)$campos[$dt]) === '') {
$campos[$dt] = null;
}
}
// Normalizar campos numéricos opcionales vacíos a NULL para evitar "Incorrect decimal value: ''"
foreach (['valor_domicilio', 'valor_copago', 'copago_laboratorio', 'pago_monto'] as $num) {
if (array_key_exists($num, $campos) && trim((string)$campos[$num]) === '') {
$campos[$num] = null;
}
}
return $campos;
}
}