# Obras — Rotas e Controllers

> Versão analisada: `v5`. Nomes de rota conferidos com `php artisan route:list`
> no container `v5_3f273858-app-1` (leitura apenas).

---

## 1. Rotas do CMS (`v5/routes/basepro.php`)

Todas as rotas vivem no grupo `Route::group(['prefix' => 'cms', 'before' => 'csrf'], ...)`.
O middleware efetivo vem do controller base: `Prefecture\Controller::__construct()`
aplica `check.route.permission` a **todas** as actions — o atributo `before =>
csrf` do grupo **não** é um middleware do Laravel 5.5 (não existe `csrf` como
alias no `Kernel`; a proteção CSRF vem do grupo `web`, aplicado em
`RouteServiceProvider::mapBaseproRoutes()`).

### 1.1 Grupo `obras.*` — `v5/routes/basepro.php:526-535`

| Método | URI | Nome | Controller@action | Middleware |
|---|---|---|---|---|
| GET | `cms/obras` | `obras.index` | `Prefecture\Works\WorkController@index` | `web`, `check.route.permission` |
| GET | `cms/obras/create` | `obras.create` | `Prefecture\Works\WorkController@create` | `web`, `check.route.permission` |
| POST | `cms/obras` | `obras.store` | `Prefecture\Works\WorkController@store` | `web`, `check.route.permission` |
| GET | `cms/obras/{id}/edit` | `obras.edit` | `Prefecture\Works\WorkController@edit` | `web`, `check.route.permission` |
| PUT | `cms/obras/{id}` | `obras.update` | `Prefecture\Works\WorkController@update` | `web`, `check.route.permission` |
| DELETE | `cms/obras/{id}` | `obras.destroy` | `Prefecture\Works\WorkController@destroy` | `web`, `check.route.permission` |
| GET | `cms/obras/get-geolocation-data` | `obras.getGeolocationData` | `Prefecture\Works\WorkController@getGeolocationData` | `web`, `check.route.permission` |

Observações:

- A linha `v5/routes/basepro.php:525` contém o resource comentado:
  `#Route::resource("obras", "Prefecture\Works\WorkController");` — as rotas
  foram **escritas à mão** para poder dar nome `obras.*` e usar `{id}`.
- `Route::resource` foi mantido apenas para **categorias**:
  `Route::resource("categorias-obras", "Prefecture\Works\WorkCategoryController");`
  (`v5/routes/basepro.php:543`), gerando o prefixo de URI
  `cms/categorias-obras` e o wildcard de parâmetro `{categorias_obra}`
  (singular de `categorias-obras`).
- `WorkController@show($id)` existe no código mas **não há rota** que o
  alcance (o resource está comentado) — é código morto.

### 1.2 Resource `categorias-obras.*` — `v5/routes/basepro.php:543`

| Método | URI | Nome | Controller@action | Middleware |
|---|---|---|---|---|
| GET | `cms/categorias-obras` | `categorias-obras.index` | `WorkCategoryController@index` | `web`, `check.route.permission` |
| GET | `cms/categorias-obras/create` | `categorias-obras.create` | `WorkCategoryController@create` | `web`, `check.route.permission` |
| POST | `cms/categorias-obras` | `categorias-obras.store` | `WorkCategoryController@store` | `web`, `check.route.permission` |
| GET | `cms/categorias-obras/{categorias_obra}` | `categorias-obras.show` | `WorkCategoryController@show` | `web`, `check.route.permission` |
| GET | `cms/categorias-obras/{categorias_obra}/edit` | `categorias-obras.edit` | `WorkCategoryController@edit` | `web`, `check.route.permission` |
| PUT/PATCH | `cms/categorias-obras/{categorias_obra}` | `categorias-obras.update` | `WorkCategoryController@update` | `web`, `check.route.permission` |
| DELETE | `cms/categorias-obras/{categorias_obra}` | `categorias-obras.destroy` | `WorkCategoryController@destroy` | `web`, `check.route.permission` |

> **`WorkCategoryController@show` não existe** — a classe implementa apenas
> `index`, `create`, `store`, `edit`, `update` e `destroy`. A rota
> `categorias-obras.show` está registrada e leva a um erro 500 se acessada.
> `create()` existe mas o corpo é vazio (`public function create(){}`), o que
> devolve uma resposta em branco.

### 1.3 Grupo `galeria-obras.*` — `v5/routes/basepro.php:538-541`

| Método | URI | Nome | Controller@action | Middleware |
|---|---|---|---|---|
| POST | `cms/galerias-obras` | `galeria-obras.` (sem `->name()`) | `WorkController@saveArchivesFromGallery` | `web`, `check.route.permission` |
| GET | `cms/galerias-obras/{id}` | `galeria-obras.` (sem `->name()`) | `WorkController@getArchivesFromGallery` | `web`, `check.route.permission` |

> O prefixo do grupo é `galeria-obras.` mas o **prefixo de URI** é
> `galerias-obras` (com "s"). As duas rotas **não têm `->name()`**, logo o nome
> final é literalmente `galeria-obras.` — nome inválido para `route()` e inútil
> para o `CheckRoutePermission`, que usa `Route::getCurrentRoute()->getName()`
> para derivar a permissão. Na prática, o middleware resolve a permissão pelo
> **segmento da URL** (`hasAccessSegment()`), não pelo nome.

### 1.4 Rotas de DataTable — `v5/routes/basepro.php:472-473`

| Método | URI | Controller@action |
|---|---|---|
| GET | `cms/datatable/listagem_obras` | `Prefecture\DatatableController@get_works_list` |
| GET | `cms/datatable/listagem_categorias_obras` | `Prefecture\DatatableController@get_works_categories_list` |

Servem as views SQL `listagem_obras` e `listagem_categorias_obras` (Yajra
DataTables em JSON). Detalhado em `02-MODELAGEM.md`.

### 1.5 Rota auxiliar de exclusão de arquivo (módulo `archives`)

`v5/routes/basepro.php:381-384`:

| Método | URI | Controller@action |
|---|---|---|
| POST | `cms/galeria/delete/{id}` | `Prefecture\ArchiveController@deleteArchives` |

Usada por `deleteFile()` em
`v5/public/assets/js/custom/contents/obras/script.js` para apagar os arquivos
anexados à obra — **não** é uma rota do módulo Obras, é do módulo de galeria
genérica.

## 2. Rotas do portal (`v5/routes/web.php`)

Todas dentro de `Route::middleware(['check.site.manutention'])->group(...)`.
**Sem autenticação e sem `check.route.permission`.**

| Método | URI | Nome | Controller@action | Middleware |
|---|---|---|---|---|
| ANY | `obras` | `portal-obras.index` | `Portal\WorksController@index` | `web`, `check.site.manutention` |
| ANY | `exportacao-obras` | `portal-obras.exportacao` | `Portal\WorksController@index` | `web`, `check.site.manutention` |
| GET | `obras/{id}` | `portal-obras.show` | `Portal\WorksController@indexShow` | `web`, `check.site.manutention` |

> **Rota `ANY` + bifurcation por nome de rota:** `exportacao-obras` e `obras`
> apontam para o **mesmo** método `index()`. Quem decide se a resposta é HTML
> ou planilha é a comparação interna
> `if (Route::currentRouteName() == 'portal-obras.exportacao')`. O tipo de
> exportação vem de `$request->type` (`xls`, `csv` ou qualquer outro → `txt`).
>
> **A rota `obras` é `ANY`**, o que permite `POST /obras` — não há CSRF
> explícito além do grupo `web`, e nenhum controller do portal aplica
> `check.route.permission`.

## 3. `WorkController` (CMS)

Arquivo: `v5/app/Http/Controllers/Prefecture/Works/WorkController.php`
(namespace `App\Http\Controllers\Prefecture\Works`, estende
`App\Http\Controllers\Prefecture\Controller`).

| Action | Comportamento |
|---|---|
| `index()` | Log de atividade ("Listou obras"), `menuClicks('obras')` (incrementa `menu_orders.clicks` do slug `obras`), retorna `view('prefecture.works.index')`. **Não consulta o banco** — o JS carrega os dados via DataTable. |
| `show($id)` | Corpo vazio `{}` — **sem rota**. |
| `create()` | Log de atividade; carrega `WorkCategory` e `WorkStatus` com `select(DB::raw("id, name"))` e mapeia para objetos `{id, name}`; retorna `view('prefecture.works.create', compact('categories','statuses'))`. |
| `store(SaveRequest)` | Transação: cria a obra, grava os arquivos anexados, commit; log; `flash(...)->success()`; redirect para `obras.index` — ou para `obras.edit` com `goto=galeria` se `gallery` foi marcado. |
| `edit($id)` | Log de atividade; carrega categorias, situações, a obra (`Work::find($id)`) e os **arquivos** (`type = FILE`) da obra; passa `$goto` (query string) para a view. |
| `update(SaveRequest, $id)` | Transação: `Work::query()->updateOrCreate(['id' => $id], $this->attributes($request))`; grava arquivos; commit; log; redirect para `obras.edit` com `goto=galeria` **se a galeria acabou de ser ativada** (`!$work->gallery && $request->get('gallery')`), senão `obras.index`. |
| `destroy(Request)` | **Não** usa `DeleteRequest` (importado mas não aplicado): faz `Work::find($request->id)` e `$store->delete()` (soft delete). Responde JSON `{"success": "Obra excluída com sucesso"}` ou `{"error": ...}` (HTTP 500). |
| `saveArchivesFromGallery(Request)` | Recebe `file` + `id`; grava a imagem via `FileTrait::saveUpload()` na pasta `/works`; cria o registro em `archives` (com `hash` = `md5_file`) e o vínculo `work_archives` com `type = GALLERY`; responde JSON `{"success": ..., "data": $archive}`. |
| `getArchivesFromGallery(int $id)` | Devolve as imagens da galeria da obra (`type = GALLERY`, `highlight IS NULL`, extensão de imagem) em JSON; com `$id` vazio devolve **todos** os arquivos do sistema. |
| `getGeolocationData(Request)` | Endpoint JSON de geocodificação: lê `?address=`, chama `GeocodingService::getCoordinates()` e devolve `{"data": {"lat": ..., "lng": ...}}`; em erro, HTTP 500 com `{"error": mensagem}`. |
| `attributes($request)` (público) | Constrói o array de atributos persistidos a partir da request (24 campos). |
| `storeArchives(array, int)` (privado) | Itera os arquivos enviados; cria `archives`, grava o físico em `obras/{id}/arquivos/` e cria `work_archives` com `type = FILE`. |

### 3.1 O método `attributes()`

```php
public function attributes($request)
{
    return [
        'title' => $request->get('title'),
        'edital' => $request->get('edital'),
        'contract' => $request->get('contract'),
        'work_category_id' => $request->get('work_category_id'),
        'status_id' => $request->get('status_id'),
        'value' => $request->get('value'),
        'value_paid' => $request->get('value_paid'),
        'start_date' => $request->get('start_date'),
        'end_date' => $request->get('end_date'),
        'cep' => $request->get('cep'),
        'state' => $request->get('state'),
        'city' => $request->get('city'),
        'neighborhood' => $request->get('neighborhood'),
        'street' => $request->get('street'),
        'house_number' => $request->get('house_number'),
        'complement' => $request->get('complement'),
        'company_holder' => $request->get('company_holder'),
        'holder' => $request->get('holder'),
        'gallery' => $request->get('gallery'),
        'latitude' => $request->get('latitude'),
        'longitude' => $request->get('longitude'),
        'justification' => $request->get('justification'),
        'description' => $request->get('description'),
        'estimated_completion_date' => $request->get('estimated_completion_date'),
        'completion_percentage' => $request->get('completion_percentage'),
    ];
}
```

> A chave do formulário de situação é `status_id` (o model e o banco também),
> mas o `attributes()` a grava em `status_id` — coerente. Já a migration
> declara a coluna `status_id`, e o model `Work` tem `status_id` no
> `$fillable`. Tudo alinhado neste ponto.

## 4. `WorkCategoryController` (CMS)

Arquivo: `v5/app/Http/Controllers/Prefecture/Works/WorkCategoryController.php`.

| Action | Comportamento |
|---|---|
| `index()` | Log ("Listou categorias de obras"); retorna `view('prefecture.works.categories.index')`. |
| `create()` | Corpo vazio `{}`. |
| `store(SaveRequest)` | `WorkCategory::create(['name' => $request->get('name')])`; log; `flash('Obra cadastrada com sucesso')` (**mensagem errada** — diz "Obra" para uma categoria); redirect `categorias-obras.index`. |
| `edit($id)` | Log; `WorkCategory::find($id)`; retorna `view('prefecture.works.categories.edit', compact('data'))`. |
| `update(SaveRequest, $id)` | `updateOrCreate(['id' => $id], ['name' => ...])`; log; flash "Categoria de obra atualizada com sucesso"; redirect. |
| `destroy(DeleteRequest)` | Valida o id; se não existir → 404 `{"erro": "Categoria de obra não encontrada."}`; se houver obras vinculadas (`$store->works()->count() > 0`) → **HTTP 400** `{"erro": "Não é possível excluir esta categoria de obra, pois existem obras vinculadas a ela."}`; senão soft delete + log + `{"success": ...}`. |

> **Bug de debug no `store()`:** o bloco `catch` contém `dd($e);` antes do
> `flash(...)->error()`. Uma exceção na criação da categoria **aborta a
> requisição com um dump** em vez de exibir a mensagem de erro.
>
> **A verificação de "obras vinculadas"** usa `$store->works()->count()`, que
> é `hasMany(Work::class)` → como `Work` usa `SoftDeletes`, obras com
> `deleted_at` preenchido **não** bloqueiam a exclusão da categoria.

## 5. `WorksController` (Portal)

Arquivo: `v5/app/Http/Controllers/Portal/WorksController.php` (namespace
`App\Http\Controllers\Portal`, estende `App\Http\Controllers\Admin\Controller` —
**não** o `Prefecture\Controller`, por isso não há `check.route.permission`).

| Action | Comportamento |
|---|---|
| `index(Request)` | Monta os filtros, pagina, monta `$forMap` (para o mapa), decide entre HTML, JSON (AJAX) e exportação. |
| `indexShow(int $id)` | `validator(['id' => $id], ['id' => 'required\|integer\|exists:works,id'])`; carrega `Work::with(['workCategory','status','archives'])->where('id', $id)->first()`; retorna a view `show`. |
| `export($obras, $type)` | `xls` → `Excel::download(new WorksExport($obras), 'obras.xlsx')`; `csv` → `Excel::download(new WorksExport($obras), 'obras.csv')`; qualquer outro → `exportTxt($obras, 'obras.txt')`. |
| `exportTxt($models, $filename)` | Monta o TXT com cabeçalho e separadores e devolve com `Content-Disposition: attachment`. |
| `gerarTxt($models)` | Gera o corpo do TXT linha a linha (acessa `$model->workCategory->name` e `$model->status->name`). |
| `getNonAccentedILikeSearch($search, $column)` | Monta o fragmento SQL de busca insensível a acentos. |
| `prepareStringToSearch($search)` | Converte entidades HTML de volta para caracteres acentuados e aplica `mb_strtolower`. |

> **`indexShow` não trata "obra inexistente":** o `validator()` é chamado sem
> `->fails()` e sem abort; se o id não existir, `$work` é `null` e
> `$work->archives` estoura um erro 500.

## 6. Form Requests

### 6.1 `Prefecture\Works\SaveRequest`

`v5/app/Http/Requests/Prefecture/Works/SaveRequest.php`

```php
public function rules()
{
    return [
        'id' => ['nullable', 'exists:works,id'],
        'title' => ['required', 'max:255'],
        'edital' => ['required', 'max:255'],
        'contract' => ['required', 'max:255'],
        'work_category_id' => ['required', 'exists:work_categories,id'],
        'status_id' => ['required', 'exists:work_statuses,id'],
        'value' => ['required'],
        'value_paid' => ['required'],
        'start_date' => ['required', 'date'],
        'end_date' => ['nullable', 'date', 'after_or_equal:start_date'],
        'cep' => ['required', 'string', 'max:10'],
        'state' => ['required', 'string', 'max:2'],
        'city' => ['required', 'string', 'max:255'],
        'neighborhood' => ['required', 'string', 'max:255'],
        'street' => ['required', 'string', 'max:255'],
        'house_number' => ['required', 'string', 'max:10'],
        'complement' => ['nullable', 'string', 'max:255'],
        'latitude' => ['nullable', 'string', 'max:20'],
        'longitude' => ['nullable', 'string', 'max:20'],
        'company_holder' => ['required', 'string', 'max:255'],
        'holder' => ['required', 'string', 'max:255'],
        'gallery' => ['nullable', 'integer', 'in:0,1'],
        'justification' => ['nullable', 'string'],
        'description' => ['nullable', 'string'],
        'estimated_completion_date' => ['nullable', 'date'],
        'completion_percentage' => ['nullable', 'integer', 'min:0', 'max:100'],
        'archive' => ['nullable', 'array'],
        'archive.*.file' => ['nullable', 'file', 'mimes:jpg,jpeg,png,pdf,doc,docx,xls,xlsx,zip', 'max:2048'],
        'archive.*.description' => ['nullable', 'string', 'max:255'],
    ];
}
```

`prepareForValidation()` normaliza antes de validar:

```php
$this->merge([
    'id' => $this->route('obra'),
    'gallery' => strtolower($this->get('gallery')) === 'on' ? 1 : 0,
    'start_date' => trim($this->get('start_date')) != '' ? Carbon::createFromFormat('d/m/Y', $this->get('start_date'))->format('Y-m-d') : null,
    'end_date' => trim($this->get('end_date')) != '' ? Carbon::createFromFormat('d/m/Y', $this->get('end_date'))->format('Y-m-d') : null,
    'estimated_completion_date' => trim($this->get('estimated_completion_date')) != '' ? Carbon::createFromFormat('d/m/Y', $this->get('estimated_completion_date'))->format('Y-m-d') : null,
]);
```

Pontos críticos:

1. **`'id' => $this->route('obra')`** — o parâmetro de rota do CMS é `{id}`,
   não `obra`. `$this->route('obra')` retorna sempre `null`, e o `merge()`
   **sobrescreve** o `id` do formulário. Efeito: a regra
   `exists:works,id` **nunca é exercitada** de fato (o id enviado é apagado).
   O `update()` continua funcionando porque usa o `{id}` da URL, não o do
   request.
2. **`gallery` é normalizado para 0/1** — o checkbox do formulário não tem
   `value`, então o navegador envia `on`. A normalização
   `strtolower(...) === 'on' ? 1 : 0` cobre os dois casos (marcado / não
   marcado). Mas o `merge()` **sempre** define `gallery`, o que significa
   que um `PUT` sem o campo `gallery` (ou de outro formulário) zera a galeria.
3. **Datas com `Carbon::createFromFormat('d/m/Y', ...)`** — se o valor vier
   em outro formato (ex.: `2026-01-31`), o `createFromFormat` lança exceção
   `InvalidFormatException` **antes** da validação, produzindo um 500 em vez
   de uma mensagem de erro.
4. **`house_number` com `max:10` e `string`** — o formulário envia `input
   type="number"`; o navegador envia string, ok.
5. **`value` e `value_paid` só `required`** — não há validação numérica nem
   `max`, e o banco é `varchar`.
6. **`justification` não é exigido condicionalmente** — a obrigatoriedade da
   justificativa para as situações "Suspensa"/"Cancelada" é aplicada **apenas
   no JavaScript** (`toggleJustification()`), que esconde o campo. Um POST
   direto sem justificativa é aceito.
7. **`archive.*.file` com `max:2048`** (2 MB) e `mimes` restrito a
   jpg/jpeg/png/pdf/doc/docx/xls/xlsx/zip. A UI, porém, anuncia
   "Tamanho máximo: 128MB" — **divergência entre o que a tela promete e o que
   o servidor aceita**.
8. `authorize()` retorna `true` — **não há autorização no FormRequest**; a
   permissão é tratada pelo middleware.

### 6.2 `Prefecture\Works\DeleteRequest`

```php
public function rules()
{
    return ['id' => ['required', 'exists:works,id']];
}

protected function prepareForValidation()
{
    $this->merge(['id' => $this->route('obra')]);
}

protected function failedValidation(Validator $validator)
{
    throw new HttpResponseException(
        response()->json(['errors' => $validator->errors()], Response::HTTP_UNPROCESSABLE_ENTITY)
    );
}
```

> Mesmo problema do `SaveRequest`: `route('obra')` não existe (é `{id}`), logo
> o `id` é `null` e a validação **falha sempre** com 422. Como
> `WorkController@destroy` **não** usa este request (usa `Illuminate\Http\Request`
> cru), o bug fica latente. O `DeleteRequest` de Works é, na prática, **código
> morto**.

### 6.3 `Prefecture\WorkCategories\SaveRequest` e `DeleteRequest`

| Request | Regras | `prepareForValidation` |
|---|---|---|
| `SaveRequest` | `id` nullable `exists:work_categories,id`; `name` required max 255 | `'id' => $this->route('categorias_obra')` — **correto**, o wildcard do resource é `{categorias_obra}` |
| `DeleteRequest` | `id` required `exists:work_categories,id` | `'id' => $this->route('categorias_obra')` — correto; `failedValidation` devolve 422 JSON |

> A **inconsistência de nomenclatura** entre `obra` (Works) e
> `categorias_obra` (WorkCategories) é a causa raiz do bug do `id`.

## 7. Autorização e permissões

| Verificação | Onde | Regra |
|---|---|---|
| Sessão do CMS | `CheckRoutePermission::handle()` | Se `!Sentinel::check()` → `redirect()->route('login')`. |
| `super-admin` | `CheckRoutePermission::handle()` | Se `Sentinel::inRole('super-admin')`, **pula** a checagem de permissão. |
| Permissão da rota | `CheckRoutePermission::hasAccess()` | `Route::getCurrentRoute()->getName()` → `hasAnyAccess([...])`. Rotas cujo nome contém `edit`, `create`, `delete` são mapeadas para `update`, `store`, `destroy` via `$resourceRoutes`; nomes "abertos" (`login`, `logout`, `datatable`, `dashboard`, `error`, `ajax`, ...) passam livre. |
| Permissão por segmento | `CheckRoutePermission::hasAccessSegment()` | Deriva a permissão do segmento da URL (`Request::segment(3)`), com `cadastrar`→`.create` e `editar`→`.edit`. |
| Aliases | `resolve_permission_aliases()` em `v5/app/Library/Helper/appHelpers.php:44` | `obra-paralisada`→`obras`, `obras-paralisadas`→`obras`, `categorias-obras`→`obras`. |
| Botões da UI | `prefecture/works/index.blade.php:10` | `has_access(['obras.create'])` controla a exibição dos botões "Categorias de Obras" e "Criar Obra". |
| Botões por linha | `DatatableController::struct_views($data, 'obras')` | Injeta `edit`, `delete`, `active`, `inactivate` conforme `has_access("obras.edit")`, `has_access("obras.delete")`, `has_access("obras.restore")`, `has_access("obras.inactivate")`. |
| Item de menu | `MenuServiceProvider` (`case 'obras'`) | Exibe o submenu "Obras" apenas se `has_access("obras.index")`. |

> **O `check.logged` não é aplicado às rotas deste módulo.** O `CheckLogged`
> (`v5/app/Http/Middleware/CheckLogged.php`) redireciona para `dashboard.index`
> quando não há sessão; as rotas de obras **não** o declaram. A proteção real
> vem do `check.route.permission`, que redireciona para `login`. Diferença
> prática: a URL de destino do redirect.

## 8. Formato de saída (JSON / resources)

| Endpoint | Formato |
|---|---|
| `GET /cms/obras/get-geolocation-data?address=...` | `{"data": {"lat": 123.45, "lng": -54.32}}` (200) ou `{"error": "..."}` (500) |
| `POST /cms/galerias-obras` | `{"success": "Imagem foi cadastrada com sucesso!", "data": {...archive}}` (200) ou `{"erro": "..."}` (500) |
| `GET /cms/galerias-obras/{id}` | `{"data": [ {id, path, extension, name}, ... ]}` (200) |
| `DELETE /cms/obras/{id}` | `{"success": "Obra excluída com sucesso"}` ou `{"error": "Ocorreu um erro ao excluir a obra."}` (500) |
| `DELETE /cms/categorias-obras/{id}` | `{"success": ...}` / `{"erro": ...}` (404 / 400 / 500) |
| `GET /cms/datatable/listagem_obras` | JSON do Yajra DataTables: `draw`, `recordsTotal`, `recordsFiltered`, `data[]` |
| `GET /obras` (com `X-Requested-With: XMLHttpRequest`) | JSON do `LengthAwarePaginator` serializado |

**Não há API Resources/Presenters** no módulo — o JSON do portal é a
serialização direta do paginator do Eloquent.
