File manager - Edit - /var/www/html/portal/app/Http/Resources/Mobiangra/MobiangraResource.php
Back
<?php namespace App\Http\Resources\Mobiangra; use App\Http\Controllers\Controller; use App\Services\Mobiangra\MobiangraService; use Carbon\Carbon; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; use Illuminate\Support\Facades\Cache; /** * Regras de negócio e orquestração do módulo MobiAngra. * * Responsável pelo mapeamento de IDs internos -> GTFS, * cálculo de serviços ativos e construção dos payloads do feed. */ class MobiangraResource extends Controller { public function __construct(private readonly MobiangraService $service) { } private const FEED_UPDATE_LOCK_KEY = 'mobiangra:feed-update'; /** * Atualiza o feed somente se os dados estiverem desatualizados (lazy). * * Usado por /live_data e /mobiangra/gtfs/feed.pb como fallback quando o cron * não estiver ativo ou falhar. Ignora silenciosamente se outra atualização * estiver em andamento. */ public function refreshIfStale(): void { if (! $this->isFeedStale()) { return; } $lock = Cache::lock(self::FEED_UPDATE_LOCK_KEY, 60); if (! $lock->get()) { return; } try { if (! $this->isFeedStale()) { return; } $this->executeFeedUpdate(); } finally { $lock->release(); } } /** * Executa o fluxo completo: busca API -> processa -> salva .pb e JSON. * * @return array Resultado da execução com contagem de veículos. */ public function atualizarFeed(): array { $lock = Cache::lock(self::FEED_UPDATE_LOCK_KEY, 60); if (! $lock->get()) { return [ 'success' => true, 'count' => 0, 'message' => 'Atualização em andamento', ]; } try { return $this->executeFeedUpdate(); } finally { $lock->release(); } } /** * @return array Resultado da execução com contagem de veículos. */ private function executeFeedUpdate(): array { $rawVehicles = $this->service->fetchVehiclePositions(); if ($rawVehicles === null) { $this->service->saveFeedStatus(0, false); return ['success' => false, 'count' => 0, 'message' => 'Falha ao consultar API']; } $feedEntities = $this->buildFeedEntities($rawVehicles); $livePayload = $this->buildLiveDataPayload($rawVehicles); $pbSaved = $this->service->saveProtobufFeed($feedEntities); $jsonSaved = $this->service->saveLiveDataJson($livePayload); $count = count($feedEntities); $success = $pbSaved && $jsonSaved; $this->service->saveFeedStatus($count, $success); return [ 'success' => $success, 'count' => $count, 'message' => $success ? "{$count} veículos processados" : 'Falha parcial ao salvar arquivos', ]; } private function isFeedStale(): bool { $interval = $this->service->getFetchInterval(); $status = $this->service->getFeedStatus(); $lastUpdate = $status['last_update_timestamp'] ?? 0; return (time() - $lastUpdate) >= $interval; } /** * Mapeia um veículo da API para route_id e direction_id do GTFS. * * Lógica equivalente a get_gtfs_route() do generate_feed.py. * * @param array $veiculo Dados brutos de um veículo da API. * @return array [route_id, direction_id] */ public function resolveGtfsRoute(array $veiculo): array { $linha = trim((string) ($veiculo['Linha'] ?? '')); $rota = trim((string) ($veiculo['Rota'] ?? '')); $mapping = $this->service->getInternalToGtfs(); $key = "{$linha}_{$rota}"; if (isset($mapping[$key])) { return $mapping[$key]; } if (isset($mapping[$rota])) { return $mapping[$rota]; } return [$linha, 0]; } /** * Retorna os service_ids ativos para o dia atual. * * Lógica equivalente ao bloco de GTFS estático do generate_feed.py: * (Regulares + Adicionados) - Removidos. */ public function getActiveServiceIds(): array { try { $now = Carbon::now('America/Sao_Paulo'); $dayOfWeek = strtolower($now->englishDayOfWeek); $todayInt = (int) $now->format('Ymd'); $calendar = $this->service->readGtfsCsv('calendar.txt'); $calendarDates = $this->service->readGtfsCsv('calendar_dates.txt'); $regular = collect($calendar) ->filter(function ($row) use ($dayOfWeek, $todayInt) { return ($row[$dayOfWeek] ?? '0') == '1' && (int) ($row['start_date'] ?? 0) <= $todayInt && (int) ($row['end_date'] ?? 0) >= $todayInt; }) ->pluck('service_id') ->toArray(); $exceptions = collect($calendarDates) ->filter(fn ($row) => (int) ($row['date'] ?? 0) === $todayInt); $added = $exceptions ->filter(fn ($row) => ($row['exception_type'] ?? '') == '1') ->pluck('service_id') ->toArray(); $removed = $exceptions ->filter(fn ($row) => ($row['exception_type'] ?? '') == '2') ->pluck('service_id') ->toArray(); return array_values(array_diff( array_unique(array_merge($regular, $added)), $removed )); } catch (\Throwable $e) { Log::warning('Mobiangra: Falha ao calcular service_ids ativos - ' . $e->getMessage()); return []; } } /** * Transforma veículos da API em entidades prontas para o encoder Protobuf. * * @param array $rawVehicles Dados brutos da API Systemsatx. * @return array Veículos processados para o GtfsRealtimeEncoder. */ public function buildFeedEntities(array $rawVehicles): array { $entities = []; foreach ($rawVehicles as $v) { [$routeId, $directionId] = $this->resolveGtfsRoute($v); if (empty($routeId) || empty($v['Latitude'])) { continue; } $speed = 0.0; if (! empty($v['Velocidade'])) { $speed = (float) $v['Velocidade'] / 3.6; } $entities[] = [ 'entity_id' => (string) $v['Veiculo'], 'latitude' => (float) $v['Latitude'], 'longitude' => (float) $v['Longitude'], 'speed' => $speed, 'timestamp' => $this->convertToUnix($v['DataHora'] ?? ''), 'vehicle_id' => (string) $v['Veiculo'], 'route_id' => (string) $routeId, 'direction_id' => (int) $directionId, ]; } return $entities; } /** * Transforma veículos da API no payload JSON consumido pelo mapa. * * Equivalente a get_live_vehicle_data() do map_server.py. * * @param array $rawVehicles Dados brutos da API Systemsatx. * @return array Estrutura { timestamp, vehicles: [...] }. */ public function buildLiveDataPayload(array $rawVehicles): array { $routeNames = $this->service->getRouteNames(); $acVehicles = $this->service->getAcVehicles(); $headsigns = $this->service->getDirectionHeadsigns(); $vehicles = []; foreach ($rawVehicles as $v) { [$routeId, $directionId] = $this->resolveGtfsRoute($v); if (empty($routeId) || empty($v['Latitude'])) { continue; } $speedKmh = 0.0; if (! empty($v['Velocidade'])) { $speedKmh = (float) $v['Velocidade']; } $vehicleId = (string) $v['Veiculo']; $vidNormalized = ltrim($vehicleId, '0') ?: '0'; $vidPadded = str_pad($vidNormalized, 3, '0', STR_PAD_LEFT); $hasAc = in_array($vehicleId, $acVehicles, true) || in_array($vidNormalized, $acVehicles, true) || in_array($vidPadded, $acVehicles, true); $dirStr = (string) $directionId; $sentido = $headsigns[$routeId][$dirStr] ?? ($directionId === 0 ? 'Ida' : 'Volta'); $vehicles[] = [ 'id' => $vehicleId, 'linha' => (string) $routeId, 'linha_nome' => $routeNames[$routeId] ?? (string) $routeId, 'lat' => (float) $v['Latitude'], 'lon' => (float) $v['Longitude'], 'direction' => $directionId === 0 ? 'Ida' : 'Volta', 'sentido' => $sentido, 'horario_programado' => trim((string) ($v['HorarioProgramado'] ?? $v['Horario'] ?? '')), 'ar_condicionado' => $hasAc, 'speed_kmh' => round($speedKmh, 1), 'timestamp' => $this->convertToUnix($v['DataHora'] ?? ''), ]; } return [ 'timestamp' => time(), 'vehicles' => $vehicles, ]; } /** * Busca linhas de ônibus com paradas próximas às coordenadas informadas. * * @return array{ * ok: bool, * error: string|null, * linhas: list<array{ * codigo: string, * nome: string, * parada: string, * distancia_m: int, * em_circulacao: bool, * horarios_url: string, * mapa_url: string * }> * } */ public function buscarLinhasProximas(float $lat, float $lng, int $radiusM, int $limit): array { if (! $this->service->coordenadaNaAreaCobertura($lat, $lng)) { return [ 'ok' => false, 'error' => 'fora_area', 'linhas' => [], ]; } $radiusM = min( max($radiusM, 100), (int) config('mobiangra.nearby.max_radius_m', 2000) ); $stops = $this->service->getStopsSpatialIndex(); $stopRoutes = $this->service->getStopRoutesMap(); $routeNames = $this->service->getRouteNames(); $publicadas = $this->service->getLinhasPublicadasCodigos(); $activeRouteIds = $this->routeIdsComVeiculosProximos($lat, $lng, $radiusM); $latDelta = $radiusM / 111_000.0; $lngDelta = $radiusM / (111_000.0 * max(cos(deg2rad($lat)), 0.01)); $lineBest = []; foreach ($stops as $stop) { if (abs($stop['lat'] - $lat) > $latDelta || abs($stop['lon'] - $lng) > $lngDelta) { continue; } $distanceM = (int) round(MobiangraService::distanciaMetros( $lat, $lng, $stop['lat'], $stop['lon'] )); if ($distanceM > $radiusM) { continue; } $routes = $stopRoutes[$stop['stop_id']] ?? []; foreach ($routes as $route) { $codigo = $route['codigo']; $codigoKey = mb_strtoupper($codigo, 'UTF-8'); if ($publicadas !== [] && ! isset($publicadas[$codigoKey])) { continue; } if (isset($lineBest[$codigoKey]) && $lineBest[$codigoKey]['distancia_m'] <= $distanceM) { continue; } $routeId = $route['route_id']; $nome = $routeNames[$routeId] ?? $codigo; $lineBest[$codigoKey] = [ 'codigo' => $codigo, 'nome' => $nome, 'parada' => $stop['name'], 'distancia_m' => $distanceM, 'em_circulacao' => isset($activeRouteIds[$routeId]), 'horarios_url' => route('mobiangra.horarios', ['linha' => $codigo]), 'mapa_url' => route('mobiangra.mapa', ['modo' => 'proximos']), ]; } } if ($lineBest === []) { return [ 'ok' => true, 'error' => null, 'linhas' => [], ]; } uasort($lineBest, static fn (array $a, array $b): int => $a['distancia_m'] <=> $b['distancia_m']); return [ 'ok' => true, 'error' => null, 'linhas' => array_values($lineBest), ]; } /** * Busca as paradas GTFS mais próximas de uma coordenada, cada uma já com o * ETA em tempo real das linhas que ainda vão atendê-la (reaproveita calcularEtaParada()). * * Usado pelo card "Parada mais próxima" do mapa: a primeira posição da lista * é a parada mais próxima do cidadão; as demais alimentam o botão "Próxima parada". * * @return array{ * ok: bool, * error: string|null, * paradas: list<array{ * stop_id: string, * nome: string, * lat: float, * lon: float, * distancia_m: int, * tempo_caminhada_min: int, * etas: list<array> * }> * } */ public function buscarParadasProximas(float $lat, float $lng, int $limit): array { if (! $this->service->coordenadaNaAreaCobertura($lat, $lng)) { return [ 'ok' => false, 'error' => 'fora_area', 'paradas' => [], ]; } $limit = min( max($limit, 1), (int) config('mobiangra.nearby.stops_max_limit', 8) ); // Caminhada urbana média (~4,3 km/h) para estimar o tempo a pé até a parada. $walkingSpeedMs = 1.2; $stops = $this->service->getStopsSpatialIndex(); $withDistance = []; foreach ($stops as $stop) { $withDistance[] = $stop + [ 'distancia_m' => (int) round(MobiangraService::distanciaMetros( $lat, $lng, $stop['lat'], $stop['lon'] )), ]; } usort($withDistance, static fn (array $a, array $b): int => $a['distancia_m'] <=> $b['distancia_m']); $paradas = []; foreach (array_slice($withDistance, 0, $limit) as $stop) { $eta = $this->calcularEtaParada($stop['stop_id']); $paradas[] = [ 'stop_id' => $stop['stop_id'], 'nome' => $stop['name'], 'lat' => $stop['lat'], 'lon' => $stop['lon'], 'distancia_m' => $stop['distancia_m'], 'tempo_caminhada_min' => max(1, (int) ceil($stop['distancia_m'] / $walkingSpeedMs / 60)), 'etas' => $eta['etas'], ]; } return [ 'ok' => true, 'error' => null, 'paradas' => $paradas, ]; } /** * @return array<string, true> */ private function routeIdsComVeiculosProximos(float $lat, float $lng, int $radiusM): array { $path = $this->service->getLiveDataPath(); if (! Storage::exists($path)) { return []; } try { $raw = Storage::get($path); $json = json_decode($raw, true); if (! is_array($json) || ! isset($json['vehicles']) || ! is_array($json['vehicles'])) { return []; } $active = []; foreach ($json['vehicles'] as $vehicle) { $vLat = (float) ($vehicle['lat'] ?? 0); $vLon = (float) ($vehicle['lon'] ?? 0); $routeId = trim((string) ($vehicle['linha'] ?? '')); if ($routeId === '' || ($vLat === 0.0 && $vLon === 0.0)) { continue; } $distanceM = MobiangraService::distanciaMetros($lat, $lng, $vLat, $vLon); if ($distanceM <= $radiusM) { $active[$routeId] = true; } } return $active; } catch (\Throwable $e) { Log::warning('[MobiAngra] Falha ao ler veículos para linhas próximas', [ 'message' => $e->getMessage(), ]); return []; } } /** * Calcula o Tempo Estimado de Chegada (ETA) de todos os veículos ativos para uma parada. * * Algoritmo: * 1. Para cada veículo em circulação, resolve route_id + direction_id -> trip + shape GTFS * 2. Verifica se a parada solicitada faz parte do itinerário dessa viagem * 3. Projeta a posição do veículo no polyline do shape para obter sua distância acumulada * 4. Projeta a posição da parada no mesmo shape para obter a distância acumulada da parada * 5. Se o veículo está antes da parada (distância restante > 0): * eta_s = distancia_restante_m / velocidade_m_s (com ajustes de limites) * 6. Retorna ETAs ordenados por tempo, uma entrada por linha+sentido (menor ETA) * * @param string $stopId Identificador GTFS da parada (stop_id) * @return array{ok: bool, error: string|null, stop: array|null, etas: list<array>} */ public function calcularEtaParada(string $stopId): array { $etaCfg = config('mobiangra.eta', []); $minSpeedKmh = (float) ($etaCfg['min_speed_kmh'] ?? 5.0); $maxSpeedKmh = (float) ($etaCfg['max_speed_kmh'] ?? 60.0); $defaultSpeedKmh = (float) ($etaCfg['default_speed_kmh'] ?? 20.0); $maxDistToShapeM = (float) ($etaCfg['max_dist_to_shape_m'] ?? 500.0); $maxStaleSeconds = max(1, (int) ($etaCfg['max_stale_seconds'] ?? 120)); $maxEtaMinutes = max(1, (int) ($etaCfg['max_minutes'] ?? 60)); $pastStopMarginM = (float) ($etaCfg['vehicle_past_stop_margin_m'] ?? 150.0); $maxVehicleLagS = max(1, (int) ($etaCfg['max_vehicle_lag_behind_file_seconds'] ?? 600)); $fileAgeMultiplier = max(1, (int) ($etaCfg['max_file_age_interval_multiplier'] ?? 120)); // Carrega parada pelo stop_id $stopsById = $this->service->getStopsById(); if (! isset($stopsById[$stopId])) { return ['ok' => false, 'error' => 'Parada não encontrada.', 'stop' => null, 'etas' => []]; } $stop = $stopsById[$stopId]; // Carrega posições dos veículos $liveDataPath = $this->service->getLiveDataPath(); if (! Storage::exists($liveDataPath)) { return ['ok' => true, 'error' => null, 'stop' => $stop, 'etas' => []]; } try { $raw = Storage::get($liveDataPath); $liveData = json_decode($raw, true); } catch (\Throwable $e) { Log::warning('[MobiAngra ETA] Falha ao ler live_data.json', ['message' => $e->getMessage()]); return ['ok' => true, 'error' => null, 'stop' => $stop, 'etas' => []]; } if (! is_array($liveData) || empty($liveData['vehicles'])) { return ['ok' => true, 'error' => null, 'stop' => $stop, 'etas' => []]; } $now = time(); $fileTs = (int) ($liveData['timestamp'] ?? 0); $maxFileAgeSeconds = max( $maxStaleSeconds, (int) config('mobiangra.fetch_interval', 10) * $fileAgeMultiplier ); if ($fileTs <= 0 || ($now - $fileTs) > $maxFileAgeSeconds) { return ['ok' => true, 'error' => null, 'stop' => $stop, 'etas' => []]; } $tripsMap = $this->service->getTripsMapForEta(); $stopTimesIdx = $this->service->getStopTimesIndexedByTrip(); $routeNames = $this->service->getRouteNames(); $headsigns = $this->service->getDirectionHeadsigns(); // Cache da projeção da parada por shape (calculada uma vez por shape durante a request) $stopShapeDistCache = []; $etas = []; foreach ($liveData['vehicles'] as $vehicle) { $vehicleTs = (int) ($vehicle['timestamp'] ?? 0); // O lote já foi validado pelo timestamp do arquivo; só descarta posição // individual absurdamente defasada em relação ao próprio live_data. if ($vehicleTs > 0 && ($fileTs - $vehicleTs) > $maxVehicleLagS) { continue; } $routeId = trim((string) ($vehicle['linha'] ?? '')); $dirStr = $vehicle['direction'] ?? 'Ida'; $dirId = ($dirStr === 'Ida') ? '0' : '1'; $tripKey = "{$routeId}_{$dirId}"; if (! isset($tripsMap[$tripKey])) { continue; } $tripId = $tripsMap[$tripKey]['trip_id']; $shapeId = $tripsMap[$tripKey]['shape_id']; if (! isset($stopTimesIdx[$tripId])) { continue; } // Verifica se a parada está nesta viagem $stopFound = false; foreach ($stopTimesIdx[$tripId] as $st) { if ($st['stop_id'] === $stopId) { $stopFound = true; break; } } if (! $stopFound) { continue; } // Carrega shape com distâncias acumuladas (cacheado) $shapePoints = $this->service->getShapeWithCumDist($shapeId); if (empty($shapePoints)) { continue; } $vehicleLat = (float) ($vehicle['lat'] ?? 0); $vehicleLon = (float) ($vehicle['lon'] ?? 0); if ($vehicleLat === 0.0 && $vehicleLon === 0.0) { continue; } // Projeta o veículo no shape $vehicleProj = MobiangraService::projetarNoShape($vehicleLat, $vehicleLon, $shapePoints); if ($vehicleProj['dist_to_shape_m'] > $maxDistToShapeM) { // Veículo fora do trajeto (garagem, outra rota, etc.) continue; } // Projeta a parada no shape (cache por shape_id) if (! isset($stopShapeDistCache[$shapeId])) { $stopProj = MobiangraService::projetarNoShape($stop['lat'], $stop['lon'], $shapePoints); $stopShapeDistCache[$shapeId] = $stopProj['cum_dist_m']; } $vehicleCumDist = $vehicleProj['cum_dist_m']; $stopCumDist = $stopShapeDistCache[$shapeId]; $distRemainingM = $stopCumDist - $vehicleCumDist; if ($distRemainingM < -$pastStopMarginM) { continue; } if ($distRemainingM < 0.0) { $distRemainingM = 0.0; } // Velocidade ajustada $speedKmh = (float) ($vehicle['speed_kmh'] ?? 0); if ($speedKmh <= 0) { $speedKmh = $defaultSpeedKmh; } else { $speedKmh = max($minSpeedKmh, min($maxSpeedKmh, $speedKmh)); } $speedMs = $speedKmh / 3.6; $etaSeconds = $distRemainingM / $speedMs; $etaMinutes = (int) ceil($etaSeconds / 60); if ($etaMinutes > $maxEtaMinutes) { continue; } $dirKey = (string) $dirId; $sentido = $headsigns[$routeId][$dirKey] ?? $headsigns[$routeId][$dirId] ?? ($dirId === '0' ? 'Ida' : 'Volta'); $etas[] = [ 'route_id' => $routeId, 'linha_nome' => $routeNames[$routeId] ?? $routeId, 'direction' => $dirStr, 'sentido' => $sentido, 'vehicle_id' => (string) ($vehicle['id'] ?? ''), 'eta_min' => $etaMinutes, 'eta_seg' => (int) round($etaSeconds), 'distancia_m' => (int) round($distRemainingM), 'velocidade_kmh' => round($speedKmh, 1), 'timestamp' => $vehicleTs, ]; } // Ordena por ETA crescente usort($etas, static fn (array $a, array $b): int => $a['eta_min'] <=> $b['eta_min']); // Por linha+sentido mantém apenas o veículo com menor ETA $uniqueEtas = []; $seen = []; foreach ($etas as $eta) { $key = $eta['route_id'] . '_' . $eta['direction']; if (! isset($seen[$key])) { $seen[$key] = true; $uniqueEtas[] = $eta; } } return [ 'ok' => true, 'error' => null, 'stop' => $stop, 'etas' => $uniqueEtas, ]; } /** * Converte a string de data/hora da API para Unix Timestamp. * * Formato de entrada: 'MM-DD-YYYY HH:MM:SS'. */ private function convertToUnix(string $dateTimeStr): int { if (empty($dateTimeStr)) { return time(); } try { $dt = Carbon::createFromFormat('m-d-Y H:i:s', $dateTimeStr, 'America/Sao_Paulo'); return $dt ? $dt->timestamp : time(); } catch (\Throwable) { return time(); } } }
| ver. 1.4 |
Github
|
.
| PHP 8.3.28 | Generation time: 0.02 |
proxy
|
phpinfo
|
Settings