File manager - Edit - /var/www/html/portalHomolog/app/Http/Controllers/Mobiangra/MobiangraController.php
Back
<?php namespace App\Http\Controllers\Mobiangra; use App\Helpers\MobiangraBuscaLinhasHelper; use App\Http\Controllers\Controller; use App\Http\Requests\Mobiangra\MobiangraAlertaRequest; use App\Http\Requests\Mobiangra\MobiangraManifestacaoMensagemRequest; use App\Http\Requests\Mobiangra\MobiangraManifestacaoRequest; use App\Http\Resources\Mobiangra\MobiangraResource; use App\Models\Html; use App\Models\Link; use App\Models\Mobiangra\MobiangraAlerta; use App\Models\Mobiangra\MobiangraHorarioGrade; use App\Models\Mobiangra\MobiangraHorarioSecao; use App\Models\Mobiangra\MobiangraLinha; use App\Models\Mobiangra\MobiangraManifestacao; use App\Models\Phone; use App\Models\Structure; use App\Http\Services\Accounts\Chamados\ChamadoArquivoService; use App\Http\Services\Accounts\Chamados\ChamadoService; use App\Mail\Mobiangra\MobiangraManifestacaoRespostaMail; use Illuminate\Http\JsonResponse; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Artisan; use Illuminate\Support\Facades\Cache; use Illuminate\Support\Facades\DB; use App\Services\Mobiangra\MobiangraService; use Illuminate\Support\Facades\Auth; use Illuminate\Support\Facades\Log; use Illuminate\Support\Facades\Mail; use Illuminate\Support\Facades\Storage; use RuntimeException; use Illuminate\View\View; use Symfony\Component\HttpFoundation\BinaryFileResponse; class MobiangraController extends Controller { /** * @var string */ private string $permissao = 'GERENCIADOR_DE_MOBIANGRA'; public function __construct( private readonly MobiangraService $service, private readonly MobiangraResource $resource, ) { } /** @var list<string> */ private const ADMIN_TABS = ['mapa', 'horarios', 'manifestacoes', 'alertas']; private function mobiangraAdminTab(Request $request, string $default = 'mapa'): string { $tab = (string) $request->input('tab', $default); return in_array($tab, self::ADMIN_TABS, true) ? $tab : $default; } /** * @return array<string, mixed> */ private function mobiangraAdminQuery(Request $request, ?string $defaultTab = null): array { $query = [ 'tab' => $this->mobiangraAdminTab($request, $defaultTab ?? 'mapa'), ]; if ($request->filled('page')) { $query['page'] = (int) $request->input('page'); } return $query; } private function redirectMobiangraAdmin( Request $request, ?string $defaultTab = null, string $flashKey = 'success', ?string $message = null ): RedirectResponse { $redirect = redirect()->route('admin.mobiangra.index', $this->mobiangraAdminQuery($request, $defaultTab)); return $message !== null ? $redirect->with($flashKey, $message) : $redirect; } private function redirectMobiangraLinhaEdit( MobiangraLinha $linha, string $message, string $flashKey = 'success' ): RedirectResponse { return redirect() ->route('admin.mobiangra.horarios.linhas.edit', $linha) ->with($flashKey, $message); } /** * Retorna o órgão (Structure) responsável pelo MobiAngra. * Facilita a troca do ID para produção via config ou .env. */ private function getStructure(): ?Structure { $structureId = config('mobiangra.structure_id'); if (! $structureId) { return null; } return Structure::query() ->where('id', $structureId) ->where('status', 'ATIVADO') ->first(); } /** * Dados do menu lateral e rodapé do portal (links, serviços, telefones). * * @return array{secretaria: Structure|null, links: \Illuminate\Support\Collection, telefones: array, htmls: \Illuminate\Support\Collection} */ private function getPortalSidebarData(): array { $secretaria = $this->getStructure(); if (! $secretaria) { return [ 'secretaria' => null, 'links' => collect(), 'telefones' => [], 'htmls' => collect(), ]; } $links = Link::query() ->where('structure_id', $secretaria->id) ->orderBy('order', 'asc') ->get(); $htmls = Html::query() ->where('structure_id', $secretaria->id) ->where('visibility', 'ATIVADO') ->orderBy('created_at', 'asc') ->get(); $telefonesRaw = Phone::query() ->where('structure_id', $secretaria->id) ->orderBy('created_at', 'asc') ->get(); $telefones = []; foreach ($telefonesRaw as $telefone) { $tels = array_map('trim', explode(',', $telefone->description ?? '')); $numeros = []; foreach ($tels as $tel) { if ($tel === '') { continue; } $numeros[] = [ 'numero' => $tel, 'link' => '+55' . str_replace(['+55', '-', ' ', ')', '('], '', $tel), ]; } $telefones[] = [ 'telefone' => $telefone->titulo, 'numeros' => $numeros, ]; } return compact('secretaria', 'links', 'telefones', 'htmls'); } /** * Mescla dados do sidebar do portal com variáveis específicas da view. * * @param array<string, mixed> $extra * @return array<string, mixed> */ private function portalViewData(array $extra = []): array { return array_merge($this->getPortalSidebarData(), $extra); } /** * Landing page pública do MobiAngra em /mobiangra. */ public function index(): View { return view('mobiangra.index', $this->portalViewData([ 'alertasDestaque' => $this->service->listarAlertasPublicos(3), 'noticiasDestaque' => $this->service->listarNoticiasPublicas(3), 'noticiasStructure' => $this->service->getNewsStructure(), 'linhas' => $this->service->listarLinhas(apenasPublicadas: true), ])); } /** * Listagem pública de alertas e avisos. */ public function alertas(Request $request): View { return view('mobiangra.alertas', $this->portalViewData([ 'alertas' => $this->service->listarAlertasPublicosPaginado(15), ])); } /** * Consulta de itinerários por linha em /mobiangra/itinerarios. */ public function itinerarios(): View { $linhas = $this->service->listarLinhas(apenasPublicadas: true); $linhaAtivaIdx = MobiangraBuscaLinhasHelper::indicePorTermo( $linhas, request()->query('linha') ); $linhaAtiva = $linhaAtivaIdx !== null ? $linhas[$linhaAtivaIdx] : null; $itinerario = $linhaAtiva ? $this->service->montarItinerarioLinha($linhaAtiva) : null; $lineColors = $this->service->getLineColors(); $corLinha = $linhaAtiva ? ($lineColors[$linhaAtiva->codigo]['bg'] ?? '#21409a') : '#21409a'; return view('mobiangra.itinerarios', $this->portalViewData([ 'linhas' => $linhas, 'linhaAtivaIdx' => $linhaAtivaIdx, 'linhaAtiva' => $linhaAtiva, 'itinerario' => $itinerario, 'corLinha' => $corLinha, ])); } /** * Subpágina de horários em /mobiangra/horarios. */ public function horarios(): View { $linhas = $this->service->listarLinhas(apenasPublicadas: true); $linhaAtivaIdx = MobiangraBuscaLinhasHelper::indicePorTermo( $linhas, request()->query('linha') ); return view('mobiangra.horarios', $this->portalViewData([ 'linhas' => $linhas, 'linhaAtivaIdx' => $linhaAtivaIdx, ])); } /** * Subpágina "Passageiro Cidadão" em /mobiangra/passageiro-cidadao. */ public function passageiroCidadao(): View { return view('mobiangra.passageiro_cidadao', $this->portalViewData()); } /** * Subpágina "Informações ao Usuário" em /mobiangra/informacoes. */ public function informacoes(): View { return view('mobiangra.informacoes', $this->portalViewData()); } /** * Subpágina "Fale Conosco" em /mobiangra/fale-conosco. */ public function faleConosco(): View { $linhas = $this->service->listarLinhas(apenasPublicadas: true); return view('mobiangra.fale_conosco', $this->portalViewData([ 'linhas' => $linhas, 'relatoMax' => (int) config('mobiangra.manifestacao_relato_max', 2000), 'usuarioLogado' => Auth::user(), ])); } /** * Recebe manifestação do formulário Fale Conosco. */ public function faleConoscoStore(MobiangraManifestacaoRequest $request): RedirectResponse { if (config('recaptcha.enabled') && ! $this->recaptcha($request->input('g-recaptcha-response'))) { return redirect() ->route('mobiangra.fale_conosco') ->withInput() ->with('error', __('Verificação de segurança inválida. Tente novamente.')); } try { $manifestacao = $this->service->registrar( $request->safe()->except(['imagem', 'g-recaptcha-response', 'lgpd_consentimento']), $request->file('imagem'), $request->ip(), $request->user() ); } catch (RuntimeException $e) { return redirect() ->route('mobiangra.fale_conosco') ->withInput() ->with('error', $e->getMessage()); } catch (\Throwable $e) { Log::error('[MobiAngra] Erro ao registrar manifestação', [ 'message' => $e->getMessage(), ]); return redirect() ->route('mobiangra.fale_conosco') ->withInput() ->with('error', __('Não foi possível enviar sua manifestação. Tente novamente em instantes.')); } return redirect() ->route('mobiangra.fale_conosco') ->with('mobiangra_manifestacao_protocolo', [ 'numero' => $manifestacao->chamado?->numeroChamado, 'chamado_id' => $manifestacao->chamado?->id, ]); } /** * Mapa resumido para embed na home (iframe leve, sem UI do mapa completo). */ public function mapaEmbed(): View { try { $this->service->ensureFleetConfigExists(); } catch (\Throwable $e) { Log::error('[MobiAngra] mapa/embed: não foi possível garantir fleet.json', [ 'message' => $e->getMessage(), ]); } $fetchInterval = $this->service->getFetchInterval(); return view('mobiangra.mapa_embed', [ 'routeNames' => $this->service->getRouteNames(), 'lineColors' => $this->service->getLineColors(), 'vehicleColors' => $this->service->getVehicleColors(), 'microVehicles' => $this->service->getMicroVehicles(), 'mapDefaults' => $this->service->getMapDefaults(), 'previewRefreshMs' => max($fetchInterval * 3, 30) * 1000, ]); } /** * Exibe o mapa público do MobiAngra em /mobiangra/mapa. */ public function mapa(): View { try { $this->service->ensureFleetConfigExists(); } catch (\Throwable $e) { Log::error('[MobiAngra] mapa: não foi possível garantir fleet.json', [ 'message' => $e->getMessage(), ]); } $routeNames = $this->service->getRouteNames(); $lineColors = $this->service->getLineColors(); $vehicleColors = $this->service->getVehicleColors(); $microVehicles = $this->service->getMicroVehicles(); $mapDefaults = $this->service->getMapDefaults(); $fetchInterval = $this->service->getFetchInterval(); $departures = $this->buildDepartures(); return view('mobiangra.mapa', compact( 'routeNames', 'lineColors', 'vehicleColors', 'microVehicles', 'mapDefaults', 'fetchInterval', 'departures', )); } /** * Partidas programadas (GTFS) para o mapa - carregadas via fetch para não estourar memória no HTML. */ public function mapaDeparturesJson(): JsonResponse { try { return $this->mapaJsonResponse($this->buildDepartures()); } catch (\Throwable $e) { Log::warning('[MobiAngra] mapa/departures indisponível', ['message' => $e->getMessage()]); return $this->mapaJsonResponse([]); } } /** * Mapa parada → linhas (GTFS) para o mapa - carregadas via fetch. */ public function mapaStopLinesJson(): JsonResponse { try { return $this->mapaJsonResponse($this->buildStopLines()); } catch (\Throwable $e) { Log::warning('[MobiAngra] mapa/stop-lines indisponível', ['message' => $e->getMessage()]); return $this->mapaJsonResponse([]); } } /** * Resposta JSON padrão dos endpoints do mapa (cache + CORS para fetch same-origin). * * @param array<string, mixed>|list<mixed> $data */ private function mapaJsonResponse(array $data): JsonResponse { return response() ->json($data, 200, [], JSON_INVALID_UTF8_SUBSTITUTE) ->header('Cache-Control', 'public, max-age=3600') ->header('Access-Control-Allow-Origin', '*'); } /** * Constrói a lista de horários de partida por rota/direção a partir do GTFS estático. * * Equivalente ao DATA.DEPARTURES do sistema anterior: para cada viagem (trip), * pega o horário de partida da primeira parada e agrupa por route_id + direction_id. * O resultado é ordenado por horário para facilitar a busca binária no JS. * * @return array Array de entradas { route_id, direction_id, departure_time }. */ private function buildDepartures(): array { return Cache::remember('mobiangra.departures', 3600, function () { try { $trips = $this->service->readGtfsCsv('trips.txt'); $stopTimes = $this->service->readGtfsCsv('stop_times.txt'); $tripMeta = []; foreach ($trips as $t) { $tripMeta[$t['trip_id'] ?? ''] = [ 'route_id' => $t['route_id'] ?? '', 'direction_id' => $t['direction_id'] ?? '0', ]; } $minStop = []; foreach ($stopTimes as $st) { $tid = $st['trip_id'] ?? ''; $seq = (int) ($st['stop_sequence'] ?? 9999); $dep = $st['departure_time'] ?? ''; if (!$dep || !isset($tripMeta[$tid])) { continue; } if (!isset($minStop[$tid]) || $seq < $minStop[$tid]['seq']) { $minStop[$tid] = ['seq' => $seq, 'time' => $dep]; } } $departures = []; $seen = []; foreach ($minStop as $tid => $data) { $meta = $tripMeta[$tid]; $key = $meta['route_id'] . '_' . $meta['direction_id'] . '_' . $data['time']; if (isset($seen[$key])) { continue; } $seen[$key] = true; $departures[] = [ 'route_id' => (string) $meta['route_id'], 'direction_id' => (string) ($meta['direction_id'] ?? '0'), 'time' => substr($data['time'], 0, 5), ]; } usort($departures, fn ($a, $b) => strcmp($a['time'], $b['time'])); return $departures; } catch (\Throwable $e) { return []; } }); } /** * Endpoint de dados em tempo real consumido pelo mapa (/live_data). * * Atualiza o feed automaticamente (lazy) caso os dados estejam stale, * eliminando a necessidade de scheduler ou terminal persistente. */ public function liveData(): JsonResponse { $this->refreshIfStale(); $path = $this->service->getLiveDataPath(); if (! Storage::exists($path)) { return response()->json(['timestamp' => time(), 'vehicles' => []]); } $raw = Storage::get($path); $json = json_decode($raw, true); if (! is_array($json) || ! isset($json['vehicles']) || ! is_array($json['vehicles'])) { return response()->json(['timestamp' => time(), 'vehicles' => []]); } return response()->json($json) ->header('Access-Control-Allow-Origin', '*'); } /** * Constrói o mapa stop_id → códigos de linha que atendem à parada. * * Equivalente ao mapeamento do sistema anterior: para cada parada, exibe * os códigos curtos (ex: "101", "T20") de todas as rotas que a servem. * * @return array { stop_id: ["101", "A208", ...], ... } */ private function buildStopLines(): array { return Cache::remember('mobiangra.stop_lines', 3600, function () { try { $routeNames = $this->service->getRouteNames(); $trips = $this->service->readGtfsCsv('trips.txt'); $stopTimes = $this->service->readGtfsCsv('stop_times.txt'); $tripToRoute = []; foreach ($trips as $t) { $tid = $t['trip_id'] ?? ''; $rid = $t['route_id'] ?? ''; if ($tid && $rid && isset($routeNames[$rid])) { $tripToRoute[$tid] = $rid; } } $stopLines = []; foreach ($stopTimes as $st) { $tid = $st['trip_id'] ?? ''; $sid = $st['stop_id'] ?? ''; if (!$tid || !$sid || !isset($tripToRoute[$tid])) { continue; } $rid = $tripToRoute[$tid]; $fullName = $routeNames[$rid]; $shortCode = explode(' ', $fullName)[0]; if (!isset($stopLines[$sid])) { $stopLines[$sid] = []; } if (!in_array($shortCode, $stopLines[$sid], true)) { $stopLines[$sid][] = $shortCode; } } foreach ($stopLines as &$codes) { sort($codes); } return $stopLines; } catch (\Throwable $e) { return []; } }); } /** * Verifica se o feed está desatualizado e, em caso positivo, regenera inline. * * O limiar é o próprio intervalo configurado, garantindo que o feed * seja regenerado assim que os dados ficarem desatualizados. * Um segundo de folga absorve variações normais de latência de rede. */ private function refreshIfStale(): void { $interval = $this->service->getFetchInterval(); $status = $this->service->getFeedStatus(); $lastUpdate = $status['last_update_timestamp'] ?? 0; if ((time() - $lastUpdate) >= $interval) { $this->resource->atualizarFeed(); } } /** * Serve o arquivo GTFS-RT Protobuf para consumo externo (Google Maps). * * Rota registrada em routes/gtfs.php (sem middleware web). Publica cópia * em public/mobiangra-assets/gtfs/ para entrega estática direta pelo Apache quando possível. */ public function feedPb(): BinaryFileResponse|JsonResponse { $absolutePath = $this->service->getProtobufAbsolutePath(); if (! file_exists($absolutePath)) { return response()->json( ['error' => __('Feed GTFS-RT não disponível. Aguarde a próxima atualização.')], 404 ); } $this->service->publishProtobufToPublic(); return $this->serveGtfsBinaryFile( $absolutePath, 'application/x-protobuf', (string) config('mobiangra.gtfs_rt_cache_control', 'no-cache'), (string) config('mobiangra.gtfs_rt_content_disposition', 'inline; filename=feed.pb') ); } /** * Disponibiliza o GTFS estático em formato .zip para integradores (ex.: Google Maps). */ public function gtfsZip(): BinaryFileResponse|JsonResponse { try { $path = $this->service->ensureGtfsZipArchive(); $this->service->publishGtfsZipToPublic($path); return $this->serveGtfsBinaryFile( $path, 'application/zip', 'public, max-age=3600', 'inline; filename=gtfs.zip' ); } catch (RuntimeException $e) { Log::warning('Mobiangra: GTFS .zip indisponível - ' . $e->getMessage()); return response()->json( ['error' => __('GTFS estático não disponível. Envie os arquivos GTFS no painel administrativo.')], 404 ); } } /** * Entrega binária de feed GTFS sem cookies de sessão (consumidores automatizados). */ private function serveGtfsBinaryFile( string $absolutePath, string $contentType, string $cacheControl, string $contentDisposition ): BinaryFileResponse { $response = response()->file($absolutePath, [ 'Content-Type' => $contentType, 'Content-Length' => (string) filesize($absolutePath), 'Cache-Control' => $cacheControl, 'Content-Disposition' => $contentDisposition, ]); $response->headers->remove('Set-Cookie'); foreach ([ 'Access-Control-Allow-Origin', 'Access-Control-Allow-Methods', 'Access-Control-Allow-Headers', 'Access-Control-Allow-Credentials', 'Access-Control-Expose-Headers', 'Access-Control-Max-Age', ] as $corsHeader) { $response->headers->remove($corsHeader); } return $response; } /** * Retorna as rotas GTFS estáticas como JSON (com cache). */ public function routesJson(): JsonResponse { $data = Cache::remember('mobiangra.routes', 3600, function () { return $this->service->readGtfsCsv('routes.txt'); }); return response()->json($data) ->header('Access-Control-Allow-Origin', '*'); } /** * Retorna as paradas GTFS estáticas como JSON (com cache). */ public function stopsJson(): JsonResponse { $data = Cache::remember('mobiangra.stops', 3600, function () { return $this->service->readGtfsCsv('stops.txt'); }); return response()->json($data) ->header('Access-Control-Allow-Origin', '*'); } /** * Retorna os shapes GTFS estáticos como JSON (com cache). */ public function shapesJson(): JsonResponse { $data = Cache::remember('mobiangra.shapes', 3600, function () { $shapes = $this->service->readGtfsCsv('shapes.txt'); $grouped = []; foreach ($shapes as $point) { $shapeId = $point['shape_id'] ?? ''; if (! isset($grouped[$shapeId])) { $grouped[$shapeId] = []; } $grouped[$shapeId][] = [ 'lat' => (float) ($point['shape_pt_lat'] ?? 0), 'lon' => (float) ($point['shape_pt_lon'] ?? 0), 'seq' => (int) ($point['shape_pt_sequence'] ?? 0), ]; } foreach ($grouped as &$points) { usort($points, fn ($a, $b) => $a['seq'] <=> $b['seq']); } return $grouped; }); return response()->json($data) ->header('Access-Control-Allow-Origin', '*'); } /** * Retorna o mapeamento route_id+direction_id -> shape_id para o mapa. * * Rotas presentes em routes.txt (catálogo GTFS + fleet.json) entram no JSON. */ public function tripsJson(): JsonResponse { $data = Cache::remember('mobiangra.trips', 3600, function () { $routeNames = $this->service->getRouteNames(); $trips = $this->service->readGtfsCsv('trips.txt'); $map = []; foreach ($trips as $trip) { $routeId = $trip['route_id'] ?? ''; if ($routeId === '' || ! isset($routeNames[$routeId])) { continue; } $directionId = (int) ($trip['direction_id'] ?? 0); $shapeId = $trip['shape_id'] ?? ''; $key = "{$routeId}_{$directionId}"; if (! isset($map[$key]) && $shapeId) { $map[$key] = $shapeId; } } return $map; }); return response()->json($data) ->header('Access-Control-Allow-Origin', '*'); } /** * Tela administrativa do MobiAngra (tabs: Mapa, Horários). */ public function adminIndex(Request $request): View|RedirectResponse { $response = $this->checkPermissao($this->permissao, 'visualizar'); if ($response instanceof RedirectResponse) { return $response; } $tabAtual = $this->mobiangraAdminTab($request); $filterParams = $this->mobiangraAdminQuery($request); $feedStatus = $this->service->getFeedStatus(); $pbExists = file_exists($this->service->getProtobufAbsolutePath()); $jsonExists = Storage::exists($this->service->getLiveDataPath()); $fetchInterval = $this->service->getFetchInterval(); $gtfsFiles = []; $gtfsDir = $this->service->getGtfsStaticDir(); $requiredFiles = ['calendar.txt', 'calendar_dates.txt', 'routes.txt', 'trips.txt', 'stops.txt', 'shapes.txt', 'stop_times.txt']; foreach ($requiredFiles as $file) { $gtfsFiles[$file] = file_exists($gtfsDir . '/' . $file); } $this->service->ensureFleetConfigExists(); $fleetExists = $this->service->fleetConfigExists(); $gtfsStaticAvailable = $this->service->hasGtfsStaticFiles(); $buscaHorarios = $tabAtual === 'horarios' ? trim((string) $request->input('q', '')) : ''; $linhasHorarios = $buscaHorarios !== '' ? $this->service->buscarLinhasHorariosAdmin($buscaHorarios) : MobiangraLinha::query() ->withCount('secoes') ->orderBy('ordem') ->orderBy('codigo') ->get(); $manifestacoes = $this->service->listarAdmin(); $alertasAdmin = $this->service->listarAlertasAdmin(); $totalLinhasHorariosCadastro = MobiangraLinha::count(); $chamadoService = new ChamadoService(); $chamadoIds = $manifestacoes->getCollection() ->pluck('chamado_id') ->filter() ->map(fn ($id) => (string) $id) ->values() ->all(); $mensagensNaoLidasPorChamado = $chamadoService->contarMensagensNaoLidasPorChamados( $chamadoIds, (string) Auth::id() ); return view('admin.mobiangra.index', compact( 'feedStatus', 'pbExists', 'jsonExists', 'gtfsFiles', 'fetchInterval', 'fleetExists', 'gtfsStaticAvailable', 'linhasHorarios', 'totalLinhasHorariosCadastro', 'manifestacoes', 'alertasAdmin', 'mensagensNaoLidasPorChamado', 'tabAtual', 'filterParams', )); } public function adminAlertaStore(MobiangraAlertaRequest $request): RedirectResponse { $response = $this->checkPermissao($this->permissao, 'adicionar'); if ($response instanceof RedirectResponse) { return $response; } $this->service->criarAlerta($request->validated()); return $this->redirectMobiangraAdmin($request, 'alertas', 'success', __('Alerta cadastrado com sucesso.')); } public function adminAlertaUpdate(MobiangraAlertaRequest $request, MobiangraAlerta $alerta): RedirectResponse { $response = $this->checkPermissao($this->permissao, 'editar'); if ($response instanceof RedirectResponse) { return $response; } $this->service->atualizarAlerta($alerta, $request->validated()); return $this->redirectMobiangraAdmin($request, 'alertas', 'success', __('Alerta atualizado com sucesso.')); } public function adminAlertaDestroy(Request $request, MobiangraAlerta $alerta): RedirectResponse { $response = $this->checkPermissao($this->permissao, 'excluir'); if ($response instanceof RedirectResponse) { return $response; } $this->service->excluirAlerta($alerta); return $this->redirectMobiangraAdmin($request, 'alertas', 'success', __('Alerta excluído com sucesso.')); } public function adminAlertaStatus(Request $request, MobiangraAlerta $alerta): RedirectResponse { $response = $this->checkPermissao($this->permissao, 'editar'); if ($response instanceof RedirectResponse) { return $response; } $this->service->alternarStatusAlerta($alerta); return $this->redirectMobiangraAdmin($request, 'alertas', 'success', __('Status do alerta atualizado.')); } /** * Detalhe de uma manifestação (admin). */ public function adminManifestacaoShow(MobiangraManifestacao $manifestacao): View|RedirectResponse { $response = $this->checkPermissao($this->permissao, 'visualizar'); if ($response instanceof RedirectResponse) { return $response; } $manifestacao->load(['chamado.user', 'user']); $chamado = $manifestacao->chamado; $mensagens = collect(); $statusChamado = []; if ($chamado) { $chamadoService = new ChamadoService(); $mensagens = $chamadoService->getMensagens($chamado) ?? collect(); $statusChamado = $chamadoService->getStatusChamado() ?? []; $chamadoService->marcarMensagensComoVisualizadas($chamado, (string) Auth::id()); } return view('admin.mobiangra.manifestacoes.show', [ 'manifestacao' => $manifestacao, 'chamado' => $chamado, 'mensagens' => $mensagens, 'statusChamado' => $statusChamado, 'sentidoLabel' => $this->service->rotuloSentido($manifestacao->sentido), 'filterParams' => ['tab' => 'manifestacoes'], ]); } /** * Nova mensagem / atualização de status no chamado vinculado à manifestação. */ public function adminManifestacaoNovaMensagem( MobiangraManifestacaoMensagemRequest $request, MobiangraManifestacao $manifestacao ): RedirectResponse { $response = $this->checkPermissao($this->permissao, 'editar'); if ($response instanceof RedirectResponse) { return $response; } $manifestacao->load('chamado.user'); if (! $manifestacao->chamado) { return redirect() ->route('admin.mobiangra.manifestacoes.show', $manifestacao) ->with('error', __('Esta manifestação não possui chamado vinculado.')); } $chamado = $manifestacao->chamado; $admin = Auth::user(); $chamadoService = new ChamadoService(); $chamadoArquivoService = new ChamadoArquivoService(); $arrayMensagem = [ 'chamado_id' => $chamado->id, 'owner_id' => $admin->id, 'user_id' => $chamado->user_id, 'mensagem' => $request->input('mensagem'), ]; $mensagem = $chamadoService->novaMensagem($arrayMensagem); if ($mensagem instanceof RedirectResponse) { return $mensagem; } if ($request->hasFile('link')) { foreach ($request->file('link') as $file) { if (! $file->isValid()) { continue; } $link = $this->handleFileUploadLoop($file, 'link', 'arquivos'); if ($link === null) { continue; } $arrayArquivo = [ 'user_id' => $admin->id, 'owner_id' => $mensagem->id, 'nome' => \App\Helpers\FileUploadHelper::sanitizeOriginalFileName($file), 'tipo' => 'Imagem', 'link' => $link, ]; if (($arquivo = $chamadoArquivoService->save($arrayArquivo)) instanceof RedirectResponse) { return $arquivo; } } } $chamadoArquivoService->updateChamado($chamado, [ 'statusChamado' => $request->input('statusChamado'), ]); $chamado->refresh(); if ($chamado->user?->email) { try { Mail::to($chamado->user->email)->send(new MobiangraManifestacaoRespostaMail($chamado, $mensagem)); } catch (\Throwable $e) { Log::error('[MobiAngra] Falha ao notificar cidadão sobre resposta da manifestação', [ 'manifestacao_id' => $manifestacao->id, 'message' => $e->getMessage(), ]); } } return redirect() ->route('admin.mobiangra.manifestacoes.show', $manifestacao) ->with('success', __('Resposta registrada e status atualizado com sucesso.')); } // -- CRUD de Horários (Linhas) --------------------------------------------- public function adminHorariosLinhas(): View|RedirectResponse { $response = $this->checkPermissao($this->permissao, 'visualizar'); if ($response instanceof RedirectResponse) { return $response; } $linhas = $this->service->listarLinhas(); return view('admin.mobiangra.horarios.linhas', compact('linhas')); } /** * Busca assíncrona de linhas de horários (aba Horários do admin). */ public function adminHorariosLinhasBuscar(Request $request): JsonResponse|RedirectResponse { $response = $this->checkPermissao($this->permissao, 'visualizar'); if ($response instanceof RedirectResponse) { return response()->json(['message' => __('Acesso negado.')], 403); } $termo = (string) $request->input('q', ''); $linhasHorarios = $this->service->buscarLinhasHorariosAdmin($termo); return response()->json([ 'total' => $linhasHorarios->count(), 'html' => view('admin.mobiangra.horarios._linhas_tbody', compact('linhasHorarios'))->render(), 'vazio' => $linhasHorarios->isEmpty(), ]); } public function adminHorariosLinhaCreate(): View|RedirectResponse { $response = $this->checkPermissao($this->permissao, 'adicionar'); if ($response instanceof RedirectResponse) { return $response; } return view('admin.mobiangra.horarios.linha_form', ['linha' => null]); } public function adminHorariosLinhaStore(Request $request): RedirectResponse { $response = $this->checkPermissao($this->permissao, 'adicionar'); if ($response instanceof RedirectResponse) { return $response; } $validated = $request->validate([ 'codigo' => ['required', 'string', 'max:20'], 'nome' => ['required', 'string', 'max:255'], 'nome_completo' => ['required', 'string', 'max:255'], 'tarifa' => ['nullable', 'numeric', 'min:0'], 'tarifa_cidadao' => ['nullable', 'numeric', 'min:0'], 'ordem' => ['nullable', 'integer', 'min:0'], 'publicada' => ['nullable'], ]); $linha = $this->service->criarLinha($validated); return redirect() ->route('admin.mobiangra.horarios.linhas.edit', $linha) ->with('success', __('Linha criada. Agora adicione as seções e horários.')); } public function adminHorariosLinhaEdit(MobiangraLinha $linha): View|RedirectResponse { $response = $this->checkPermissao($this->permissao, 'editar'); if ($response instanceof RedirectResponse) { return $response; } $linha->load(['secoes.grades', 'legendas', 'integracoes']); $periodos = MobiangraHorarioGrade::PERIODOS; return view('admin.mobiangra.horarios.linha_form', compact('linha', 'periodos')); } public function adminHorariosLinhaUpdate(Request $request, MobiangraLinha $linha): RedirectResponse { $response = $this->checkPermissao($this->permissao, 'editar'); if ($response instanceof RedirectResponse) { return $response; } $validated = $request->validate([ 'codigo' => ['required', 'string', 'max:20'], 'nome' => ['required', 'string', 'max:255'], 'nome_completo' => ['required', 'string', 'max:255'], 'tarifa' => ['nullable', 'numeric', 'min:0'], 'tarifa_cidadao' => ['nullable', 'numeric', 'min:0'], 'ordem' => ['nullable', 'integer', 'min:0'], 'publicada' => ['nullable'], ]); $this->service->atualizarLinha($linha, $validated); // Sincronizar legendas $legendas = []; foreach ($request->input('legendas', []) as $leg) { if (! empty($leg['descricao'])) { $legendas[] = $leg; } } $this->service->sincronizarLegendas($linha, $legendas); // Sincronizar integrações (formato visual: integracoes[i][linhas][j][codigo|nome]) $integracoes = []; foreach ($request->input('integracoes', []) as $integ) { $linhasEnvolvidas = []; foreach ($integ['linhas'] ?? [] as $lenv) { $codigo = trim($lenv['codigo'] ?? ''); $nome = trim($lenv['nome'] ?? ''); if ($codigo !== '') { $linhasEnvolvidas[] = ['codigo' => $codigo, 'nome' => $nome]; } } if (! empty($linhasEnvolvidas)) { $integracoes[] = [ 'linhas_envolvidas' => $linhasEnvolvidas, 'tempo_minutos' => $integ['tempo_minutos'] ?? 120, ]; } } $this->service->sincronizarIntegracoes($linha, $integracoes); return $this->redirectMobiangraLinhaEdit($linha, __('Linha atualizada com sucesso.')); } public function adminHorariosLinhaDestroy(Request $request, MobiangraLinha $linha): RedirectResponse { $response = $this->checkPermissao($this->permissao, 'excluir'); if ($response instanceof RedirectResponse) { return $response; } $this->service->excluirLinha($linha); return $this->redirectMobiangraAdmin($request, 'horarios', 'success', __('Linha excluída.')); } public function adminHorariosLinhaStatus(Request $request, MobiangraLinha $linha): RedirectResponse { $response = $this->checkPermissao($this->permissao, 'editar'); if ($response instanceof RedirectResponse) { return $response; } $linha->update(['publicada' => ! $linha->publicada]); return $this->redirectMobiangraAdmin($request, 'horarios', 'success', __('Status da linha atualizado.')); } // -- Seções ---------------------------------------------------------------- public function adminHorariosSecaoStore(Request $request, MobiangraLinha $linha): RedirectResponse { $response = $this->checkPermissao($this->permissao, 'adicionar'); if ($response instanceof RedirectResponse) { return $response; } $request->validate(['titulo' => ['required', 'string', 'max:255']]); $this->service->criarSecao($linha, $request->all()); return $this->redirectMobiangraLinhaEdit($linha, __('Seção adicionada.')); } public function adminHorariosSecaoUpdate(Request $request, MobiangraHorarioSecao $secao): RedirectResponse { $response = $this->checkPermissao($this->permissao, 'editar'); if ($response instanceof RedirectResponse) { return $response; } $request->validate(['titulo' => ['required', 'string', 'max:255']]); $this->service->atualizarSecao($secao, $request->all()); return $this->redirectMobiangraLinhaEdit($secao->linha, __('Seção atualizada.')); } public function adminHorariosSecaoDestroy(MobiangraHorarioSecao $secao): RedirectResponse { $response = $this->checkPermissao($this->permissao, 'excluir'); if ($response instanceof RedirectResponse) { return $response; } $linha = $secao->linha; $this->service->excluirSecao($secao); return $this->redirectMobiangraLinhaEdit($linha, __('Seção excluída.')); } // -- Grades ---------------------------------------------------------------- public function adminHorariosGradeStore(Request $request, MobiangraHorarioSecao $secao): RedirectResponse { $response = $this->checkPermissao($this->permissao, 'editar'); if ($response instanceof RedirectResponse) { return $response; } $request->validate([ 'periodo' => ['required', 'in:seg_sex,sabado,dom_feriado'], 'label' => ['nullable', 'string', 'max:100'], 'horarios' => ['required', 'string'], 'horarios_cores' => ['nullable', 'string'], ]); $secao->loadMissing('linha.legendas'); $coresPermitidas = $secao->linha->legendas->pluck('cor')->unique()->values()->all(); $itensComCor = $this->service->decodificarHorariosCores($request->input('horarios_cores')); if ($itensComCor !== []) { $horarios = $this->service->normalizarHorariosItensPintor($itensComCor, $coresPermitidas); } else { $horarios = $this->service->normalizarHorariosComCores( $request->input('horarios'), [], $coresPermitidas ); } if (empty($horarios)) { return $this->redirectMobiangraLinhaEdit( $secao->linha, __('Informe ao menos um horário válido no formato HH:MM.'), 'error' ); } $label = $request->input('label') ?: (MobiangraHorarioGrade::PERIODOS[$request->input('periodo')] ?? ''); $this->service->salvarGrade($secao, $request->input('periodo'), $label, $horarios); return $this->redirectMobiangraLinhaEdit($secao->linha, __('Horários salvos com sucesso.')); } public function adminHorariosGradeDestroy(MobiangraHorarioGrade $grade): RedirectResponse { $response = $this->checkPermissao($this->permissao, 'excluir'); if ($response instanceof RedirectResponse) { return $response; } $linha = $grade->secao->linha; $this->service->excluirGrade($grade); return $this->redirectMobiangraLinhaEdit($linha, __('Grade removida.')); } // -- Importação XLSX ------------------------------------------------------- public function adminHorariosImportarXlsx(Request $request): RedirectResponse { $response = $this->checkPermissao($this->permissao, 'adicionar'); if ($response instanceof RedirectResponse) { return $response; } $request->validate([ 'horarios_xlsx' => ['required', 'file', 'mimes:xlsx,xls', 'max:10240'], ]); $path = $request->file('horarios_xlsx')->getPathname(); try { $resultados = $this->service->importarXlsx($path); $erros = array_filter($resultados, fn ($v) => $v !== 'ok'); if (empty($erros)) { $msg = __(':count linhas importadas com sucesso.', ['count' => count($resultados)]); } else { $msg = __(':ok importadas. Erros: :erros', [ 'ok' => count($resultados) - count($erros), 'erros' => implode(', ', array_keys($erros)), ]); } } catch (\Throwable $e) { return $this->redirectMobiangraAdmin( $request, 'horarios', 'error', __('Falha na importação: :erro', ['erro' => $e->getMessage()]) ); } return $this->redirectMobiangraAdmin($request, 'horarios', 'success', $msg); } /** * Força uma atualização manual do feed. */ public function adminForceUpdate(Request $request): RedirectResponse { $response = $this->checkPermissao($this->permissao, 'editar'); if ($response instanceof RedirectResponse) { return $response; } Artisan::call('mobiangra:fetch-positions'); $output = Artisan::output(); return $this->redirectMobiangraAdmin( $request, 'mapa', 'success', __('Atualização forçada executada.') . ' ' . trim($output) ); } /** * Salva configurações dinâmicas do módulo (ex: intervalo de atualização). */ public function adminSaveSettings(Request $request): RedirectResponse { $response = $this->checkPermissao($this->permissao, 'editar'); if ($response instanceof RedirectResponse) { return $response; } $request->validate([ 'fetch_interval' => ['required', 'integer', 'min:5', 'max:300'], ]); $saved = $this->service->saveSettings([ 'fetch_interval' => (int) $request->input('fetch_interval'), ]); if (! $saved) { return $this->redirectMobiangraAdmin($request, 'mapa', 'error', __('Falha ao salvar configurações.')); } return $this->redirectMobiangraAdmin($request, 'mapa', 'success', __('Intervalo de atualização salvo com sucesso.')); } /** * Upload de arquivos GTFS estáticos (ZIP como forma principal ou .txt individuais). */ public function adminUploadGtfs(Request $request): RedirectResponse { $response = $this->checkPermissao($this->permissao, 'adicionar'); if ($response instanceof RedirectResponse) { return $response; } $maxZipKb = (int) config('mobiangra.gtfs_zip_max_kb', 102400); $request->validate([ 'gtfs_zip' => ['nullable', 'file', 'mimes:zip', 'max:' . $maxZipKb], 'gtfs_calendar' => ['nullable', 'file', 'mimes:txt,csv', 'max:51200'], 'gtfs_calendar_dates' => ['nullable', 'file', 'mimes:txt,csv', 'max:51200'], 'gtfs_routes' => ['nullable', 'file', 'mimes:txt,csv', 'max:51200'], 'gtfs_trips' => ['nullable', 'file', 'mimes:txt,csv', 'max:51200'], 'gtfs_stops' => ['nullable', 'file', 'mimes:txt,csv', 'max:51200'], 'gtfs_shapes' => ['nullable', 'file', 'mimes:txt,csv', 'max:102400'], 'gtfs_stop_times' => ['nullable', 'file', 'mimes:txt,csv', 'max:102400'], 'gtfs_agency' => ['nullable', 'file', 'mimes:txt,csv', 'max:51200'], 'gtfs_feed_info' => ['nullable', 'file', 'mimes:txt,csv', 'max:51200'], ]); $uploaded = 0; $fromZip = 0; if ($request->hasFile('gtfs_zip') && $request->file('gtfs_zip')->isValid()) { try { $fromZip = $this->service->extractGtfsZip($request->file('gtfs_zip')->getRealPath()); $uploaded += $fromZip; } catch (RuntimeException $e) { Log::warning('Mobiangra: falha ao extrair ZIP GTFS - ' . $e->getMessage()); return $this->redirectMobiangraAdmin( $request, 'mapa', 'error', __('Falha ao importar o pacote GTFS: :detalhe', ['detalhe' => $e->getMessage()]) ); } } foreach ($this->service->getGtfsAllowedFileKeys() as $name) { $fieldName = "gtfs_{$name}"; if ($request->hasFile($fieldName) && $request->file($fieldName)->isValid()) { $this->service->importGtfsTextFile($name, $request->file($fieldName)); $uploaded++; } } if ($uploaded === 0) { return $this->redirectMobiangraAdmin( $request, 'mapa', 'error', __('Envie um arquivo .zip com o GTFS ou selecione ao menos um arquivo .txt.') ); } $this->service->clearModuleCaches(); return $this->redirectMobiangraAdmin( $request, 'mapa', 'success', __('GTFS atualizado com sucesso (:count arquivo(s)).', ['count' => $uploaded]) ); } /** * Faz download do fleet.json (mapeamentos, cores e frota). */ public function adminDownloadFleet(): BinaryFileResponse|RedirectResponse { $response = $this->checkPermissao($this->permissao, 'visualizar'); if ($response instanceof RedirectResponse) { return $response; } $this->service->ensureFleetConfigExists(); $path = $this->service->getFleetConfigAbsolutePath(); return response()->download($path, config('mobiangra.fleet_filename', 'fleet.json')); } /** * Substitui o fleet.json por um arquivo enviado pelo administrador. */ public function adminUploadFleet(Request $request): RedirectResponse { $response = $this->checkPermissao($this->permissao, 'editar'); if ($response instanceof RedirectResponse) { return $response; } $request->validate([ 'fleet_config' => ['required', 'file', 'mimes:json,txt', 'max:2048'], ]); $raw = file_get_contents($request->file('fleet_config')->getRealPath()); $decoded = json_decode($raw, true); if (! is_array($decoded)) { return $this->redirectMobiangraAdmin( $request, 'mapa', 'error', $this->fleetUploadInvalidJsonUserMessage($raw) ); } $requiredKeys = ['internal_to_gtfs', 'line_colors', 'vehicle_colors', 'ac_vehicles']; foreach ($requiredKeys as $key) { if (! array_key_exists($key, $decoded)) { return $this->redirectMobiangraAdmin( $request, 'mapa', 'error', __('O JSON deve conter a chave ":key".', ['key' => $key]) ); } } if (! $this->service->saveFleetConfig($decoded)) { return $this->redirectMobiangraAdmin( $request, 'mapa', 'error', __('Falha ao salvar o arquivo de configuração da frota.') ); } return $this->redirectMobiangraAdmin( $request, 'mapa', 'success', __('Configuração da frota atualizada com sucesso.') ); } /** * Regenera fleet.json a partir do seed em config/mobiangra.php. */ public function adminResetFleet(Request $request): RedirectResponse { $response = $this->checkPermissao($this->permissao, 'editar'); if ($response instanceof RedirectResponse) { return $response; } if (! $this->service->saveFleetConfig($this->service->getFleetSeedFromConfig())) { return $this->redirectMobiangraAdmin( $request, 'mapa', 'error', __('Falha ao regenerar o arquivo de configuração da frota.') ); } return $this->redirectMobiangraAdmin( $request, 'mapa', 'success', __('Arquivo fleet.json regenerado a partir do modelo padrão.') ); } /** * Mensagem amigável quando o upload do fleet.json não passa em json_decode. */ private function fleetUploadInvalidJsonUserMessage(string $raw): string { $base = __( 'O arquivo não pôde ser carregado: o conteúdo não é um JSON válido. ' . 'Use um editor que grave JSON estrito (sem comentários // ou /* */ e sem vírgula após o último item de cada objeto ou lista). ' . 'Se editou a partir do arquivo baixado do painel, confira se não colou trechos de planilha ou texto com aspas tipográficas (por exemplo copiadas de Word ou Excel).' ); $hints = []; if (str_starts_with($raw, "\xEF\xBB\xBF")) { $hints[] = __( 'Dica: o arquivo parece ter BOM (marca UTF-8 no início). Salve novamente como UTF-8 sem BOM.' ); } if (preg_match('/,\s*([\}\]])/', $raw, $matches, PREG_OFFSET_CAPTURE) === 1) { $offset = (int) $matches[0][1]; $line = substr_count(substr($raw, 0, $offset), "\n") + 1; $hints[] = __( 'Dica: foi encontrada uma vírgula imediatamente antes de um "}" ou "]" (por volta da linha :linha). Em JSON isso não é permitido - remova essa vírgula.', ['linha' => $line] ); } $parser = json_last_error_msg(); if ($parser !== '' && strcasecmp($parser, 'No error') !== 0) { $hints[] = __('Detalhe técnico do analisador: :detalhe.', ['detalhe' => $parser]); } return $hints === [] ? $base : $base . ' ' . implode(' ', $hints); } }
| ver. 1.4 |
Github
|
.
| PHP 8.3.28 | Generation time: 0.02 |
proxy
|
phpinfo
|
Settings