leftJoin('InternetCosas_Dispositivos as d', 'r.id_recurso', '=', 'd.recurso_id') ->where('r.id_recurso', $id) ->where('r.tipo', 'MESA') ->select( 'r.*', 'd.mac as mac_address', 'd.ultima_accion as iot_action', 'r.mesero_asignado_id', DB::raw("(SELECT nombre_completo FROM dbo.Seguridad_Usuarios WHERE id_usuario = r.mesero_asignado_id) as mesero_nombre_actual"), DB::raw("(SELECT TOP 1 u.nombre_completo FROM Ventas_Ordenes o_sub JOIN dbo.Seguridad_Usuarios u ON o_sub.usuario_id = u.id_usuario WHERE o_sub.recurso_id = r.id_recurso AND o_sub.estado IN ('PENDIENTE', 'EN_PREPARACION', 'LISTO', 'ENTREGADO') ORDER BY o_sub.creado_at DESC) as mesero_nombre_pedido"), DB::raw("(SELECT TOP 1 c.comentario FROM InternetCosas_Reclamos rec JOIN InternetCosas_Reclamos_Comentarios c ON rec.id_reclamo = c.reclamo_id WHERE rec.recurso_id = r.id_recurso AND rec.estado = 'PENDIENTE' ORDER BY rec.fecha_creacion DESC) as iot_payload") ) ->first(); if (!$mesa) { return null; } // [FIX] Si la mesa es parte de una unión, obtener nombre combinado $nombreFinal = $mesa->nombre; if (!empty($mesa->grupo_union_id)) { $mesasUnidas = DB::table('Nucleo_Recursos') ->where('grupo_union_id', $mesa->grupo_union_id) ->where('id_recurso', '!=', $mesa->id_recurso) // Excluir actual ->where('tipo', 'MESA') ->where('activo', 1) ->orderBy('nombre') ->pluck('nombre') ->toArray(); if (!empty($mesasUnidas)) { // E.g., "Mesa 04 + Mesa 03 + Mesa 05" // Or simplified: "Mesa 04 + 03 + 05" if naming follows pattern // For safety, just join full names or concise $nombreFinal .= ' + ' . implode(' + ', array_map(function ($n) { return str_replace('Mesa ', '', $n); // Try to shorten "Mesa 03" to "03" }, $mesasUnidas)); } } return new Mesa( id: $mesa->id_recurso, sucursalId: $mesa->sucursal_id, nombre: $nombreFinal, // Name overridden with combined info tipo: 'MESA', estado: EstadoMesa::fromString($mesa->estado ?? 'LIBRE'), meseroAsignadoId: $mesa->mesero_asignado_id ?? null, capacidad: $mesa->capacidad ?? 4, ubicacion: $mesa->ubicacion ?? 'General', qrCode: $mesa->qr_code, activo: (bool) $mesa->activo, descripcion: $mesa->descripcion, esVip: (bool) ($mesa->es_vip ?? false), permiteUnion: (bool) ($mesa->permite_union ?? true), grupoUnionId: $mesa->grupo_union_id, macAddress: $mesa->mac_address ?? null, iotAction: $mesa->iot_action ?? null, meseroNombre: ($mesa->estado ?? 'LIBRE') === 'LIBRE' ? null : ($mesa->mesero_nombre_actual ?? $mesa->mesero_nombre_pedido ?? null), iotPayload: $mesa->iot_payload ?? null, pagoAdelantado: (bool) ($mesa->pago_adelantado ?? false), pagoAdelantadoExpira: $mesa->pago_adelantado_expira ? \Carbon\Carbon::parse((string)$mesa->pago_adelantado_expira . ' UTC')->format('Y-m-d\TH:i:s.v\Z') : null ); } public function obtenerPorSucursal(string $sucursalId): array { $mesas = DB::table('Nucleo_Recursos as r') ->leftJoin('InternetCosas_Dispositivos as d', 'r.id_recurso', '=', 'd.recurso_id') ->where('r.sucursal_id', $sucursalId) ->where('r.tipo', 'MESA') ->where('r.activo', 1) ->select( 'r.*', 'd.mac as mac_address', 'd.ultima_accion as iot_action', 'r.mesero_asignado_id', DB::raw("(SELECT nombre_completo FROM dbo.Seguridad_Usuarios WHERE id_usuario = r.mesero_asignado_id) as mesero_nombre_actual"), DB::raw("(SELECT TOP 1 u.nombre_completo FROM Ventas_Ordenes o_sub JOIN dbo.Seguridad_Usuarios u ON o_sub.usuario_id = u.id_usuario WHERE o_sub.recurso_id = r.id_recurso AND o_sub.estado IN ('PENDIENTE', 'EN_PREPARACION', 'LISTO', 'ENTREGADO') ORDER BY o_sub.creado_at DESC) as mesero_nombre_pedido"), DB::raw("(SELECT TOP 1 c.comentario FROM InternetCosas_Reclamos rec JOIN InternetCosas_Reclamos_Comentarios c ON rec.id_reclamo = c.reclamo_id WHERE rec.recurso_id = r.id_recurso AND rec.estado = 'PENDIENTE' ORDER BY rec.fecha_creacion DESC) as iot_payload") ) ->orderBy('r.nombre', 'asc') ->get(); return $mesas->map(function ($mesa) { return new Mesa( id: $mesa->id_recurso, sucursalId: $mesa->sucursal_id, nombre: $mesa->nombre, tipo: 'MESA', estado: EstadoMesa::fromString($mesa->estado ?? 'LIBRE'), meseroAsignadoId: $mesa->mesero_asignado_id ?? null, capacidad: $mesa->capacidad ?? 4, ubicacion: $mesa->ubicacion ?? 'General', qrCode: $mesa->qr_code, activo: (bool) $mesa->activo, descripcion: $mesa->descripcion, esVip: (bool) ($mesa->es_vip ?? false), permiteUnion: (bool) ($mesa->permite_union ?? true), grupoUnionId: $mesa->grupo_union_id, macAddress: $mesa->mac_address ?? null, iotAction: $mesa->iot_action ?? null, meseroNombre: ($mesa->estado ?? 'LIBRE') === 'LIBRE' ? null : ($mesa->mesero_nombre_actual ?? $mesa->mesero_nombre_pedido ?? null), iotPayload: $mesa->iot_payload ?? null, pagoAdelantado: (bool) ($mesa->pago_adelantado ?? false), pagoAdelantadoExpira: $mesa->pago_adelantado_expira ? \Carbon\Carbon::parse((string)$mesa->pago_adelantado_expira . ' UTC')->format('Y-m-d\TH:i:s.v\Z') : null ); })->values()->all(); } public function guardar(Mesa $mesa): void { DB::table('Nucleo_Recursos')->updateOrInsert( ['id_recurso' => $mesa->id], [ 'sucursal_id' => $mesa->sucursalId, 'nombre' => $mesa->nombre, 'tipo' => 'MESA', 'estado' => $mesa->estado->valor(), 'capacidad' => $mesa->capacidad, 'ubicacion' => $mesa->ubicacion, 'qr_code' => $mesa->qrCode, 'activo' => $mesa->activo ? 1 : 0, 'descripcion' => $mesa->descripcion, 'es_vip' => $mesa->esVip ? 1 : 0, 'permite_union' => $mesa->permiteUnion ? 1 : 0, 'grupo_union_id' => $mesa->grupoUnionId, 'mesero_asignado_id' => $mesa->meseroAsignadoId, 'pago_adelantado' => $mesa->pagoAdelantado ? 1 : 0, 'pago_adelantado_expira' => $mesa->pagoAdelantadoExpira ] ); // [IOT PERSISTENCE] Sync fields with IoT tables if present if ($mesa->iotAction === null) { // Clear last action for device linked to this resource DB::table('InternetCosas_Dispositivos') ->where('recurso_id', $mesa->id) ->update(['ultima_accion' => null]); // Mark pending claims as ATENDIDO DB::table('InternetCosas_Reclamos') ->where('recurso_id', $mesa->id) ->where('estado', 'PENDIENTE') ->update(['estado' => 'ATENDIDO', 'fecha_cierre' => now()]); } else { // If we have an action, we might want to update device too (optional) DB::table('InternetCosas_Dispositivos') ->where('recurso_id', $mesa->id) ->update(['ultima_accion' => $mesa->iotAction]); } // Broadcast change Log::info("📡 [MesaRepository] guardar: id={$mesa->id}, sucursal={$mesa->sucursalId}, activo=" . ($mesa->activo ? '1' : '0')); Log::info("📡 Broadcasting mesa:update for sucursal: {$mesa->sucursalId}"); $payload = [ 'mesaId' => $mesa->id, 'estado' => $mesa->estado->valor(), 'iot_action' => $mesa->iotAction, 'iot_payload' => $mesa->iotPayload ]; if ($mesa->estado->valor() === 'LIBRE') { $payload['mesero_asignado_id'] = null; $payload['mesero_nombre'] = null; $payload['pago_adelantado'] = false; $payload['pago_adelantado_expira'] = null; } $this->websocketService->emit("branch_{$mesa->sucursalId}", [ 'event' => 'mesa:update', 'payload' => array_merge($payload, [ 'path' => "Operations/Resources/{$mesa->id}", 'timestamp' => now('UTC')->toIso8601String() ]) ]); } public function eliminar(string $id): void { $mesa = DB::table('Nucleo_Recursos')->where('id_recurso', $id)->first(); if (!$mesa) return; DB::table('Nucleo_Recursos') ->where('id_recurso', $id) ->update(['activo' => 0]); // Broadcast change $sucursalId = $mesa->sucursal_id ?? ''; Log::info("📡 [MesaRepository] eliminar: id=$id, sucursal=$sucursalId"); $this->websocketService->emit("branch_$sucursalId", [ 'event' => 'mesa:update', 'payload' => [ 'mesaId' => $id, 'action' => 'delete' ] ]); } // ... métodos auxiliares mantenidos o simplificados si no cambian lógica ... public function obtenerDisponiblesPorSucursal(string $sucursalId): array { $mesas = $this->obtenerPorSucursal($sucursalId); return array_filter($mesas, fn($m) => $m->estaDisponible()); } public function obtenerOcupadasPorSucursal(string $sucursalId): array { $mesas = $this->obtenerPorSucursal($sucursalId); return array_filter($mesas, fn($m) => $m->estaOcupada()); } public function actualizarEstado(string $id, string $estado): void { Log::info("📡 [MesaRepository] Petición cambiar estado: ID={$id} -> {$estado}"); $mesa = DB::table('Nucleo_Recursos')->where('id_recurso', $id)->first(); if (!$mesa) { Log::warning("⚠️ [MesaRepository] Mesa no encontrada para actualizar estado: {$id}"); return; } $updateData = ['estado' => $estado]; if ($estado === 'LIBRE') { $updateData['mesero_asignado_id'] = null; $updateData['pago_adelantado'] = 0; $updateData['pago_adelantado_expira'] = null; } $affected = DB::table('Nucleo_Recursos') ->where('id_recurso', $id) ->update($updateData); Log::info("✅ [MesaRepository] Estado actualizado en DB. Filas afectadas: {$affected}"); // Broadcast change Log::info("📡 Broadcasting mesa:update (estado) for sucursal: {$mesa->sucursal_id}"); $payload = [ 'mesaId' => $id, 'estado' => $estado, 'iot_action' => null, 'iot_payload' => null ]; if ($estado === 'LIBRE') { $payload['mesero_asignado_id'] = null; $payload['mesero_nombre'] = null; $payload['pago_adelantado'] = false; $payload['pago_adelantado_expira'] = null; } $this->websocketService->emit("branch_{$mesa->sucursal_id}", [ 'event' => 'mesa:update', 'payload' => array_merge($payload, [ 'path' => $estado === 'LIBRE' ? "Client/Home/Mesa/{$id}/Desvincular" : "Operations/Resources/{$id}/Estado", 'timestamp' => now('UTC')->toIso8601String() ]) ]); } public function asignarMesero(string $mesaId, string $meseroId): void { DB::table('Nucleo_Recursos') ->where('id_recurso', $mesaId) ->update(['mesero_asignado_id' => $meseroId]); // Broadcast change $mesa = DB::table('Nucleo_Recursos')->where('id_recurso', $mesaId)->first(); if ($mesa) { $meseroNombre = DB::table('Seguridad_Usuarios') ->where('id_usuario', $meseroId) ->value('nombre_completo'); $this->websocketService->emit("branch_{$mesa->sucursal_id}", [ 'event' => 'mesa:update', 'payload' => [ 'mesaId' => $mesaId, 'mesero_asignado_id' => $meseroId, 'mesero_nombre' => $meseroNombre ] ]); } } public function liberarMesero(string $mesaId): void { DB::table('Nucleo_Recursos') ->where('id_recurso', $mesaId) ->update(['mesero_asignado_id' => null]); // Broadcast change $mesa = DB::table('Nucleo_Recursos')->where('id_recurso', $mesaId)->first(); if ($mesa) { $this->websocketService->emit("branch_{$mesa->sucursal_id}", [ 'event' => 'mesa:update', 'payload' => [ 'mesaId' => $mesaId, 'mesero_asignado_id' => null, 'mesero_nombre' => null ] ]); } } public function obtenerInactivosPorSucursal(string $sucursalId): array { $mesas = DB::table('Nucleo_Recursos as r') ->leftJoin('InternetCosas_Dispositivos as d', 'r.id_recurso', '=', 'd.recurso_id') ->where('r.sucursal_id', $sucursalId) ->where('r.tipo', 'MESA') ->where('r.activo', 0) // Solo inactivos ->select('r.*', 'd.mac as mac_address') ->get(); return $mesas->map(function ($mesa) { return new Mesa( id: $mesa->id_recurso, sucursalId: $mesa->sucursal_id, nombre: $mesa->nombre, tipo: 'MESA', estado: EstadoMesa::fromString($mesa->estado ?? 'LIBRE'), meseroAsignadoId: null, capacidad: $mesa->capacidad ?? 4, ubicacion: $mesa->ubicacion ?? 'General', qrCode: $mesa->qr_code, activo: (bool) $mesa->activo, descripcion: $mesa->descripcion, esVip: (bool) ($mesa->es_vip ?? false), permiteUnion: (bool) ($mesa->permite_union ?? true), grupoUnionId: $mesa->grupo_union_id ); })->values()->all(); } public function restaurar(string $id): void { $mesa = DB::table('Nucleo_Recursos')->where('id_recurso', $id)->first(); if (!$mesa) return; DB::table('Nucleo_Recursos') ->where('id_recurso', $id) ->update(['activo' => 1]); // Broadcast change Log::info("📡 Broadcasting mesa:restore for sucursal: {$mesa->sucursal_id}"); $this->websocketService->emit("branch_{$mesa->sucursal_id}", [ 'event' => 'mesa:update', 'payload' => [ 'mesaId' => $id, 'action' => 'restore' ] ]); } public function activarPagoAdelantado(string $mesaId, int $horas = 3): void { // [TEST] 30 segundos para pruebas recursivas $expira = now('UTC')->addHours(3); $expiraString = $expira->format('Y-m-d H:i:s'); Log::info("🚨 CHISMOSO: activando PagoAdelantado mesa {$mesaId}. Expira en: {$expiraString} (UTC)"); DB::table('Nucleo_Recursos') ->where('id_recurso', $mesaId) ->update([ 'pago_adelantado' => true, 'pago_adelantado_expira' => $expira, ]); // Disparar solo el PRIMER job de aviso después de 1 hora // El job se encargará de re-programarse recursivamente hasta la liberación Log::info("🚨 CHISMOSO: dispatching AvisarMesaOcupadaJob INICIAL para mesa {$mesaId} delay 1h. Expira: {$expiraString}"); \App\Jobs\AvisarMesaOcupadaJob::dispatch($mesaId, $expiraString)->delay(now('UTC')->addHours(1)); $mesa = DB::table('Nucleo_Recursos')->where('id_recurso', $mesaId)->first(); if ($mesa) { Log::info("💳 [PagoAdelantado] Activado para mesa {$mesa->nombre}, expira: {$expira}"); $this->websocketService->emit("branch_{$mesa->sucursal_id}", [ 'event' => 'mesa:update', 'payload' => [ 'mesaId' => $mesaId, 'pago_adelantado' => true, 'pago_adelantado_expira' => $expira->format('Y-m-d\TH:i:s.v\Z') ] ]); } } public function desactivarPagoAdelantado(string $mesaId): void { DB::table('Nucleo_Recursos') ->where('id_recurso', $mesaId) ->update([ 'pago_adelantado' => 0, 'pago_adelantado_expira' => null ]); $mesa = DB::table('Nucleo_Recursos')->where('id_recurso', $mesaId)->first(); if ($mesa) { $this->websocketService->emit("branch_{$mesa->sucursal_id}", [ 'event' => 'mesa:update', 'payload' => [ 'mesaId' => $mesaId, 'pago_adelantado' => false, 'pago_adelantado_expira' => null ] ]); } } public function resetTimerPagoAdelantado(string $mesaId, int $horas = 3): void { $mesa = DB::table('Nucleo_Recursos')->where('id_recurso', $mesaId)->first(); if ($mesa && $mesa->pago_adelantado) { $expira = now('UTC')->addHours(3); $expiraString = $expira->format('Y-m-d H:i:s'); DB::table('Nucleo_Recursos') ->where('id_recurso', $mesaId) ->update(['pago_adelantado_expira' => $expira]); // Emitir actualización vía WebSocket con el formato correcto $this->websocketService->emit("branch_{$mesa->sucursal_id}", [ 'event' => 'mesa:update', 'payload' => [ 'mesaId' => $mesaId, 'pago_adelantado' => true, 'pago_adelantado_expira' => $expira->format('Y-m-d\TH:i:s.v\Z') ] ]); // Disparar solo el PRIMER job de aviso después de 1 hora // El job se encargará de re-programarse recursivamente hasta la liberación Log::info("🚨 CHISMOSO: re-dispatching AvisarMesaOcupadaJob INICIAL mesa {$mesaId} delay 1h. Expira: {$expiraString}"); \App\Jobs\AvisarMesaOcupadaJob::dispatch($mesaId, $expiraString)->delay(now('UTC')->addHours(1)); Log::info("🔄 [PagoAdelantado] Timer reiniciado para mesa {$mesa->nombre}"); } } public function liberarMesasExpiradas(): array { $mesasExpiradas = DB::table('Nucleo_Recursos') ->where('pago_adelantado', 1) ->where('pago_adelantado_expira', '<=', now('UTC')) ->get(); $liberadas = []; foreach ($mesasExpiradas as $mesa) { DB::table('Nucleo_Recursos') ->where('id_recurso', $mesa->id_recurso) ->update([ 'estado' => 'LIBRE', 'mesero_asignado_id' => null, 'pago_adelantado' => 0, 'pago_adelantado_expira' => null ]); $this->websocketService->emit("branch_{$mesa->sucursal_id}", [ 'event' => 'mesa:update', 'payload' => [ 'path' => "Client/Home/Mesa/{$mesa->id_recurso}/Desvincular", 'mesaId' => $mesa->id_recurso, 'estado' => 'LIBRE', 'pago_adelantado' => false, 'mesero_asignado_id' => null, 'mesero_nombre' => null, 'timestamp' => now('UTC')->toIso8601String() ] ]); $liberadas[] = $mesa->nombre; Log::info("⏰ [PagoAdelantado] Mesa {$mesa->nombre} liberada por expiración de timer"); // Limpiar pedidos activos try { app(\App\Modules\Orders\Domain\Repositories\PedidoRepositoryInterface::class)->limpiarPedidosAlLiberarMesa($mesa->id_recurso); } catch (\Exception $e) { Log::error("❌ Error llamando a limpiarPedidosAlLiberarMesa para mesa {$mesa->id_recurso}: " . $e->getMessage()); } } return $liberadas; } }