# Licitantes Sancionados — Rotas, Controllers e Validações

> Todas as rotas abaixo existem **apenas na v5**.

---

## 1. Rotas do portal (público)

Arquivo: `v5/routes/web.php`, dentro do grupo `Route::middleware(['check.site.manutention'])`
que abre na linha 5 e fecha na linha 213.

```php
// v5/routes/web.php:157-159
##########Licitantes Sancionados##############################################
Route::any('licitantes-sancionados', 'Portal\LicitantesSancionadosController@index')->name('licitantes-sancionados.index');
Route::get('licitante-sancionado/{slug}', 'Portal\LicitantesSancionadosController@show')->name('licitantes-sancionados.show');
```

| Método | URI | Nome da rota | Controller@ação | Middleware |
|---|---|---|---|---|
| ANY | `/licitantes-sancionados` | `licitantes-sancionados.index` | `Portal\LicitantesSancionadosController@index` | `check.site.manutention` (grupo), `web` |
| GET | `/licitante-sancionado/{slug}` | `licitantes-sancionados.show` | `Portal\LicitantesSancionadosController@show` | `check.site.manutention` (grupo), `web` |

Observações:

- `Route::any` permite GET/POST na listagem; o controller só lê `$request`, sem efeito
  colateral.
- A URL do detalhe é **singular** (`licitante-sancionado/{slug}`), enquanto a da
  listagem é **plural** (`licitantes-sancionados`) — inconsistência de nomenclatura
  que se repete no prefixo das permissões.
- **Sem** `check.logged` e **sem** `check.route.permission`: são rotas públicas.
- A listagem aceita query string: `ano`, `busca`, `dt_inicio`, `dt_fim`.

### Migalhas (breadcrumbs)

```php
// v5/routes/breadcrumbs.php:179-188
// Home > Licitantes Sancionados
Breadcrumbs::register('licitantes-sancionados', function ($breadcrumbs) {
    $breadcrumbs->parent('home');
    $breadcrumbs->push('Licitantes Sancionados', route('licitantes-sancionados.index'));
});

Breadcrumbs::register('licitantes-sancionados.show', function ($breadcrumbs, $sancionado) {
    $breadcrumbs->parent('licitantes-sancionados');
    $breadcrumbs->push($sancionado->nome_razao_social);
});
```

O arquivo é carregado por `v5/config/breadcrumbs.php` (`'files' => base_path('routes/breadcrumbs.php')`).

## 2. Rotas do CMS (`/cms`)

Arquivo: `v5/routes/basepro.php`. Todas estão dentro do grupo
`Route::group(['prefix' => 'cms', 'before' => 'csrf'], ...)` que abre na linha 35.

```php
// v5/routes/basepro.php:346-353
// Licitantes Sancionados
Route::get('licitantes-sancionados', ['uses' => 'Prefecture\LicitanteSancionadoController@index'])->name('licitante-sancionado.index')->middleware('check.logged');
Route::get('licitantes-sancionados/cadastrar', ['uses' => 'Prefecture\LicitanteSancionadoController@create'])->name('licitante-sancionado.create')->middleware('check.logged');
Route::get('licitantes-sancionados/editar/{id}', ['uses' => 'Prefecture\LicitanteSancionadoController@edit'])->name('licitante-sancionado.edit')->middleware('check.logged');
Route::put('licitantes-sancionados/atualizar/{id}', ['uses' => 'Prefecture\LicitanteSancionadoController@update'])->name('licitante-sancionado.update');
Route::post('licitantes-sancionados', ['uses' => 'Prefecture\LicitanteSancionadoController@store'])->name('licitante-sancionado.store');
Route::delete('licitantes-sancionados/excluir/{id}', ['uses' => 'Prefecture\LicitanteSancionadoController@destroy'])->name('licitante-sancionado.destroy');
Route::delete('licitantes-sancionados/{id}/arquivo/{index}', ['uses' => 'Prefecture\LicitanteSancionadoController@destroyArquivo'])->name('licitante-sancionado.destroy-arquivo');
```

| Método | URI completa | Nome da rota | Controller@ação | Middleware explícito |
|---|---|---|---|---|
| GET | `/cms/licitantes-sancionados` | `licitante-sancionado.index` | `index` | `check.logged` |
| GET | `/cms/licitantes-sancionados/cadastrar` | `licitante-sancionado.create` | `create` | `check.logged` |
| GET | `/cms/licitantes-sancionados/editar/{id}` | `licitante-sancionado.edit` | `edit` | `check.logged` |
| PUT | `/cms/licitantes-sancionados/atualizar/{id}` | `licitante-sancionado.update` | `update` | — |
| POST | `/cms/licitantes-sancionados` | `licitante-sancionado.store` | `store` | — |
| DELETE | `/cms/licitantes-sancionados/excluir/{id}` | `licitante-sancionado.destroy` | `destroy` | — |
| DELETE | `/cms/licitantes-sancionados/{id}/arquivo/{index}` | `licitante-sancionado.destroy-arquivo` | `destroyArquivo` | — |

Além do middleware de rota, **todas** as ações recebem
`check.route.permission` — ele é registrado no construtor da classe base
`v5/app/Http/Controllers/Prefecture/Controller.php:22`:

```php
public function __construct()
{
    $this->middleware('check.route.permission');
}
```

### Rota do DataTable (CMS)

```php
// v5/routes/basepro.php:409-422
Route::group(['prefix' => 'datatable', 'as' => 'datatable.'], function () {
    // ...
    Route::get('/licitante-sancionado', 'Prefecture\DatatableController@get_licitante_sancionado')->name('licitante-sancionado');
});
```

| Método | URI | Nome | Action | Middleware |
|---|---|---|---|---|
| GET | `/cms/datatable/licitante-sancionado` | `datatable.licitante-sancionado` | `Prefecture\DatatableController@get_licitante_sancionado` | apenas `web` + `csrf` (grupo) |

⚠ Esta rota **não tem** `check.logged` nem `check.route.permission` explícitos. O
`DatatableController` estende `App\Http\Controllers\Prefecture\Controller`, que
registra `check.route.permission` no construtor — portanto a proteção existe, mas via
controller, não via rota. O nome `datatable.licitante-sancionado` cai na lista
`$openRoutes` do middleware (`'datatable'`), o que faz `hasAccess()` retornar `true`
antecipadamente para qualquer rota cujo nome contenha `datatable`.

## 3. Controller do CMS — `Prefecture\LicitanteSancionadoController`

Arquivo: `v5/app/Http/Controllers/Prefecture/LicitanteSancionadoController.php` (143 linhas).

| Método | Linha | Retorno | Comportamento |
|---|---|---|---|
| `index()` | 15-18 | `View` | Renderiza `prefecture.licitantes-sancionados.index` (a listagem é preenchida por JS) |
| `create()` | 20-23 | `View` | Renderiza `prefecture.licitantes-sancionados.create` |
| `store(Request)` | 25-50 | `RedirectResponse` | Valida, grava anexos, cria o registro, redireciona ao índice |
| `edit($id)` | 52-62 | `View` | Busca por `id`; se não achar, `flash('Registro não encontrado!')->error()` e volta ao índice |
| `update(Request, $id)` | 64-95 | `RedirectResponse` | `findOrFail`; **soma** os anexos existentes aos novos e atualiza |
| `destroy($id)` | 97-107 | `JsonResponse` | `findOrFail` + `delete()` (soft delete) → `{"success": "..."}` (200) ou `{"erro": "..."}` (500) |
| `destroyArquivo($id, $index)` | 109-127 | `JsonResponse` | Remove o item `$index` do JSON de `arquivo`; 404 se o índice não existir, 500 em exceção |
| `prepareData($request)` | 129-141 | `array` | Filtra 6 campos e converte as datas de `d/m/Y` para `Y-m-d` |

### `store` — trecho real

```php
// v5/app/Http/Controllers/Prefecture/LicitanteSancionadoController.php:25-50
public function store(LicitanteSancionadoRequest $request): RedirectResponse
{
    try {
        $data = $this->prepareData($request);

        if ($request->hasFile('arquivo')) {
            $items = [];
            $descs = $request->input('arquivo_desc', []);
            foreach ($request->file('arquivo') as $i => $file) {
                $path = $this->saveUpload($file, 'docs', null, null, false);
                $items[] = ['path' => "docs/{$path}", 'description' => $descs[$i] ?? ''];
            }
            $data['arquivo'] = json_encode($items);
        }

        LicitanteSancionado::create($data);

        flash('Registro cadastrado com sucesso.')->success();
        return redirect()->route('licitante-sancionado.index');
    } catch (\Exception $e) {
        flash($e->getMessage())->error();
    }

    flash('Ocorreu um erro ao tentar cadastrar o registro.')->error();
    return redirect()->back();
}
```

Pontos que merecem atenção:

- O `catch` **não** interrompe: depois de `flash($e->getMessage())->error()` o fluxo
  continua e dispara **um segundo** `flash('Ocorreu um erro ao tentar cadastrar o
  registro.')->error()`, redirecionando para `back()`. O operador vê duas mensagens
  de erro (a exceção e a genérica). Ver `08-GAPS-E-DIVIDAS.md`, G6.
- O nome da rota de redirecionamento é `licitante-sancionado.index` (singular) —
  existe em `basepro.php:347`.
- `saveUpload($file, 'docs', null, null, false)`: o último `false` desliga a
  compressão (importante, pois `FileTrait::compress()` chamaria `getimagesize()` em um
  PDF). O nome do arquivo gerado é aleatório (`uniqid` + `str_random(5)` + extensão
  original), então o nome original do PDF **não é preservado**.

### `prepareData` — conversão de datas

```php
// v5/app/Http/Controllers/Prefecture/LicitanteSancionadoController.php:129-141
private function prepareData($request): array
{
    $data = $request->only(['ano', 'numero_sancao', 'nome_razao_social', 'cpf_cnpj', 'inicio_sancao', 'fim_sancao']);

    if (!empty($data['inicio_sancao'])) {
        $data['inicio_sancao'] = date('Y-m-d', strtotime(str_replace('/', '-', $data['inicio_sancao'])));
    }

    if (!empty($data['fim_sancao'])) {
        $data['fim_sancao'] = date('Y-m-d', strtotime(str_replace('/', '-', $data['fim_sancao'])));
    }

    return $data;
}
```

A conversão troca `/` por `-` e usa `strtotime()`. Com `d/m/Y` isso é ambiguo para
dias ≤ 12: `05/06/2026` → `05-06-2026` → interpretado como **5 de junho** pelo
`strtotime()` (confirmado no container PHP 7.2), enquanto `26/05/2026` →
`26-05-2026` → 26 de maio. O formato `d/m/Y` do request evita o problema quando o
usuário digita `dd/mm/aaaa` com dois dígitos, mas `5/6/2026` passaria a 5 de junho
(aceito pelo `date_format:d/m/Y`? — o `date_format` do Laravel exige `d/m/Y` com
`d`/`m` de 2 dígitos, então `5/6/2026` é rejeitado). Ver `05-REGRAS-E-PORTABILIDADE.md`.

## 4. Controller do portal — `Portal\LicitantesSancionadosController`

Arquivo: `v5/app/Http/Controllers/Portal/LicitantesSancionadosController.php` (58 linhas).

| Método | Linha | Comportamento |
|---|---|---|
| `index(Request)` | 11-49 | Monta a query com 4 filtros, pagina 15 por página, escolhe a view do template ativo |
| `show($slug)` | 51-57 | `LicitanteSancionado::where('slug', $slug)->firstOrFail()` e renderiza a ficha |

O controller **estende `App\Http\Controllers\Admin\Controller`** (e não um controller
`Portal\Controller` — que nem existe no repositório) para herdar `getTemplateActived()`.

```php
// v5/app/Http/Controllers/Portal/LicitantesSancionadosController.php:51-57
public function show($slug)
{
    $sancionado = LicitanteSancionado::where('slug', $slug)->firstOrFail();

    return view(
        'portal.layouts.' . $this->getTemplateActived() . '.licitacao.sancionados.show',
        compact('sancionado')
    );
}
```

A resolução do template:

```php
// v5/app/Http/Controllers/Admin/Controller.php:39-48
protected function getTemplateActived()
{
    $templateActived = Configuration::where('key', 'layout')->first()->value;

    if (!View::exists('portal.layouts.'.$templateActived.'.home.index')) {
        DB::table('configurations')->where('key', 'pagina_em_manutencao')->update(['value' => 1]);
        abort(404, 'view_inexistente');
    }

    return $templateActived;
}
```

Valor atual no banco v5: `configurations.layout = 'modelo1'`. Os templates
`default` e `newsbit` **não** possuem `licitacao/sancionados/` — se o layout ativo
fosse um deles, a rota do portal quebraria com *View not found* (a verificação acima
só protege a existência de `home.index`, que existe nesses dois templates mas cujas
pastas de licitação não contêm o módulo). Ver `04-VIEWS-E-UX.md`.

## 5. Form Request — `LicitanteSancionadoRequest`

Arquivo: `v5/app/Http/Requests/LicitanteSancionadoRequest.php`.

```php
public function authorize()
{
    return true;
}

public function rules()
{
    $id = $this->route('id');

    return [
        'ano'              => 'required|integer|min:1900|max:2100',
        'numero_sancao'    => 'required|string|max:50',
        'nome_razao_social'=> 'required|string|max:255',
        'cpf_cnpj'         => 'required|string|max:18',
        'inicio_sancao'    => 'required|date_format:d/m/Y',
        'fim_sancao'       => 'required|date_format:d/m/Y',
        'arquivo'          => 'nullable|array',
        'arquivo.*'        => 'file|mimes:pdf|max:10240',
        'arquivo_desc'     => 'nullable|array',
        'arquivo_desc.*'   => 'nullable|string|max:255',
    ];
}
```

| Campo | Regra | Efeito no operador |
|---|---|---|
| `ano` | `required|integer|min:1900|max:2100` | Ano da sanção; faixa ampla (aceita 1900 e 2100) |
| `numero_sancao` | `required|string|max:50` | Número do processo |
| `nome_razao_social` | `required|string|max:255` | Razão social |
| `cpf_cnpj` | `required|string|max:18` | Máscara aplicada no cliente; **sem validação de dígito verificador** |
| `inicio_sancao` | `required|date_format:d/m/Y` | Obrigatória; formato brasileiro |
| `fim_sancao` | `required|date_format:d/m/Y` | Obrigatória; **não há validação de `fim >= inicio`** |
| `arquivo` | `nullable|array` | Lista de arquivos (o formulário envia `arquivo[]`) |
| `arquivo.*` | `file|mimes:pdf|max:10240` | Cada anexo: PDF, até 10 MB |
| `arquivo_desc` | `nullable|array` | Descrições (o formulário envia `arquivo_desc[]`) |
| `arquivo_desc.*` | `nullable|string|max:255` | Cada descrição até 255 caracteres |

Observações:

- `authorize()` retorna `true`: **não há autorização no request**. A proteção vem do
  middleware `check.route.permission`.
- A variável `$id = $this->route('id');` é atribuída e **nunca usada** (código morto).
- A regra `'arquivo' => 'nullable|array'` convive com a mensagem
  `'arquivo.required' => 'Pelo menos um arquivo PDF é obrigatório.'` — como a regra é
  `nullable`, a mensagem **nunca é disparada**. Ver `08-GAPS-E-DIVIDAS.md`, G3.
- Não há `unique`, nem verificação de sobreposição de períodos para o mesmo
  sancionado.

## 6. API Resources / Presenters

**Não existem.** Não há nenhuma classe de API Resource para o módulo, nem rota em
`v5/routes/api.php`. A saída do CMS é o JSON do Yajra DataTables:

```php
// v5/app/Http/Controllers/Prefecture/DatatableController.php:188-192
public function get_licitante_sancionado()
{
    $sancionados = DB::table('licitantes_sancionados_view')->get();
    return DataTables::of($this->struct_views($sancionados, 'licitante-sancionado'))->make(true);
}
```

E `struct_views()` injeta as flags de permissão por linha:

```php
// v5/app/Http/Controllers/Prefecture/DatatableController.php:508-524
public function struct_views($data, $permission)
{
    for ($i = 0; $i < count($data); $i++) {
        $data[$i]->edit = has_access("$permission.edit");

        if ($permission === 'diario-oficial') {
            $data[$i]->delete = Sentinel::inRole('super-admin') || Sentinel::inRole('administrador');
        } else {
            $data[$i]->delete = has_access("$permission.delete");
        }

        $data[$i]->active = has_access("$permission.restore");
        $data[$i]->inactivate = has_access("$permission.inactivate");
    }

    return $data;
}
```

| Campo injetado | Permissão | Uso no JS |
|---|---|---|
| `edit` | `licitante-sancionado.edit` | Exibe o botão de editar (`createBTN(..., data.edit, ...)`) |
| `delete` | `licitante-sancionado.delete` | Exibe o botão de excluir |
| `active` | `licitante-sancionado.restore` | Não usado por este módulo |
| `inactivate` | `licitante-sancionado.inactivate` | Não usado por este módulo |

## 7. Permissões exigidas (papel/guard)

| Recurso | Permissão | Onde é exigida |
|---|---|---|
| Listagem do CMS | `licitante-sancionado.index` | Menu (`MenuServiceProvider.php:106`) e via middleware (nome da rota `licitante-sancionado.index`) |
| Formulário de cadastro | `licitante-sancionado.create` | Botão "Cadastrar" (`index.blade.php`) |
| Botão editar (por linha) | `licitante-sancionado.edit` | `struct_views()` |
| Botão excluir (por linha) | `licitante-sancionado.delete` | `struct_views()` |
| Ações `store`/`update`/`destroy`/`destroy-arquivo` | `licitante-sancionado.{store,update,destroy,destroy-arquivo}` | `check.route.permission` deriva o slug do **nome da rota** |

Como resolver as permissões:

```php
// v5/app/Library/Helper/appHelpers.php:22-44
function has_access($access = null)
{
    if (!Sentinel::check()) {
        return false;
    }
    if (!isset($access)) {
        return true;
    }
    if (Sentinel::inRole('super-admin')) {
        return true;
    }
    $slugs = is_array($access) ? $access : [$access];
    $slugs = resolve_permission_aliases($slugs);
    return Sentinel::check()->hasAnyAccess($slugs);
}
```

O middleware `check.route.permission` (`v5/app/Http/Middleware/CheckRoutePermission.php`)
combina o **nome da rota** com o **segmento da URL** e resolve com
`hasAnyAccess()`. Como o slug `licitante-sancionado` **não existe** na tabela
`resources` do banco v5, nenhum perfil além de `super-admin` possui as chaves
correspondentes — impacto registrado em `08-GAPS-E-DIVIDAS.md`, G5.

## 8. Resumo de endpoints

```text
PÚBLICO
  GET  /licitantes-sancionados                     → Portal\LicitantesSancionadosController@index
  GET  /licitante-sancionado/{slug}                → Portal\LicitantesSancionadosController@show

CMS (prefixo /cms)
  GET    /cms/licitantes-sancionados               → Prefecture\LicitanteSancionadoController@index
  GET    /cms/licitantes-sancionados/cadastrar     → ...@create
  GET    /cms/licitantes-sancionados/editar/{id}   → ...@edit
  PUT    /cms/licitantes-sancionados/atualizar/{id}→ ...@update
  POST   /cms/licitantes-sancionados               → ...@store
  DELETE /cms/licitantes-sancionados/excluir/{id}  → ...@destroy
  DELETE /cms/licitantes-sancionados/{id}/arquivo/{index} → ...@destroyArquivo
  GET    /cms/datatable/licitante-sancionado       → Prefecture\DatatableController@get_licitante_sancionado
```
