File manager - Edit - /var/www/html/portal/app/Services/Mobiangra/MobiangraService.php
Back
<?php namespace App\Services\Mobiangra; use App\Enums\MobiangraChamadoConvenio; use App\Helpers\FileUploadHelper; use App\Mail\Mobiangra\MobiangraManifestacaoProtocoloMail; use App\Mail\Mobiangra\MobiangraManifestacaoRecebidaMail; use App\Models\Accounts\Chamados\Chamado; use App\Models\Accounts\Chamados\Mensagem; use App\Models\Mobiangra\MobiangraAlerta; use App\Models\Mobiangra\MobiangraHorarioGrade; use App\Models\Mobiangra\MobiangraHorarioIntegracao; use App\Models\Mobiangra\MobiangraHorarioLegenda; use App\Models\Mobiangra\MobiangraHorarioSecao; use App\Models\Mobiangra\MobiangraLinha; use App\Models\Mobiangra\MobiangraManifestacao; use App\Models\Comunicacao\Noticia; use App\Models\Structure; use Illuminate\Database\Eloquent\Collection; use Illuminate\Contracts\Pagination\LengthAwarePaginator; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Mail; use PhpOffice\PhpSpreadsheet\Cell\Cell; use PhpOffice\PhpSpreadsheet\IOFactory; use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet; use App\Protobuf\GtfsRealtimeEncoder; use Illuminate\Http\UploadedFile; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\Http; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Storage; use RuntimeException; use App\Models\User; use Illuminate\Support\Str; use ZipArchive; /** * Operações de persistência e integração com a API de telemetria. */ class MobiangraService { /** * Consulta a API Systemsatx e retorna os dados brutos dos veículos. * * @return array|null Array de veículos ou null em caso de falha. */ public function fetchVehiclePositions(): ?array { $url = config('mobiangra.api_url'); $hash = config('mobiangra.api_hash'); $timeout = config('mobiangra.api_timeout', 15); try { $response = Http::timeout($timeout) ->get($url, ['HashCode' => $hash]); if (! $response->successful()) { Log::warning('Mobiangra: API retornou status ' . $response->status()); return null; } $data = $response->json(); if (! is_array($data)) { Log::warning('Mobiangra: API retornou dados inválidos (não é array).'); return null; } return $data; } catch (\Throwable $e) { Log::error('Mobiangra: Falha ao consultar API - ' . $e->getMessage()); return null; } } /** * Grava o feed GTFS-RT em formato Protocol Buffers. * * @param array $processedVehicles Veículos já processados pelo Resource. * @return bool */ public function saveProtobufFeed(array $processedVehicles): bool { try { $binary = GtfsRealtimeEncoder::encodeFeedMessage($processedVehicles); $path = $this->getProtobufFilePath(); Storage::put($path, $binary); $this->publishProtobufToPublic(); return true; } catch (\Throwable $e) { Log::error('Mobiangra: Falha ao salvar feed .pb - ' . $e->getMessage()); return false; } } /** * Grava o JSON de dados em tempo real consumido pelo mapa. * * @param array $payload Estrutura { timestamp, vehicles: [...] }. * @return bool */ public function saveLiveDataJson(array $payload): bool { try { $path = $this->getLiveDataPath(); $json = json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT); Storage::put($path, $json); return true; } catch (\Throwable $e) { Log::error('Mobiangra: Falha ao salvar live_data.json - ' . $e->getMessage()); return false; } } /** * Retorna as configurações dinâmicas do módulo (sobrepõem o config estático). * * @return array Ex: ['fetch_interval' => 10] */ public function getSettings(): array { $path = config('mobiangra.storage_dir') . '/settings.json'; if (! Storage::exists($path)) { return []; } $data = json_decode(Storage::get($path), true); return is_array($data) ? $data : []; } /** * Persiste configurações dinâmicas do módulo. * * @param array $settings Chaves a sobrescrever (merge com as existentes). */ public function saveSettings(array $settings): bool { try { $current = $this->getSettings(); $merged = array_merge($current, $settings); $path = config('mobiangra.storage_dir') . '/settings.json'; Storage::put($path, json_encode($merged, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)); return true; } catch (\Throwable $e) { Log::error('Mobiangra: Falha ao salvar settings - ' . $e->getMessage()); return false; } } /** * Retorna o intervalo de atualização em segundos (settings > config). */ public function getFetchInterval(): int { $settings = $this->getSettings(); return (int) ($settings['fetch_interval'] ?? config('mobiangra.fetch_interval', 10)); } /** * Salva o status da última execução do feed. */ public function saveFeedStatus(int $vehicleCount, bool $success): void { try { $path = config('mobiangra.storage_dir') . '/' . config('mobiangra.feed_status_filename'); $status = [ 'last_update' => now()->toIso8601String(), 'last_update_timestamp' => time(), 'vehicle_count' => $vehicleCount, 'success' => $success, ]; Storage::put($path, json_encode($status, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT)); } catch (\Throwable $e) { Log::error('Mobiangra: Falha ao salvar status do feed - ' . $e->getMessage()); } } /** * Retorna o status da última execução do feed. */ public function getFeedStatus(): ?array { $path = config('mobiangra.storage_dir') . '/' . config('mobiangra.feed_status_filename'); if (! Storage::exists($path)) { return null; } return json_decode(Storage::get($path), true); } /** * Retorna o caminho relativo ao Storage do arquivo .pb. */ public function getProtobufFilePath(): string { return config('mobiangra.storage_dir') . '/' . config('mobiangra.pb_filename'); } /** * Retorna o caminho relativo ao Storage do arquivo live_data.json. */ public function getLiveDataPath(): string { return config('mobiangra.storage_dir') . '/' . config('mobiangra.live_data_filename'); } /** * Retorna o caminho absoluto do arquivo .pb no disco. */ public function getProtobufAbsolutePath(): string { return storage_path('app/' . $this->getProtobufFilePath()); } /** * Diretório público onde os feeds GTFS são publicados para entrega estática. */ public function getPublicGtfsDirectory(): string { return public_path((string) config('mobiangra.public_gtfs_dir', 'mobiangra/gtfs')); } /** * Caminho absoluto do feed GTFS-RT publicado em public/mobiangra-assets/gtfs/feed.pb. */ public function getPublicGtfsProtobufPath(): string { return $this->getPublicGtfsDirectory() . DIRECTORY_SEPARATOR . (string) config('mobiangra.public_gtfs_pb_name', 'feed.pb'); } /** * Caminho absoluto do GTFS estático publicado em public/mobiangra-assets/gtfs/gtfs.zip. */ public function getPublicGtfsZipPath(): string { return $this->getPublicGtfsDirectory() . DIRECTORY_SEPARATOR . (string) config('mobiangra.public_gtfs_zip_name', 'gtfs.zip'); } /** * Copia o feed GTFS-RT (.pb) do storage para public/mobiangra-assets/gtfs/feed.pb. */ public function publishProtobufToPublic(): bool { $source = $this->getProtobufAbsolutePath(); if (! is_file($source)) { return false; } return $this->copyToPublicGtfs($source, $this->getPublicGtfsProtobufPath()); } /** * Copia o pacote GTFS estático (.zip) para public/mobiangra-assets/gtfs/gtfs.zip. */ public function publishGtfsZipToPublic(?string $sourcePath = null): bool { $source = $sourcePath ?? $this->getGtfsZipAbsolutePath(); if (! is_file($source)) { return false; } return $this->copyToPublicGtfs($source, $this->getPublicGtfsZipPath()); } /** * Retorna o caminho absoluto do diretório GTFS estático. */ public function getGtfsStaticDir(): string { return storage_path('app/' . config('mobiangra.gtfs_dir')); } /** * Chaves dos arquivos GTFS aceitos no upload (sem extensão). * * @return array<int, string> */ public function getGtfsAllowedFileKeys(): array { return [ 'calendar', 'calendar_dates', 'routes', 'trips', 'stops', 'shapes', 'stop_times', 'agency', 'feed_info', ]; } /** * Nomes de arquivo GTFS permitidos na extração de ZIP ou envio individual. * * @return array<int, string> */ public function getGtfsAllowedFilenames(): array { return array_map( fn (string $key) => $key . '.txt', $this->getGtfsAllowedFileKeys() ); } /** * Garante que o diretório GTFS estático exista. */ public function ensureGtfsDirectory(): void { $dir = $this->getGtfsStaticDir(); if (! is_dir($dir)) { mkdir($dir, 0755, true); } } /** * Extrai arquivos GTFS de um pacote .zip para o diretório estático. * * Aceita ZIP com arquivos na raiz ou em subpastas (ex.: exportação GTFS padrão). * Apenas arquivos da lista permitida são gravados, pelo nome base (ex.: routes.txt). * * @throws RuntimeException Quando o ZIP não puder ser aberto ou estiver vazio de GTFS válido. */ public function extractGtfsZip(string $zipPath): int { if (! class_exists(ZipArchive::class)) { throw new RuntimeException('Extensão ZIP do PHP não está disponível.'); } $zip = new ZipArchive(); $opened = $zip->open($zipPath); if ($opened !== true) { throw new RuntimeException('Não foi possível abrir o arquivo ZIP.'); } $allowed = array_flip($this->getGtfsAllowedFilenames()); $this->ensureGtfsDirectory(); $targetDir = $this->getGtfsStaticDir(); $extracted = 0; for ($i = 0; $i < $zip->numFiles; $i++) { $entryName = $zip->getNameIndex($i); if ($entryName === false || $this->shouldSkipGtfsZipEntry($entryName)) { continue; } $basename = basename(str_replace('\\', '/', $entryName)); if (! isset($allowed[$basename])) { continue; } $content = $zip->getFromIndex($i); if ($content === false) { continue; } file_put_contents($targetDir . DIRECTORY_SEPARATOR . $basename, $content); $extracted++; } $zip->close(); if ($extracted === 0) { throw new RuntimeException( 'O ZIP não contém arquivos GTFS reconhecidos (.txt permitidos na raiz ou em subpastas).' ); } return $extracted; } /** * Importa um arquivo .txt GTFS enviado individualmente. */ public function importGtfsTextFile(string $fileKey, UploadedFile $file): bool { if (! in_array($fileKey, $this->getGtfsAllowedFileKeys(), true)) { return false; } $this->ensureGtfsDirectory(); $targetPath = $this->getGtfsStaticDir() . '/' . $fileKey . '.txt'; $file->move(dirname($targetPath), basename($targetPath)); return true; } private function shouldSkipGtfsZipEntry(string $entryName): bool { $normalized = str_replace('\\', '/', $entryName); if (str_ends_with($normalized, '/')) { return true; } if (str_contains($normalized, '__MACOSX') || str_contains($normalized, '/.')) { return true; } $basename = basename($normalized); return $basename === '' || str_starts_with($basename, '.'); } /** * Caminho relativo ao Storage do pacote GTFS estático (.zip) para download público. */ public function getGtfsZipStoragePath(): string { return config('mobiangra.storage_dir') . '/' . config('mobiangra.gtfs_zip_filename', 'gtfs_static.zip'); } /** * Caminho absoluto do pacote GTFS estático (.zip). */ public function getGtfsZipAbsolutePath(): string { return storage_path('app/' . $this->getGtfsZipStoragePath()); } /** * Nome do arquivo sugerido no download HTTP. */ public function getGtfsZipDownloadName(): string { return (string) config('mobiangra.gtfs_zip_download_name', 'angra-gtfs.zip'); } /** * Indica se há ao menos um arquivo GTFS estático no diretório. */ public function hasGtfsStaticFiles(): bool { $gtfsDir = $this->getGtfsStaticDir(); foreach ($this->getGtfsAllowedFilenames() as $filename) { if (is_file($gtfsDir . DIRECTORY_SEPARATOR . $filename)) { return true; } } return false; } /** * Remove o ZIP publicado para forçar reconstrução após alteração dos .txt. */ public function invalidateGtfsZipArchive(): void { $path = $this->getGtfsZipAbsolutePath(); if (is_file($path)) { unlink($path); } } /** * Garante um pacote .zip atualizado com os arquivos GTFS estáticos (raiz do ZIP). * * @throws RuntimeException Quando não há arquivos GTFS ou falha ao gerar o ZIP. */ public function ensureGtfsZipArchive(): string { if (! $this->hasGtfsStaticFiles()) { throw new RuntimeException('Nenhum arquivo GTFS estático disponível.'); } $zipPath = $this->getGtfsZipAbsolutePath(); $gtfsDir = $this->getGtfsStaticDir(); $needsRebuild = ! is_file($zipPath); if (! $needsRebuild) { $zipMtime = (int) filemtime($zipPath); foreach ($this->getGtfsAllowedFilenames() as $filename) { $filePath = $gtfsDir . DIRECTORY_SEPARATOR . $filename; if (is_file($filePath) && filemtime($filePath) > $zipMtime) { $needsRebuild = true; break; } } } if ($needsRebuild) { $this->buildGtfsZipArchive($zipPath); } return $zipPath; } /** * @throws RuntimeException */ private function buildGtfsZipArchive(string $zipPath): void { if (! class_exists(ZipArchive::class)) { throw new RuntimeException('Extensão ZIP do PHP não está disponível.'); } $zip = new ZipArchive(); $parentDir = dirname($zipPath); if (! is_dir($parentDir)) { mkdir($parentDir, 0755, true); } if ($zip->open($zipPath, ZipArchive::CREATE | ZipArchive::OVERWRITE) !== true) { throw new RuntimeException('Não foi possível criar o pacote GTFS (.zip).'); } $gtfsDir = $this->getGtfsStaticDir(); $added = 0; foreach ($this->getGtfsAllowedFilenames() as $filename) { $filePath = $gtfsDir . DIRECTORY_SEPARATOR . $filename; if (! is_file($filePath)) { continue; } $zip->addFile($filePath, $filename); $added++; } $zip->close(); if ($added === 0) { if (is_file($zipPath)) { unlink($zipPath); } throw new RuntimeException('Nenhum arquivo GTFS estático disponível.'); } $this->publishGtfsZipToPublic($zipPath); } /** * Copia um arquivo de feed para o diretório público GTFS. */ private function copyToPublicGtfs(string $source, string $destination): bool { try { $directory = dirname($destination); if (! is_dir($directory) && ! mkdir($directory, 0755, true) && ! is_dir($directory)) { throw new RuntimeException("Não foi possível criar o diretório {$directory}."); } if (! copy($source, $destination)) { throw new RuntimeException("Falha ao copiar {$source} para {$destination}."); } return true; } catch (\Throwable $e) { Log::warning('Mobiangra: falha ao publicar feed GTFS em public/ - ' . $e->getMessage()); return false; } } /** * Lê e retorna o conteúdo de um arquivo GTFS estático como array associativo. * * @param string $filename Nome do arquivo (ex: "routes.txt"). * @return array */ public function readGtfsCsv(string $filename): array { $path = $this->getGtfsStaticDir() . '/' . $filename; if (! file_exists($path)) { Log::warning("Mobiangra: Arquivo GTFS não encontrado - {$filename}"); return []; } $rows = []; $handle = fopen($path, 'r'); if ($handle === false) { return []; } $headers = fgetcsv($handle); if ($headers === false) { fclose($handle); return []; } while (($row = fgetcsv($handle)) !== false) { if (count($row) === count($headers)) { $rows[] = array_combine($headers, $row); } } fclose($handle); return $rows; } /** * Caminho relativo ao Storage do arquivo de configuração da frota. */ public function getFleetConfigPath(): string { return config('mobiangra.storage_dir') . '/' . config('mobiangra.fleet_filename', 'fleet.json'); } /** * Caminho absoluto do arquivo fleet.json. */ public function getFleetConfigAbsolutePath(): string { return storage_path('app/' . $this->getFleetConfigPath()); } /** * Indica se o arquivo fleet.json existe no storage. */ public function fleetConfigExists(): bool { return Storage::exists($this->getFleetConfigPath()); } /** * Cria fleet.json a partir do seed do config quando ainda não existir. */ public function ensureFleetConfigExists(): void { $path = $this->getFleetConfigPath(); if (Storage::exists($path)) { return; } Storage::put( $path, json_encode($this->getFleetSeedFromConfig(), JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) ); } /** * Valores iniciais exportados para fleet.json (espelham config/mobiangra.php). * * @return array<string, mixed> */ public function getFleetSeedFromConfig(): array { return [ 'internal_to_gtfs' => config('mobiangra.internal_to_gtfs', []), 'route_names' => config('mobiangra.route_names', []), 'direction_headsigns' => config('mobiangra.direction_headsigns', []), 'line_colors' => config('mobiangra.line_colors', []), 'vehicle_colors' => config('mobiangra.vehicle_colors', []), 'ac_vehicles' => config('mobiangra.ac_vehicles', []), 'defaults' => config('mobiangra.defaults', []), ]; } /** * Lê e mescla a configuração da frota (arquivo externo + seed). * * @return array<string, mixed> */ public function getFleetConfig(): array { return Cache::remember('mobiangra.fleet.merged', 300, function () { $this->ensureFleetConfigExists(); $seed = $this->getFleetSeedFromConfig(); $path = $this->getFleetConfigPath(); $file = []; if (Storage::exists($path)) { $decoded = json_decode(Storage::get($path), true); $file = is_array($decoded) ? $decoded : []; } return [ 'internal_to_gtfs' => array_replace( $seed['internal_to_gtfs'], $file['internal_to_gtfs'] ?? [] ), 'route_names' => array_replace( $seed['route_names'], $file['route_names'] ?? [] ), 'direction_headsigns' => $this->mergeDirectionHeadsigns( $seed['direction_headsigns'], $file['direction_headsigns'] ?? [] ), 'line_colors' => array_replace( $seed['line_colors'], $file['line_colors'] ?? [] ), 'vehicle_colors' => array_replace( $seed['vehicle_colors'], $file['vehicle_colors'] ?? [] ), 'ac_vehicles' => array_values(array_unique(array_merge( $seed['ac_vehicles'], $file['ac_vehicles'] ?? [] ))), 'defaults' => array_replace( $seed['defaults'], $file['defaults'] ?? [] ), ]; }); } /** * Substitui o conteúdo completo do fleet.json. * * @param array<string, mixed> $config */ public function saveFleetConfig(array $config): bool { try { $path = $this->getFleetConfigPath(); Storage::put( $path, json_encode($config, JSON_UNESCAPED_UNICODE | JSON_PRETTY_PRINT) ); $this->clearModuleCaches(); return true; } catch (\Throwable $e) { Log::error('Mobiangra: Falha ao salvar fleet.json - ' . $e->getMessage()); return false; } } /** * Mapeamento Systemsatx (Linha_Rota) → [route_id, direction_id]. * * @return array<string, array{0: string, 1: int}> */ public function getInternalToGtfs(): array { return $this->getFleetConfig()['internal_to_gtfs'] ?? []; } /** * Nomes das rotas: GTFS (routes.txt) com overrides em fleet.json. * * @return array<string, string> */ public function getRouteNames(): array { return Cache::remember('mobiangra.route_catalog', 3600, function () { $names = $this->loadRouteNamesFromGtfs(); $overrides = $this->getFleetConfig()['route_names'] ?? []; if (empty($names)) { $names = config('mobiangra.route_names', []); } // array_replace preserva chaves string-numéricas como "139", "134" // (array_merge reindexa para 0..N e quebra o lookup por route_id). return array_replace($names, $overrides); }); } /** * Headsigns por rota e sentido: GTFS (trips.txt) com overrides em fleet.json. * * @return array<string, array<string, string>> */ public function getDirectionHeadsigns(): array { return Cache::remember('mobiangra.direction_headsigns', 3600, function () { $headsigns = $this->loadDirectionHeadsignsFromGtfs(); $fleet = $this->getFleetConfig()['direction_headsigns'] ?? []; return $this->mergeDirectionHeadsigns($headsigns, $fleet); }); } /** * Cores de popup por código de linha (fleet + padrão automático para rotas novas). * * @return array<string, array{bg: string, text: string}> */ public function getLineColors(): array { $fleet = $this->getFleetConfig(); $colors = $fleet['line_colors'] ?? []; $default = $fleet['defaults']['line_color'] ?? config('mobiangra.defaults.line_color', [ 'bg' => '#0066cc', 'text' => 'white', ]); foreach ($this->getRouteNames() as $name) { $shortCode = explode(' ', (string) $name)[0]; if ($shortCode === '' || isset($colors[$shortCode])) { continue; } $colors[$shortCode] = $this->resolveAutoLineColor($shortCode, $default); } return $colors; } /** * Cores dos ícones de veículos no mapa. * * @return array<string, string> */ public function getVehicleColors(): array { return $this->getFleetConfig()['vehicle_colors'] ?? []; } /** * IDs de veículos com ar condicionado. * * @return array<int, string> */ public function getAcVehicles(): array { return $this->getFleetConfig()['ac_vehicles'] ?? []; } /** * IDs de veículos classificados como micro-ônibus no mapa. * * @return array<int, string> */ public function getMicroVehicles(): array { return config('mobiangra.micro_vehicles', []); } /** * Valores padrão visuais (veículo/linha sem cadastro explícito). * * @return array<string, mixed> */ public function getMapDefaults(): array { $fleet = $this->getFleetConfig(); return array_merge(config('mobiangra.defaults', []), $fleet['defaults'] ?? []); } /** * Parâmetros de ETA expostos ao mapa público (window.MOBIANGRA.eta). * * @return array{ * maxMinutes: int, * chegandoThresholdMinutes: int, * displayMaxLines: int * } */ public function getEtaPublicConfig(): array { $eta = config('mobiangra.eta', []); return [ 'maxMinutes' => max(1, (int) ($eta['max_minutes'] ?? 60)), 'chegandoThresholdMinutes' => max(0, (int) ($eta['chegando_threshold_minutes'] ?? 1)), 'displayMaxLines' => max(1, (int) ($eta['display_max_lines'] ?? 8)), ]; } /** * Índice espacial leve das paradas GTFS para busca por proximidade. * * @return list<array{stop_id: string, name: string, lat: float, lon: float}> */ public function getStopsSpatialIndex(): array { return Cache::remember('mobiangra.stops_spatial', 3600, function () { $index = []; foreach ($this->readGtfsCsv('stops.txt') as $stop) { $lat = (float) ($stop['stop_lat'] ?? 0); $lon = (float) ($stop['stop_lon'] ?? 0); if ($lat === 0.0 && $lon === 0.0) { continue; } $stopId = trim((string) ($stop['stop_id'] ?? '')); if ($stopId === '') { continue; } $index[] = [ 'stop_id' => $stopId, 'name' => trim((string) ($stop['stop_name'] ?? $stopId)), 'lat' => $lat, 'lon' => $lon, ]; } return $index; }); } /** * Mapa parada GTFS para rotas que a atendem (route_id + código curto). * * @return array<string, list<array{route_id: string, codigo: string}>> */ public function getStopRoutesMap(): array { return Cache::remember('mobiangra.stop_routes', 3600, function () { try { $routeNames = $this->getRouteNames(); $trips = $this->readGtfsCsv('trips.txt'); $stopTimes = $this->readGtfsCsv('stop_times.txt'); $tripToRoute = []; foreach ($trips as $trip) { $tripId = trim((string) ($trip['trip_id'] ?? '')); $routeId = trim((string) ($trip['route_id'] ?? '')); if ($tripId === '' || $routeId === '' || ! isset($routeNames[$routeId])) { continue; } $tripToRoute[$tripId] = $routeId; } $stopRoutes = []; foreach ($stopTimes as $stopTime) { $tripId = trim((string) ($stopTime['trip_id'] ?? '')); $stopId = trim((string) ($stopTime['stop_id'] ?? '')); if ($tripId === '' || $stopId === '' || ! isset($tripToRoute[$tripId])) { continue; } $routeId = $tripToRoute[$tripId]; $fullName = $routeNames[$routeId]; $codigo = explode(' ', (string) $fullName)[0]; if ($codigo === '') { continue; } if (! isset($stopRoutes[$stopId])) { $stopRoutes[$stopId] = []; } $seen = false; foreach ($stopRoutes[$stopId] as $entry) { if ($entry['route_id'] === $routeId) { $seen = true; break; } } if (! $seen) { $stopRoutes[$stopId][] = [ 'route_id' => $routeId, 'codigo' => $codigo, ]; } } return $stopRoutes; } catch (\Throwable) { return []; } }); } /** * Códigos das linhas de horários publicadas no portal. * * @return array<string, true> */ public function getLinhasPublicadasCodigos(): array { return Cache::remember('mobiangra.linhas_publicadas_codigos', 3600, function () { return MobiangraLinha::query() ->where('publicada', true) ->pluck('codigo') ->mapWithKeys(static fn (string $codigo): array => [ mb_strtoupper(trim($codigo), 'UTF-8') => true, ]) ->all(); }); } /** * Distância em metros entre dois pontos geográficos (Haversine). */ public static function distanciaMetros(float $lat1, float $lon1, float $lat2, float $lon2): float { $earthRadius = 6_371_000.0; $dLat = deg2rad($lat2 - $lat1); $dLon = deg2rad($lon2 - $lon1); $a = sin($dLat / 2) ** 2 + cos(deg2rad($lat1)) * cos(deg2rad($lat2)) * sin($dLon / 2) ** 2; return $earthRadius * 2 * atan2(sqrt($a), sqrt(1 - $a)); } /** * Verifica se coordenadas estão dentro da área de cobertura do MobiAngra. */ public function coordenadaNaAreaCobertura(float $lat, float $lng): bool { $bounds = config('mobiangra.nearby.bounds', []); return $lat >= (float) ($bounds['lat_min'] ?? -90) && $lat <= (float) ($bounds['lat_max'] ?? 90) && $lng >= (float) ($bounds['lng_min'] ?? -180) && $lng <= (float) ($bounds['lng_max'] ?? 180); } /** * Paradas GTFS indexadas por stop_id. * * @return array<string, array{stop_id: string, name: string, lat: float, lon: float}> */ public function getStopsById(): array { return Cache::remember('mobiangra.stops_by_id', 3600, function () { $stopsById = []; foreach ($this->readGtfsCsv('stops.txt') as $stop) { $stopId = trim((string) ($stop['stop_id'] ?? '')); if ($stopId === '') { continue; } $stopsById[$stopId] = [ 'stop_id' => $stopId, 'name' => trim((string) ($stop['stop_name'] ?? $stopId)), 'lat' => (float) ($stop['stop_lat'] ?? 0), 'lon' => (float) ($stop['stop_lon'] ?? 0), ]; } return $stopsById; }); } /** * Mapa {route_id}_{direction_id} -> {trip_id, shape_id} para cálculo de ETA. * Usa a primeira viagem de cada combinação rota+sentido encontrada no GTFS. * * @return array<string, array{trip_id: string, shape_id: string}> */ public function getTripsMapForEta(): array { return Cache::remember('mobiangra.trips_eta_map', 3600, function () { $trips = $this->readGtfsCsv('trips.txt'); $map = []; foreach ($trips as $trip) { $routeId = trim((string) ($trip['route_id'] ?? '')); $dirId = (string) ($trip['direction_id'] ?? '0'); $tripId = trim((string) ($trip['trip_id'] ?? '')); $shapeId = trim((string) ($trip['shape_id'] ?? '')); if ($routeId === '' || $tripId === '' || $shapeId === '') { continue; } $key = "{$routeId}_{$dirId}"; if (! isset($map[$key])) { $map[$key] = ['trip_id' => $tripId, 'shape_id' => $shapeId]; } } return $map; }); } /** * Horários de parada (stop_times) indexados por trip_id, ordenados por stop_sequence. * * @return array<string, list<array{stop_id: string, seq: int}>> */ public function getStopTimesIndexedByTrip(): array { return Cache::remember('mobiangra.stop_times_by_trip', 3600, function () { $stopTimes = $this->readGtfsCsv('stop_times.txt'); $indexed = []; foreach ($stopTimes as $st) { $tripId = trim((string) ($st['trip_id'] ?? '')); $stopId = trim((string) ($st['stop_id'] ?? '')); $seq = (int) ($st['stop_sequence'] ?? 0); if ($tripId === '' || $stopId === '') { continue; } if (! isset($indexed[$tripId])) { $indexed[$tripId] = []; } $indexed[$tripId][] = ['stop_id' => $stopId, 'seq' => $seq]; } foreach ($indexed as &$stops) { usort($stops, static fn (array $a, array $b): int => $a['seq'] <=> $b['seq']); } return $indexed; }); } /** * Retorna os pontos de um shape com distância acumulada em metros calculada por Haversine. * * A distância é sempre computada a partir da geometria (não depende de shape_dist_traveled * do GTFS, cujas unidades variam por exportador). * * @param string $shapeId * @return list<array{lat: float, lon: float, seq: int, cum_dist_m: float}> */ public function getShapeWithCumDist(string $shapeId): array { $cacheKey = 'mobiangra.shape_cumdist.' . md5($shapeId); return Cache::remember($cacheKey, 3600, function () use ($shapeId) { $points = []; foreach ($this->readGtfsCsv('shapes.txt') as $row) { if (($row['shape_id'] ?? '') !== $shapeId) { continue; } $lat = (float) ($row['shape_pt_lat'] ?? 0); $lon = (float) ($row['shape_pt_lon'] ?? 0); $seq = (int) ($row['shape_pt_sequence'] ?? 0); if ($lat === 0.0 && $lon === 0.0) { continue; } $points[] = ['lat' => $lat, 'lon' => $lon, 'seq' => $seq]; } if (empty($points)) { return []; } usort($points, static fn (array $a, array $b): int => $a['seq'] <=> $b['seq']); $cumDist = 0.0; $result = []; $prev = null; foreach ($points as $p) { if ($prev !== null) { $cumDist += self::distanciaMetros($prev['lat'], $prev['lon'], $p['lat'], $p['lon']); } $result[] = [ 'lat' => $p['lat'], 'lon' => $p['lon'], 'seq' => $p['seq'], 'cum_dist_m' => $cumDist, ]; $prev = $p; } return $result; }); } /** * Projeta um ponto geográfico no polyline do shape e retorna: * - cum_dist_m: distância acumulada no ponto de projeção (metros desde o início do shape) * - dist_to_shape_m: distância perpendicular do ponto ao shape (metros) * * Usa aproximação plana (flat-earth) válida para segmentos curtos (<50 km). * * @param float $lat * @param float $lon * @param list<array{lat: float, lon: float, cum_dist_m: float}> $shapePoints * @return array{cum_dist_m: float, dist_to_shape_m: float} */ public static function projetarNoShape(float $lat, float $lon, array $shapePoints): array { $n = count($shapePoints); if ($n === 0) { return ['cum_dist_m' => 0.0, 'dist_to_shape_m' => PHP_FLOAT_MAX]; } if ($n === 1) { return [ 'cum_dist_m' => 0.0, 'dist_to_shape_m' => self::distanciaMetros($lat, $lon, $shapePoints[0]['lat'], $shapePoints[0]['lon']), ]; } $bestDist = PHP_FLOAT_MAX; $bestCumDist = 0.0; for ($i = 0; $i < $n - 1; $i++) { $p1 = $shapePoints[$i]; $p2 = $shapePoints[$i + 1]; $dx = $p2['lat'] - $p1['lat']; $dy = $p2['lon'] - $p1['lon']; $lenSq = $dx * $dx + $dy * $dy; if ($lenSq < 1e-18) { $dist = self::distanciaMetros($lat, $lon, $p1['lat'], $p1['lon']); if ($dist < $bestDist) { $bestDist = $dist; $bestCumDist = $p1['cum_dist_m']; } continue; } $t = (($lat - $p1['lat']) * $dx + ($lon - $p1['lon']) * $dy) / $lenSq; $t = max(0.0, min(1.0, $t)); $projLat = $p1['lat'] + $t * $dx; $projLon = $p1['lon'] + $t * $dy; $dist = self::distanciaMetros($lat, $lon, $projLat, $projLon); if ($dist < $bestDist) { $bestDist = $dist; $segLen = self::distanciaMetros($p1['lat'], $p1['lon'], $p2['lat'], $p2['lon']); $bestCumDist = $p1['cum_dist_m'] + $t * $segLen; } } return ['cum_dist_m' => $bestCumDist, 'dist_to_shape_m' => $bestDist]; } /** * Invalida caches derivados de GTFS e fleet.json. */ public function clearModuleCaches(): void { $keys = [ 'mobiangra.fleet.merged', 'mobiangra.route_catalog', 'mobiangra.direction_headsigns', 'mobiangra.departures', 'mobiangra.stop_lines', 'mobiangra.stop_routes', 'mobiangra.stops_spatial', 'mobiangra.stops_by_id', 'mobiangra.trips_eta_map', 'mobiangra.stop_times_by_trip', 'mobiangra.linhas_publicadas_codigos', 'mobiangra.routes', 'mobiangra.stops', 'mobiangra.shapes', 'mobiangra.trips', ]; foreach ($keys as $key) { Cache::forget($key); } $this->invalidateGtfsZipArchive(); } /** * @return array<string, string> */ private function loadRouteNamesFromGtfs(): array { $names = []; foreach ($this->readGtfsCsv('routes.txt') as $row) { $id = trim((string) ($row['route_id'] ?? '')); if ($id === '') { continue; } $long = trim((string) ($row['route_long_name'] ?? '')); $short = trim((string) ($row['route_short_name'] ?? '')); // Garante que o nome começa pelo código curto (ex.: "101 Cantagalo - Divisa Mangaratiba"), // necessário para o JS/PHP extraírem o código via explode(' ', $name)[0]. if ($short !== '' && $long !== '') { $names[$id] = $short . ' ' . $long; } elseif ($short !== '') { $names[$id] = $short; } elseif ($long !== '') { $names[$id] = $long; } else { $names[$id] = $id; } } return $names; } /** * @return array<string, array<string, string>> */ private function loadDirectionHeadsignsFromGtfs(): array { $headsigns = []; foreach ($this->readGtfsCsv('trips.txt') as $trip) { $routeId = trim((string) ($trip['route_id'] ?? '')); $dir = (string) ($trip['direction_id'] ?? '0'); $label = trim((string) ($trip['trip_headsign'] ?? '')); if ($routeId === '' || $label === '') { continue; } if (! isset($headsigns[$routeId][$dir])) { $headsigns[$routeId][$dir] = $label; } } return $headsigns; } /** * @param array<string, array<string, string>> $base * @param array<string, array<string, string>> $overrides * @return array<string, array<string, string>> */ private function mergeDirectionHeadsigns(array $base, array $overrides): array { foreach ($overrides as $routeId => $dirs) { if (! is_array($dirs)) { continue; } foreach ($dirs as $dir => $label) { $base[$routeId][$dir] = $label; } } return $base; } /** * @param array{bg: string, text: string} $default * @return array{bg: string, text: string} */ private function resolveAutoLineColor(string $shortCode, array $default): array { $palette = [ ['bg' => 'orange', 'text' => 'white'], ['bg' => 'green', 'text' => 'white'], ]; $index = abs(crc32($shortCode)) % count($palette); return $palette[$index] ?? $default; } // ------------------------------------------------------------------------- // CRUD - Horários // ------------------------------------------------------------------------- // ------------------------------------------------------------------------- // CRUD - Linhas // ------------------------------------------------------------------------- /** * @return \Illuminate\Database\Eloquent\Collection<int, MobiangraLinha> */ public function listarLinhas(bool $apenasPublicadas = false) { $q = MobiangraLinha::query() ->with(['secoes.grades', 'legendas', 'integracoes']) ->orderBy('ordem') ->orderBy('codigo'); if ($apenasPublicadas) { $q->where('publicada', true); } return $q->get(); } /** * Busca linhas de horários no admin (código, nome ou nome completo). * * @return \Illuminate\Database\Eloquent\Collection<int, MobiangraLinha> */ public function buscarLinhasHorariosAdmin(string $termo = '') { $query = MobiangraLinha::query() ->withCount('secoes') ->orderBy('ordem') ->orderBy('codigo'); $termo = trim($termo); if ($termo !== '') { $like = '%'.addcslashes($termo, '%_\\').'%'; $query->where(function ($builder) use ($like) { $builder->where('codigo', 'like', $like) ->orWhere('nome', 'like', $like) ->orWhere('nome_completo', 'like', $like); }); } return $query->get(); } public function buscarLinha(int $id): MobiangraLinha { return MobiangraLinha::with(['secoes.grades', 'legendas', 'integracoes'])->findOrFail($id); } public function criarLinha(array $dados): MobiangraLinha { return DB::transaction(function () use ($dados) { $linha = MobiangraLinha::create($this->sanitizarDadosLinha($dados)); Cache::forget('mobiangra.linhas_publicadas_codigos'); return $linha; }); } public function atualizarLinha(MobiangraLinha $linha, array $dados): MobiangraLinha { return DB::transaction(function () use ($linha, $dados) { $linha->update($this->sanitizarDadosLinha($dados)); Cache::forget('mobiangra.linhas_publicadas_codigos'); return $linha; }); } public function excluirLinha(MobiangraLinha $linha): void { DB::transaction(function () use ($linha) { $linha->delete(); Cache::forget('mobiangra.linhas_publicadas_codigos'); }); } // ------------------------------------------------------------------------- // CRUD - Seções // ------------------------------------------------------------------------- public function criarSecao(MobiangraLinha $linha, array $dados): MobiangraHorarioSecao { return DB::transaction(function () use ($linha, $dados) { $ordem = $linha->secoes()->max('ordem') + 1; return $linha->secoes()->create([ 'titulo' => $dados['titulo'], 'ordem' => $dados['ordem'] ?? $ordem, ]); }); } public function atualizarSecao(MobiangraHorarioSecao $secao, array $dados): MobiangraHorarioSecao { return DB::transaction(function () use ($secao, $dados) { $secao->update([ 'titulo' => $dados['titulo'], 'ordem' => $dados['ordem'] ?? $secao->ordem, ]); return $secao; }); } public function excluirSecao(MobiangraHorarioSecao $secao): void { DB::transaction(fn () => $secao->delete()); } // ------------------------------------------------------------------------- // CRUD - Grades (horários por período) // ------------------------------------------------------------------------- public function salvarGrade(MobiangraHorarioSecao $secao, string $periodo, string $label, array $horarios): MobiangraHorarioGrade { return DB::transaction(function () use ($secao, $periodo, $label, $horarios) { $horarios = $this->filtrarHorariosGrade($horarios); return $secao->grades()->updateOrCreate( ['periodo' => $periodo], [ 'label' => $label, 'horarios' => $horarios, 'ordem' => array_search($periodo, array_keys(MobiangraHorarioGrade::PERIODOS)) ?: 0, ] ); }); } public function excluirGrade(MobiangraHorarioGrade $grade): void { DB::transaction(fn () => $grade->delete()); } // ------------------------------------------------------------------------- // CRUD - Legendas // ------------------------------------------------------------------------- public function sincronizarLegendas(MobiangraLinha $linha, array $legendas): void { DB::transaction(function () use ($linha, $legendas) { $linha->legendas()->delete(); foreach ($legendas as $i => $legenda) { if (empty($legenda['descricao'])) { continue; } $linha->legendas()->create([ 'cor' => $legenda['cor'] ?? '', 'descricao' => $legenda['descricao'], 'ordem' => $i, ]); } }); } // ------------------------------------------------------------------------- // CRUD - Integrações // ------------------------------------------------------------------------- public function sincronizarIntegracoes(MobiangraLinha $linha, array $integracoes): void { DB::transaction(function () use ($linha, $integracoes) { $linha->integracoes()->delete(); $ordem = 0; foreach ($integracoes as $integracao) { // Aceita dois formatos: // 1. ['linhas_envolvidas' => [...]] - vindo do import XLSX / legado // 2. já processado pelo controller (linhas_envolvidas já é array) $linhasEnvolvidas = $integracao['linhas_envolvidas'] ?? []; if (is_string($linhasEnvolvidas)) { $linhasEnvolvidas = json_decode($linhasEnvolvidas, true) ?? []; } if (empty($linhasEnvolvidas)) { continue; } $linha->integracoes()->create([ 'linhas_envolvidas' => $linhasEnvolvidas, 'tempo_minutos' => (int) ($integracao['tempo_minutos'] ?? 120), 'ordem' => $ordem++, ]); } }); } // ------------------------------------------------------------------------- // Importação do XLSX // ------------------------------------------------------------------------- /** * Importa todas as abas do XLSX para o banco de dados. * Não destrói dados existentes - faz upsert por código de linha. */ public function importarXlsx(string $caminho): array { $spreadsheet = IOFactory::load($caminho); $resultados = []; foreach ($spreadsheet->getSheetNames() as $nomeAba) { $sheet = $spreadsheet->getSheetByName($nomeAba); try { $this->processarAba($nomeAba, $sheet); $resultados[$nomeAba] = 'ok'; } catch (\Throwable $e) { $resultados[$nomeAba] = 'erro: '.$e->getMessage(); } } return $resultados; } // ------------------------------------------------------------------------- // Helpers privados // ------------------------------------------------------------------------- private function processarAba(string $codigo, Worksheet $sheet): void { $rows = $sheet->toArray(null, true, true, true); // Row 1: nome completo $nomeCompleto = trim((string) ($rows[1]['A'] ?? '')); if (! $nomeCompleto) { return; } // Extrair nome sem o código $nome = preg_replace('/^\S+\s*-\s*/', '', $nomeCompleto); // Row 3: tarifas $tarifaNormal = $this->extrairValorMonetario((string) ($rows[3]['A'] ?? '')); $tarifaCidadao = $this->extrairValorMonetario((string) ($rows[3]['D'] ?? '')); $linha = MobiangraLinha::updateOrCreate( ['codigo' => $codigo], [ 'nome' => $nome, 'nome_completo' => $nomeCompleto, 'tarifa' => $tarifaNormal, 'tarifa_cidadao' => $tarifaCidadao, 'publicada' => true, 'ordem' => 0, ] ); // Limpar seções antigas e recriar $linha->secoes()->delete(); $linha->legendas()->delete(); $linha->integracoes()->delete(); $this->parsearSecoes($linha, $sheet, $rows); $this->parsearLegendasEIntegracoes($linha, $rows); $this->removerSecoesVazias($linha); } /** * Percorre as linhas e identifica blocos de seções com seus horários. */ private function parsearSecoes(MobiangraLinha $linha, Worksheet $sheet, array $rows): void { $ordemSecao = 0; $secaoAtual = null; $periodoAtual = null; // 'seg_sex' | 'sabado' | 'dom_feriado' $buffers = []; // periodo => [horarios] $labelsBuffer = []; // periodo => label // Mapeamento de colunas → período // Padrão: cols A-D = Seg-Sex, F-I = Sábado, K-N = Dom/Feriado // Para linhas com apenas 1 coluna por lado (105), cols A, C, E = Seg-Sex $colsPeriodo = $this->detectarColunasPeriodo($rows); $emIntegracao = false; foreach ($rows as $i => $row) { $colA = trim((string) ($row['A'] ?? '')); if (str_contains($colA, 'ESTA LINHA FAZ INTEGRAÇÃO')) { $emIntegracao = true; if ($secaoAtual !== null && ! empty($buffers)) { $this->salvarBufferGrades($secaoAtual, $buffers, $labelsBuffer); $buffers = []; $labelsBuffer = []; } continue; } if ($emIntegracao) { continue; } // Linha de título de seção: texto em col A, não é horário, não é tarifa, não é integração if ( $colA !== '' && ! $this->ehHorario($colA) && ! str_starts_with($colA, 'Tarifa') && ! str_starts_with($colA, 'Integração') && ! str_starts_with($colA, 'Como usar') && ! str_starts_with($colA, '1.') && ! str_starts_with($colA, '2.') && ! str_starts_with($colA, '3.') && ! str_starts_with($colA, '4.') && ! str_starts_with($colA, 'Horário em') && $i > 1 ) { // Detectar cabeçalho de período if ($this->ehCabecalhoPeriodo($row, $colsPeriodo)) { $labelsBuffer = $this->extrairLabelsPeriodo($row, $colsPeriodo); continue; } // Subtítulo de direção após variante (ex: B220 → SAÍDA DO CENTRO) if ($this->ehSubtituloDirecao($colA) && $secaoAtual !== null && empty($buffers)) { continue; } // Se for texto que identifica nova seção (origem) if ($this->ehTituloSecao($colA, $row)) { if ($secaoAtual !== null && ! empty($buffers)) { $this->salvarBufferGrades($secaoAtual, $buffers, $labelsBuffer); $buffers = []; $labelsBuffer = []; } $secaoAtual = $linha->secoes()->create([ 'titulo' => $colA, 'ordem' => $ordemSecao++, ]); continue; } } // Linha de horários if ($secaoAtual !== null && $this->linhaTemHorarios($row)) { $this->acumularHorarios($sheet, $i, $colsPeriodo, $buffers); } } // Salvar último buffer if ($secaoAtual !== null && ! empty($buffers)) { $this->salvarBufferGrades($secaoAtual, $buffers, $labelsBuffer); } } private function salvarBufferGrades(MobiangraHorarioSecao $secao, array $buffers, array $labelsBuffer): void { $ordemPeriodo = 0; foreach ($buffers as $periodo => $horarios) { if (empty($horarios)) { continue; } $label = $labelsBuffer[$periodo] ?? MobiangraHorarioGrade::PERIODOS[$periodo] ?? $periodo; $secao->grades()->create([ 'periodo' => $periodo, 'label' => $label, 'horarios' => $this->filtrarHorariosGrade($horarios), 'ordem' => $ordemPeriodo++, ]); } } /** * Detecta quais colunas correspondem a cada período na planilha. * Padrão: seg_sex → A-D, sabado → F-I, dom_feriado → K-N * Alternativo (somente Seg-Sex em poucas colunas): A,C,E */ private function detectarColunasPeriodo(array $rows): array { foreach ($rows as $row) { $colA = strtoupper(trim((string) ($row['A'] ?? ''))); $colF = strtoupper(trim((string) ($row['F'] ?? ''))); $colI = strtoupper(trim((string) ($row['I'] ?? ''))); $colK = strtoupper(trim((string) ($row['K'] ?? ''))); if (str_contains($colA, 'SEGUNDA') && str_contains($colF, 'SÁBADO')) { return [ 'seg_sex' => ['A', 'B', 'C', 'D'], 'sabado' => ['F', 'G', 'H', 'I'], 'dom_feriado' => ['K', 'L', 'M', 'N'], ]; } if (str_contains($colA, 'SEGUNDA') && str_contains($colI, 'SEGUNDA')) { // Duas origens lado a lado return [ 'seg_sex' => ['A', 'C', 'E'], 'sabado' => [], 'dom_feriado' => [], ]; } if (str_contains($colA, 'SEGUNDA') && ! str_contains($colF, 'SÁBADO')) { return [ 'seg_sex' => ['A', 'B', 'C', 'D'], 'sabado' => [], 'dom_feriado' => [], ]; } } return [ 'seg_sex' => ['A', 'B', 'C', 'D'], 'sabado' => ['F', 'G', 'H', 'I'], 'dom_feriado' => ['K', 'L', 'M', 'N'], ]; } private function ehCabecalhoPeriodo(array $row, array $colsPeriodo): bool { $colA = strtoupper(trim((string) ($row['A'] ?? ''))); $colF = strtoupper(trim((string) ($row['F'] ?? ''))); $colI = strtoupper(trim((string) ($row['I'] ?? ''))); $colK = strtoupper(trim((string) ($row['K'] ?? ''))); return str_contains($colA, 'SEGUNDA') || str_contains($colF, 'SÁBADO') || str_contains($colK, 'DOMINGO'); } private function extrairLabelsPeriodo(array $row, array $colsPeriodo): array { $labels = []; $colA = trim((string) ($row['A'] ?? '')); $colF = trim((string) ($row['F'] ?? '')); $colK = trim((string) ($row['K'] ?? '')); if ($colA) { $labels['seg_sex'] = $colA; } if ($colF) { $labels['sabado'] = $colF; } if ($colK) { $labels['dom_feriado'] = $colK; } return $labels; } private function ehTituloSecao(string $texto, array $row): bool { foreach ($row as $val) { $valor = trim((string) ($val ?? '')); if ($valor === '↔' || str_contains($valor, '↔')) { return false; } } foreach ($row as $val) { $valor = (string) ($val ?? ''); if (str_contains($valor, 'Tempo de Integração') || str_contains($valor, 'Integração é quando')) { return false; } } $colB = trim((string) ($row['B'] ?? '')); // Linhas de integração costumam ter apenas o código na coluna A e destino na B if ($colB !== '' && preg_match('/^[A-Z]?\d+[A-Z]?$/u', $texto)) { return false; } return mb_strlen($texto) > 2 && ! $this->ehHorario($texto) && ! preg_match('/^\d+\.\s/', $texto) && ! str_starts_with($texto, 'Horário em') && ! str_starts_with($texto, 'Integração') && ! str_starts_with($texto, 'Passageiro Cidadão') && ! str_starts_with($texto, 'Tempo de Integração'); } private function ehSubtituloDirecao(string $texto): bool { return (bool) preg_match('/^SAÍDA\s+(DO|DA|DÁ|DE)\s+/iu', $texto); } private function removerSecoesVazias(MobiangraLinha $linha): void { $linha->secoes()->whereDoesntHave('grades')->delete(); } private function linhaTemHorarios(array $row): bool { foreach ($row as $val) { if ($this->ehHorario((string) ($val ?? ''))) { return true; } } return false; } private function acumularHorarios(Worksheet $sheet, int $rowIndex, array $colsPeriodo, array &$buffers): void { foreach ($colsPeriodo as $periodo => $colunas) { if (empty($colunas)) { continue; } foreach ($colunas as $col) { $cell = $sheet->getCell($col.$rowIndex); $val = trim((string) $cell->getFormattedValue()); if (! $this->ehHorario($val)) { continue; } $buffers[$periodo][] = [ 'h' => $this->formatarHora($val), 'c' => $this->corDaCelula($cell), ]; } } } private function formatarHora(string $hora): string { return MobiangraHorarioGrade::formatarHora($hora); } private function corDaCelula(Cell $cell): ?string { $rgb = strtoupper($cell->getStyle()->getFont()->getColor()->getRGB()); return match ($rgb) { '0000FF', '0070C0', '0071C1', '0033CC' => 'azul', 'FF0000', 'C00000', '990000' => 'vermelho', '00B050', '008000', '548235', '00FF00' => 'verde', 'FF6600', 'ED7D31', 'FFC000', 'E36C09' => 'laranja', 'FFFF00', 'FFD966' => 'amarelo', default => null, }; } private function parsearLegendasEIntegracoes(MobiangraLinha $linha, array $rows): void { $ordemLegenda = 0; $ordemIntegracao = 0; $emIntegracao = false; $integracaoAtual = null; foreach ($rows as $i => $row) { $colA = trim((string) ($row['A'] ?? '')); $colB = trim((string) ($row['B'] ?? '')); $colD = trim((string) ($row['D'] ?? '')); // Legendas if (preg_match('/^Hor[aá]rio em (\w+):\s*(.+)$/u', $colA, $m)) { $linha->legendas()->create([ 'cor' => mb_strtolower($m[1]), 'descricao' => trim($m[2]), 'ordem' => $ordemLegenda++, ]); continue; } // Início das integrações if (str_contains($colA, 'ESTA LINHA FAZ INTEGRAÇÃO')) { $emIntegracao = true; continue; } if ($emIntegracao && str_contains($colB, 'Tempo de Integração')) { // Salvar integração anterior if ($integracaoAtual !== null) { $tempo = (int) preg_replace('/\D/', '', $colB); $linha->integracoes()->create([ 'linhas_envolvidas' => $integracaoAtual, 'tempo_minutos' => $tempo ?: 120, 'ordem' => $ordemIntegracao++, ]); $integracaoAtual = null; } continue; } if ($emIntegracao && $colA !== '' && ! str_starts_with($colA, 'Como usar') && ! str_starts_with($colA, 'Integração é')) { // Linha de integração: A=codigo1, B=nome1, C=↔, D=codigo2, E=nome2 [, G=↔, H=cod3, I=nome3] $colC = trim((string) ($row['C'] ?? '')); $colE = trim((string) ($row['E'] ?? '')); $colG = trim((string) ($row['G'] ?? '')); $colH = trim((string) ($row['H'] ?? '')); $colI = trim((string) ($row['I'] ?? '')); if ($colC === '↔' || $colD !== '') { $envolvidas = [ ['codigo' => $colA, 'nome' => $colB], ['codigo' => $colD, 'nome' => $colE], ]; if ($colG === '↔' && $colH !== '') { $envolvidas[] = ['codigo' => $colH, 'nome' => $colI]; } $integracaoAtual = $envolvidas; } } } } private function ehHorario(string $val): bool { return (bool) preg_match('/^\d{1,2}:\d{2}$/', trim($val)); } private function extrairValorMonetario(string $texto): ?float { if (preg_match('/R$\s*([\d,.]+)/u', $texto, $m)) { return (float) str_replace(',', '.', str_replace('.', '', $m[1])); } return null; } private function sanitizarDadosLinha(array $dados): array { return [ 'codigo' => strtoupper(trim($dados['codigo'] ?? '')), 'nome' => trim($dados['nome'] ?? ''), 'nome_completo' => trim($dados['nome_completo'] ?? ''), 'tarifa' => $dados['tarifa'] ? (float) str_replace(',', '.', $dados['tarifa']) : null, 'tarifa_cidadao' => $dados['tarifa_cidadao'] ? (float) str_replace(',', '.', $dados['tarifa_cidadao']) : null, 'ordem' => (int) ($dados['ordem'] ?? 0), 'publicada' => (bool) ($dados['publicada'] ?? true), ]; } /** * Decodifica o payload JSON enviado pelo pintor de cores do admin. * * @return list<array<string, mixed>> */ public function decodificarHorariosCores(mixed $raw): array { if (is_array($raw)) { return $raw; } if (! is_string($raw) || trim($raw) === '') { return []; } $decoded = json_decode(trim($raw), true); return is_array($decoded) ? $decoded : []; } /** * Normaliza itens {h,c} vindos do pintor (fonte primária no salvamento por card). * * @param list<array<string, mixed>|string> $itensComCor * @param list<string> $coresPermitidas * @return list<array{h: string, c: string|null}> */ public function normalizarHorariosItensPintor(array $itensComCor, array $coresPermitidas = []): array { $result = []; foreach ($itensComCor as $item) { if (! is_array($item)) { continue; } $norm = MobiangraHorarioGrade::normalizarHorarioItem($item); if ($norm['h'] === '' || ! $this->ehHorario($norm['h'])) { continue; } if ($norm['c'] !== null && ! in_array($norm['c'], $coresPermitidas, true)) { $norm['c'] = null; } $result[] = $norm; } return $result; } /** * @param list<array<string, mixed>|string> $horarios * @return list<array{h: string, c: string|null}> */ private function filtrarHorariosGrade(array $horarios): array { $result = []; foreach ($horarios as $item) { $norm = MobiangraHorarioGrade::normalizarHorarioItem($item); if ($norm['h'] === '' || ! $this->ehHorario($norm['h'])) { continue; } $result[] = $norm; } return $result; } /** Normaliza array de horários: aceita string separada por vírgula/quebra ou objetos {h,c} */ public function normalizarHorarios(array|string $horarios): array { if (is_string($horarios)) { $horarios = preg_split('/[\s,;]+/', $horarios); } $result = []; foreach ($horarios as $item) { $norm = MobiangraHorarioGrade::normalizarHorarioItem($item); if ($norm['h'] !== '' && $this->ehHorario($norm['h'])) { $result[] = $norm; } } return $result; } /** * Combina horários digitados em texto com cores enviadas pelo pintor do admin. * * @param list<string> $coresPermitidas * @return list<array{h: string, c: string|null}> */ public function normalizarHorariosComCores( string $horariosTexto, array $itensComCor, array $coresPermitidas = [] ): array { $horarios = $this->normalizarHorarios($horariosTexto); if (empty($horarios)) { return []; } $mapaCores = []; foreach ($itensComCor as $item) { if (! is_array($item)) { continue; } $norm = MobiangraHorarioGrade::normalizarHorarioItem($item); if ($norm['h'] === '') { continue; } if ($norm['c'] !== null && ! in_array($norm['c'], $coresPermitidas, true)) { $norm['c'] = null; } $mapaCores[$norm['h']] = $norm['c']; } return array_map(function (array $item) use ($mapaCores) { return [ 'h' => $item['h'], 'c' => $mapaCores[$item['h']] ?? $item['c'] ?? null, ]; }, $horarios); } // ------------------------------------------------------------------------- // Manifestações // ------------------------------------------------------------------------- /** * Registra manifestação do cidadão, abre chamado vinculado e notifica setor e solicitante. * * @param array<string, mixed> $dados */ public function registrar(array $dados, ?UploadedFile $imagem, ?string $ipOrigem, User $user): MobiangraManifestacao { return DB::transaction(function () use ($dados, $imagem, $ipOrigem, $user) { $numeroChamado = $this->gerarNumeroChamadoMobiangra(); $assunto = Str::limit(trim((string) ($dados['relato'] ?? '')), 120, '…'); if ($assunto === '') { $assunto = __('Manifestação MobiAngra'); } $chamado = Chamado::create([ 'user_id' => $user->id, 'convenio' => MobiangraChamadoConvenio::valor(), 'convenioAtual' => MobiangraChamadoConvenio::valor(), 'numeroChamado' => $numeroChamado, 'assunto' => $assunto, 'statusChamado' => 'Aberto', ]); $manifestacao = MobiangraManifestacao::create([ 'user_id' => $user->id, 'chamado_id' => $chamado->id, 'identificacao' => $user->name, 'email' => $user->email, 'linha' => $dados['linha'] ?? null, 'sentido' => $dados['sentido'] ?? null, 'horario' => $dados['horario'] ?? null, 'local' => $dados['local'] ?? null, 'numero_veiculo' => $dados['numero_veiculo'] ?? null, 'relato' => $dados['relato'], 'imagem_path' => null, 'ip_origem' => $ipOrigem, 'lgpd_consentimento' => true, ]); if ($imagem && $imagem->isValid()) { $path = \App\Support\MobiangraManifestacaoImageProcessor::storeOptimized( $imagem, 'mobiangra/manifestacoes' ); if (! $path) { throw new RuntimeException(__('Não foi possível processar a imagem enviada. Verifique o formato (JPG, PNG ou WEBP) e tente novamente.')); } $manifestacao->update(['imagem_path' => $path]); } Mensagem::create([ 'chamado_id' => $chamado->id, 'owner_id' => $user->id, 'user_id' => $user->id, 'mensagem' => $this->montarTextoSolicitacaoManifestacao($manifestacao->fresh()), 'solicitacao' => true, ]); $manifestacao = $manifestacao->fresh(['chamado', 'user']); $destino = config('mobiangra.manifestacao_email_destino'); if ($destino) { try { Mail::to($destino)->send(new MobiangraManifestacaoRecebidaMail($manifestacao)); } catch (\Throwable $e) { Log::error('[MobiAngra] Falha ao enviar e-mail de manifestação', [ 'manifestacao_id' => $manifestacao->id, 'message' => $e->getMessage(), ]); } } if ($user->email) { try { Mail::to($user->email)->send(new MobiangraManifestacaoProtocoloMail($manifestacao)); } catch (\Throwable $e) { Log::error('[MobiAngra] Falha ao enviar e-mail de protocolo ao cidadão', [ 'manifestacao_id' => $manifestacao->id, 'message' => $e->getMessage(), ]); } } return $manifestacao; }); } public function gerarNumeroChamadoMobiangra(): string { $prefix = 'MA-' . now()->format('Y') . '-'; $ultimo = Chamado::query() ->where('convenioAtual', MobiangraChamadoConvenio::valor()) ->where('numeroChamado', 'like', $prefix . '%') ->orderByDesc('numeroChamado') ->value('numeroChamado'); $sequencia = 1; if (is_string($ultimo) && str_starts_with($ultimo, $prefix)) { $sequencia = ((int) substr($ultimo, strlen($prefix))) + 1; } return $prefix . str_pad((string) $sequencia, 6, '0', STR_PAD_LEFT); } public function montarTextoSolicitacaoManifestacao(MobiangraManifestacao $manifestacao): string { $linhas = [ '<p><strong>' . e(__('Relato')) . ':</strong></p>', '<p>' . nl2br(e($manifestacao->relato)) . '</p>', '<hr>', '<p><strong>' . e(__('Linha')) . ':</strong> ' . e($manifestacao->linha ?: '-') . '</p>', '<p><strong>' . e(__('Sentido')) . ':</strong> ' . e($this->rotuloSentido($manifestacao->sentido)) . '</p>', '<p><strong>' . e(__('Horário')) . ':</strong> ' . e($manifestacao->horario ?: '-') . '</p>', '<p><strong>' . e(__('Local')) . ':</strong> ' . e($manifestacao->local ?: '-') . '</p>', '<p><strong>' . e(__('Número do veículo')) . ':</strong> ' . e($manifestacao->numero_veiculo ?: '-') . '</p>', ]; if ($manifestacao->imagem_path) { $linhas[] = '<p><strong>' . e(__('Imagem anexada')) . ':</strong> ' . e(__('Disponível no painel administrativo do MobiAngra.')) . '</p>'; } return implode("\n", $linhas); } public function listarAdmin(int $porPagina = 20): LengthAwarePaginator { return MobiangraManifestacao::query() ->with(['chamado', 'user']) ->latest('created_at') ->paginate($porPagina) ->withQueryString(); } public function rotuloSentido(?string $sentido): string { return match ($sentido) { 'ida' => __('Ida'), 'volta' => __('Volta'), 'nao_informado' => __('Não informado'), default => $sentido ?? '-', }; } /** * Retorna o ID da structure fonte das notícias exibidas no portal MobiAngra. */ public function getNewsStructureId(): ?string { $structureId = config('mobiangra.structure_id'); if (! $structureId) { return null; } $structure = Structure::query() ->where('id', $structureId) ->where('status', 'ATIVADO') ->first(['id', 'news_structure_id']); if (! $structure) { return null; } return $structure->news_structure_id ?: $structure->id; } /** * Retorna o órgão (Structure) fonte das notícias exibidas no portal MobiAngra. */ public function getNewsStructure(): ?Structure { $newsSourceId = $this->getNewsStructureId(); if (! $newsSourceId) { return null; } return Structure::query() ->where('id', $newsSourceId) ->where('status', 'ATIVADO') ->first(['id', 'name', 'abbreviation', 'slug']); } /** * @return Collection<int, Noticia> */ public function listarNoticiasPublicas(int $limite = 3): Collection { $newsSourceId = $this->getNewsStructureId(); if (! $newsSourceId) { return new Collection(); } return Noticia::query() ->where('status', 'ATIVADO') ->whereHas('relacionadas', static function ($query) use ($newsSourceId) { $query->where('structures.id', $newsSourceId); }) ->orderByDesc('created_at') ->limit($limite) ->get(); } /** * @return Collection<int, MobiangraAlerta> */ public function listarAlertasPublicos(int $limite = 3): Collection { return MobiangraAlerta::query() ->visivelNoPortal() ->orderByDesc('publicado_em') ->limit($limite) ->get(); } public function listarAlertasPublicosPaginado(int $porPagina = 15): LengthAwarePaginator { return MobiangraAlerta::query() ->visivelNoPortal() ->orderByDesc('publicado_em') ->paginate($porPagina) ->withQueryString(); } public function listarAlertasAdmin(int $porPagina = 20): LengthAwarePaginator { return MobiangraAlerta::query() ->orderByDesc('publicado_em') ->paginate($porPagina) ->withQueryString(); } public function criarAlerta(array $dados): MobiangraAlerta { return DB::transaction(function () use ($dados): MobiangraAlerta { return MobiangraAlerta::create([ 'categoria' => $dados['categoria'], 'titulo' => $dados['titulo'], 'mensagem' => $dados['mensagem'], 'publicado_em' => $dados['publicado_em'], 'exibir_ate' => $dados['exibir_ate'] ?? null, 'publicado' => filter_var($dados['publicado'] ?? true, FILTER_VALIDATE_BOOLEAN), ]); }); } public function atualizarAlerta(MobiangraAlerta $alerta, array $dados): MobiangraAlerta { return DB::transaction(function () use ($alerta, $dados): MobiangraAlerta { $alerta->update([ 'categoria' => $dados['categoria'], 'titulo' => $dados['titulo'], 'mensagem' => $dados['mensagem'], 'publicado_em' => $dados['publicado_em'], 'exibir_ate' => $dados['exibir_ate'] ?? null, 'publicado' => filter_var($dados['publicado'] ?? $alerta->publicado, FILTER_VALIDATE_BOOLEAN), ]); return $alerta->fresh(); }); } public function excluirAlerta(MobiangraAlerta $alerta): void { DB::transaction(function () use ($alerta): void { $alerta->delete(); }); } public function alternarStatusAlerta(MobiangraAlerta $alerta): MobiangraAlerta { $alerta->update(['publicado' => ! $alerta->publicado]); return $alerta->fresh(); } /** * Resolve o route_id GTFS a partir do código curto da linha (ex.: "020"). */ public function resolverRouteIdPorCodigo(string $codigo): ?string { $codigo = trim($codigo); if ($codigo === '') { return null; } foreach ($this->getRouteNames() as $routeId => $name) { $partes = explode(' ', (string) $name, 2); if (($partes[0] ?? '') === $codigo) { return (string) $routeId; } } return null; } /** * Paradas ordenadas de um sentido da linha (GTFS). * * @return list<array{sequencia: int, stop_id: string, nome: string, lat: float|null, lon: float|null}> */ public function obterParadasItinerario(string $routeId, int $directionId = 0): array { $cacheKey = 'mobiangra.itinerario.' . $routeId . '.' . $directionId; return Cache::remember($cacheKey, 3600, function () use ($routeId, $directionId) { $trips = $this->readGtfsCsv('trips.txt'); $stopTimes = $this->readGtfsCsv('stop_times.txt'); $stopsRaw = $this->readGtfsCsv('stops.txt'); $stopsById = []; foreach ($stopsRaw as $stop) { $id = $stop['stop_id'] ?? ''; if ($id !== '') { $stopsById[$id] = $stop; } } $tripId = null; foreach ($trips as $trip) { if (($trip['route_id'] ?? '') === $routeId && (int) ($trip['direction_id'] ?? 0) === $directionId) { $tripId = $trip['trip_id'] ?? ''; break; } } if ($tripId === null || $tripId === '') { return []; } $paradas = []; foreach ($stopTimes as $st) { if (($st['trip_id'] ?? '') !== $tripId) { continue; } $stopId = $st['stop_id'] ?? ''; $stop = $stopsById[$stopId] ?? null; $paradas[] = [ 'sequencia' => (int) ($st['stop_sequence'] ?? 0), 'stop_id' => $stopId, 'nome' => $stop['stop_name'] ?? $stopId, 'lat' => isset($stop['stop_lat']) ? (float) $stop['stop_lat'] : null, 'lon' => isset($stop['stop_lon']) ? (float) $stop['stop_lon'] : null, ]; } usort($paradas, fn (array $a, array $b): int => $a['sequencia'] <=> $b['sequencia']); return $paradas; }); } /** * Monta dados de itinerário e metadados para exibição pública da linha. * * @return array{ * route_id: string|null, * sentidos: list<array{direction_id: int, label: string, paradas: list<array<string, mixed>>}> * } */ public function montarItinerarioLinha(MobiangraLinha $linha): array { $routeId = $this->resolverRouteIdPorCodigo($linha->codigo); $headsigns = $this->getDirectionHeadsigns(); $sentidos = []; foreach ([0, 1] as $directionId) { if ($routeId === null) { break; } $paradas = $this->obterParadasItinerario($routeId, $directionId); if ($paradas === []) { continue; } $label = $headsigns[$routeId][$directionId] ?? $headsigns[$routeId][(string) $directionId] ?? ($directionId === 0 ? __('Ida') : __('Volta')); $sentidos[] = [ 'direction_id' => $directionId, 'label' => $label, 'paradas' => $paradas, ]; } return [ 'route_id' => $routeId, 'sentidos' => $sentidos, ]; } }
| ver. 1.4 |
Github
|
.
| PHP 8.3.28 | Generation time: 0.14 |
proxy
|
phpinfo
|
Settings