# Licitantes Sancionados — Regras, Armadilhas e Portabilidade

> Tudo aqui foi verificado por leitura de código, execução no container PHP 7.2 da v5 e
> consultas **read-only** ao PostgreSQL v5.

---

## 1. Regras de negócio não óbvias

| # | Regra | Onde está | Implicação |
|---|---|---|---|
| R1 | O `slug` é gerado **a partir do nome + id** (`str_slug(nome . '-' . id)`) | `v5/app/Http/Model/Prefecture/LicitanteSancionado.php:32,38` | O slug é estável apenas enquanto o nome não muda |
| R2 | O `slug` é **recalculado** a cada `update` | mesmo arquivo, evento `updating` | Renomear o sancionado **quebra** a URL pública já divulgada |
| R3 | O evento `created` faz `$model->save()` para gravar o slug | mesmo arquivo, linha 33-34 | Um `INSERT` seguido de um `UPDATE` — o slug só existe após a segunda escrita |
| R4 | O filtro de datas do portal é sobre **`inicio_sancao`**, não sobre o período | `v5/app/Http/Controllers/Portal/LicitantesSancionadosController.php:31-36` | Uma sanção iniciada antes da janela mas ainda vigente **não** aparece |
| R5 | O portal **não** filtra por sanções vigentes | idem | Sanções encerradas continuam publicadas; não há "somente vigentes" |
| R6 | A listagem do CMS vem da **view SQL**, o portal do **model** | `DatatableController.php:190` vs `LicitantesSancionadosController.php:14` | Duas fontes de leitura com regras de exclusão duplicadas |
| R7 | A exclusão é *soft delete*; `destroyArquivo` também é soft (via `save()`) | `LicitanteSancionadoController.php:103,120` | Remover um anexo dispara `updating` → recalcula o slug (sem efeito prático) |
| R8 | Anexos são **somados** na edição, nunca substituídos | `LicitanteSancionadoController.php:71-84` | Não há como substituir o conjunto de anexos; é preciso excluir um a um |
| R9 | O nome original do PDF **não é preservado** | `v5/app/Http/Traits/FileTrait.php:78-82` (`uniqid` + `str_random(5)`) | O link de download mostra um nome aleatório (`5f3a....pdf`), não o nome do documento |
| R10 | `arquivo` guarda **JSON** em coluna `text` | `LicitanteSancionadoController.php:38,84` | Não há coluna normalizada nem índice para busca por anexo |
| R11 | O botão "Cadastrar" depende de `licitante-sancionado.create` | `index.blade.php` | Como o slug não existe em `resources`, o botão só aparece para `super-admin` |
| R12 | A listagem pública **não** tem item de menu no portal | ausência de registro em `menu_items`/`links` (verificado no banco v5) | O acesso público depende de link externo ou digitação da URL |

## 2. Acoplamentos

### Dependências de outros módulos

| Acoplamento | Detalhe |
|---|---|
| `MenuServiceProvider` | Cria o item de menu "Sancionados" dentro do `case 'licitacao'` do switch sobre `menu_orders.slug` — o item **só** existe se houver uma linha em `menu_orders` com `slug = 'licitacao'` (existe: id 6, 20 cliques) **e** se o usuário tiver `licitante-sancionado.index` |
| `check.route.permission` | Deriva a permissão a partir do **nome da rota** (`licitante-sancionado.store` etc.) e do **segmento da URL** (`licitantes-sancionados`) |
| `resolve_permission_aliases()` | `v5/app/Library/Helper/appHelpers.php:44-65` — só mapeia `obra-paralisada`, `obras-paralisadas` e `categorias-obras` para `obras`. **Não** há alias para `licitante-sancionado` |
| `configurations.layout` | Escolhe o template do portal; muda o caminho da view em runtime |
| `Configuration` (model) | `v5/app/Http/Model/Prefecture/Configuration.php` — lido em `getTemplateActived()` |

### O módulo **não** é consumido por nenhum outro

Busca por `licitantes_sancionados`, `LicitanteSancionado` e `sancionados` em `v5/app/`
retorna apenas: o controller do CMS, o controller do portal, o model, o request, o
`DatatableController` e o `MenuServiceProvider`. Não há API pública, feed RSS, página
estática ou widget que consuma o módulo.

## 3. Armadilhas reais

### A1 — `whereRaw` com SQL montado à mão (busca do portal)

```php
// v5/app/Http/Controllers/Portal/LicitantesSancionadosController.php:22-28
$query->when($request->busca, function ($q) use ($request) {
    $busca = $request->busca;
    $q->where(function ($inner) use ($busca) {
        $inner->whereRaw("translate(lower(nome_razao_social), ' ''àáâãäéèëêíìïîóòõöôúùüûçÇ', '--aaaaaeeeeiiiiooooouuuucc') ilike '%" . clean_slug($busca) . "%'")
              ->orWhereRaw("translate(lower(cpf_cnpj), ' ''àáâãäéèëêíìïîóòõöôúùüûçÇ', '--aaaaaeeeeiiiiooooouuuucc') ilike '%" . clean_slug($busca) . "%'");
    });
});
```

- O valor é concatenado, **não** parametrizado. `clean_slug()`
  (`v5/app/Helpers/functions.php:780`) remove acentos e caracteres fora de
  `A-Za-z0-9. `, o que neutraliza a injeção de aspas — mas a proteção é **acidental**,
  não estrutural. Um `'` é removido pelo regex, então a busca não é injetável hoje.
- `translate()` e `ilike` são **funções do PostgreSQL**: a query não funciona em MySQL.
- A tabela de transliteração do SQL e o helper `clean_slug()` **não coincidem**:
  - `clean_slug()` preserva **maiúsculas/minúsculas** e **espaços**;
  - o `translate()` do banco converte **minúsculas** e **espaços em hífen**.
  Resultado verificado no banco: buscar `Joao da Silva` **não** encontra
  `joao-da-silva-ltda`. Só a busca por **palavra única** funciona:

```sql
SELECT translate(lower('JOAO DA SILVA LTDA'), ' ''àáâãäéèëêíìïîóòõöôúùüûçÇ', '--aaaaaeeeeiiiiooooouuuucc')
       ILIKE '%Joao da Silva%'  AS multi_palavra,
       translate(lower('JOAO DA SILVA LTDA'), ' ''àáâãäéèëêíìïîóòõöôúùüûçÇ', '--aaaaaeeeeiiiiooooouuuucc')
       ILIKE '%Silva%'          AS palavra_unica;
-- multi_palavra = f | palavra_unica = t
```

- O mesmo desalinhamento do `translate()` do portal ocorre na migration do slug
  (origem 48 × destino 46).

### A2 — `slug_fix()` vs `clean_slug()` vs `str_slug()`: três algoritmos

| Helper | Local | Comportamento |
|---|---|---|
| `str_slug()` | global do Laravel 5.5 (`v5/vendor/laravel/framework/src/Illuminate/Support/helpers.php:991`) | minúsculo, translitera via `Str::ascii()`, troca separadores por `-` |
| `clean_slug()` | `v5/app/Helpers/functions.php:780` | remove acentos, **mantém maiúsculas e espaços**, remove tudo fora de `A-Za-z0-9. ` |
| `slug_fix()` | `v5/app/Helpers/functions.php:771` | `clean_slug()` + espaços→`-` + collapse de `-` |

O model usa `str_slug()`; a migration usa SQL; a busca do portal usa `clean_slug()`.
**Três rotinas diferentes** para o mesmo conceito. Consequência verificada: o mesmo
nome gera slugs diferentes conforme a origem do registro.

```text
'Construções Ação & Cia Ltda' + id 42
  migration (SQL)  → construnoes-anao-cia-ltda-42
  model (str_slug) → construcoes-acao-cia-ltda-42
```

### A3 — a view SQL como contrato de leitura

`Prefecture\DatatableController@get_licitante_sancionado` lê
`licitantes_sancionados_view`, e o JS consome colunas **por nome**
(`ano`, `numero_sancao`, `nome_razao_social`, `cpf_cnpj`, `inicio_sancao`, `fim_sancao`).
Renomear ou remover uma coluna na view **quebra silenciosamente** a tabela do CMS
(coluna vazia), sem erro de PHP. A view também expõe `arquivo` (JSON completo com
caminhos de arquivos) no JSON do DataTable, embora a tabela não o exiba.

### A4 — `has_access()` e a ausência do resource

`has_access(['licitante-sancionado.index'])` consulta
`Sentinel::check()->hasAnyAccess(...)`, que compara com as chaves do JSON
`roles.permissions` (formato `"<resource-slug>.<permissao>"`). Como a tabela
`resources` do banco v5 **não tem** o slug `licitante-sancionado` e nenhum papel
possui as chaves correspondentes, **apenas `super-admin`** opera o módulo. O item de
menu "Sancionados" também **nunca** aparece para os demais perfis.

### A5 — `check.route.permission` e nomes de rota vs. segmentos de URL

O middleware resolve a permissão a partir de **dois** candidatos:

```php
// v5/app/Http/Middleware/CheckRoutePermission.php:41-88
$access = Route::getCurrentRoute()->getName();
$accessArr = explode('.', $access);
$accessSegment = $this->hasAccessSegment();
```

Para a rota `licitante-sancionado.index` (CMS) os candidatos são
`licitante-sancionado.index` e `licitantes-sancionados.index` (segmento 2 da URL).
Para o portal, os nomes são `licitantes-sancionados.index` / `.show` (plural).
Ou seja, **o mesmo conceito usa singular no CMS e plural no portal** — e o prefixo de
permissão é o singular. A inconsistência é absorvida pelo `hasAnyAccess()`, que
aceita qualquer um dos candidatos, mas quebra qualquer verificação pontual.

### A6 — o `catch` que não interrompe

```php
// v5/app/Http/Controllers/Prefecture/LicitanteSancionadoController.php:44-49
    } catch (\Exception $e) {
        flash($e->getMessage())->error();
    }

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

Após o `catch`, o fluxo **continua** e dispara um segundo `flash()`. O operador vê
**duas** mensagens de erro. O mesmo padrão existe em `update()` (linhas 89-95).

### A7 — `flash()` do `laracasts/flash` com sintaxe de encadeamento invertida

```php
flash('Registro cadastrado com sucesso.')->success();
```

`flash('msg')` retorna o `FlashNotifier` já com a mensagem adicionada e
persistida em `session('flash_notification')`; `->success()`/`->error()` **sem
argumento** apenas atualizam o **nível** da última mensagem
(`updateLastMessage(['level' => ...])`). Funciona, mas é frágil: a mensagem só
aparece se o helper global `flash()` (de `laracasts/flash/src/Laracasts/Flash/functions.php`)
estiver carregado — o que ocorre via `composer.json` → `"autoload": {"files": ["app/Helpers/functions.php"]}`.

### A8 — `activity()` (log de atividades) ausente

O módulo **não** registra log de atividade, ao contrário de
`Prefecture\ContentController`, `Prefecture\ContractController`,
`Prefecture\Bidding\BiddingController` e outros, que usam
`activity()->performedOn(...)->causedBy(...)->log(...)`. Consequência: não há
rastreabilidade de quem cadastrou ou alterou uma sanção — dado de **transparência e
legalidade** (a Lei de Abuso de Autoridade e a Lei Anticorrupção exigem
rastreabilidade de agentes públicos, e a sanção é o registro de uma decisão).

### A9 — `str_slug()` e `str_random()` foram removidos do Laravel

`str_slug()` e `str_random()` são helpers globais do Laravel 5.5, **deprecados em
5.8 e removidos em 6.0**. Na v7 (Laravel 11/12) é obrigatório usar
`Str::slug()` e `Str::random()`.

### A10 — o DataTable carrega a tabela inteira

`DB::table('licitantes_sancionados_view')->get()` materializa **todos** os registros
(no banco v5, hoje 0) e o Yajra devolve o JSON completo. Não há paginação
server-side nem `serverSide: true` no JS. A busca, a ordenação e a paginação são
client-side. O padrão se repete em `get_fila_espera_sus`, `get_terceirizados` e
`get_obras_paralisadas` — é herança do `DatatableController` da v5.

### A11 — `prepareData()` e a ambiguidade de `strtotime()`

```php
$data['inicio_sancao'] = date('Y-m-d', strtotime(str_replace('/', '-', $data['inicio_sancao'])));
```

`strtotime('05-06-2026')` devolve **2026-06-05** (dia-mês-ano) no PHP 7.2, mas
`strtotime('2026-05-26')` (ISO) também funciona e `strtotime('26-05-2026')` devolve
2026-05-26. O `date_format:d/m/Y` do request exige `dd/mm/aaaa` com dois dígitos,
o que mantém a conversão coerente — mas qualquer chamada direta a
`prepareData()` com formato diferente quebra silenciosamente.

### A12 — nomes de arquivo e `Storage::disk('public')`

`saveUpload()` grava via `$file->storeAs($dir, "{$nameFile}")` no disk **default**
(`v5/config/filesystems.php`: `'default' => env('FILESYSTEM_DRIVER', 'public')`).
O `public/storage` **não existe** no repositório v5 (`v5/public/storage` ausente) —
ele precisa ser criado por `php artisan storage:link` no deploy. Sem isso, todos os
links de download do portal (`url('storage/' . $path)`) retornam 404.

### A13 — `getFileName()` e a checagem de existência

```php
// v5/app/Http/Traits/FileTrait.php:84-87
private function fileExistsInPath($nameFile, $dir): bool
{
    return ($nameFile && file_exist("storage/{$dir}/{$nameFile}"));
}
```

`file_exist()` (`v5/app/Helpers/functions.php:264`) é um wrapper de `file_exists()`,
mas o caminho é **relativo** (`storage/docs/...`), dependente do diretório de
trabalho do processo. Além disso, como o nome é aleatório, a colisão é improvável —
a checagem existe para reaproveitar arquivos, o que na prática nunca acontece.

### A14 — a exclusão de anexo não remove o arquivo físico

`destroyArquivo()` reescreve apenas o JSON da coluna `arquivo`. O PDF continua em
`storage/app/public/docs/`. Não há chamada a `Storage::delete()`. O mesmo vale para
`destroy()` (soft delete): os anexos permanecem no disco.

## 4. Código duplicado ou copiado

| Duplicação | Onde | Observação |
|---|---|---|
| Controller do CMS | `LicitanteSancionadoController` é praticamente idêntico a `EstagiarioController` e `ConcursoPssController` | Mesma estrutura `store`/`update`/`destroyArquivo`, mesmo `prepareData`, mesma máscara de CPF/CNPJ |
| Controller do portal | `LicitantesSancionadosController` segue o padrão de `EstagiariosController` | Filtros com `when()`, `paginate(15)`, view por template |
| Views do portal | `index`/`show`/`filtro` copiados em 6 templates | Diferem só no `@extends` e no `include` |
| Migrations | `2026_05_26_000000/1/2` com o mesmo padrão de `2026_05_26_*` dos módulos irmãos | Sem `foreignId`, sem `->index()` |
| JS do DataTable | `assets/js/custom/config/licitantes-sancionados/datatable.js` × módulos irmãos | Estrutura idêntica, só as colunas mudam |

## 5. Onde a v6 divergiu da v5

A v6 **removeu o módulo inteiro**. Não há:

| Elemento | v5 | v6 |
|---|---|---|
| Tabela `licitantes_sancionados` | ✔ (0 linhas) | ✖ (`to_regclass` retorna `NULL`) |
| View `licitantes_sancionados_view` | ✔ | ✖ |
| Model | `v5/app/Http/Model/Prefecture/LicitanteSancionado.php` | ✖ |
| Controller do CMS | `v5/app/Http/Controllers/Prefecture/LicitanteSancionadoController.php` | ✖ |
| Controller do portal | `v5/app/Http/Controllers/Portal/LicitantesSancionadosController.php` | ✖ |
| Form Request | `v5/app/Http/Requests/LicitanteSancionadoRequest.php` | ✖ |
| Views do CMS | `v5/resources/views/prefecture/licitantes-sancionados/*` | ✖ |
| Views do portal | `v5/resources/views/portal/layouts/modelo{1..6}/licitacao/sancionados/*` + `portal/components/sancionados/table.blade.php` | ✖ |
| JS | `v5/public/assets/js/custom/config/licitantes-sancionados/datatable.js` | ✖ |
| Rotas do portal | `v5/routes/web.php:158-159` | ✖ |
| Rotas do CMS | `v5/routes/basepro.php:347-353, 422` | ✖ |
| Breadcrumbs | `v5/routes/breadcrumbs.php:180-188` | ✖ |
| Item de menu do CMS | `MenuServiceProvider.php:106-108` | ✖ |
| Resource `licitante-sancionado` | ✖ (ausente nos dois bancos) | ✖ |
| Módulo no `modulos` (v6) | tabela `modulos` não existe na v5 | ✖ (58 módulos, nenhum de licitantes sancionados) |

Detalhamento e implicações em `08-GAPS-E-DIVIDAS.md`.

## 6. Regras de portabilidade (v5 → v7) — checklist

| # | Item | Ação na v7 |
|---|---|---|
| P1 | `str_slug()` | Trocar por `Str::slug()` |
| P2 | `str_random()` (em `FileTrait`) | Trocar por `Str::random()` |
| P3 | `clean_slug()` / `slug_fix()` (helpers em `app/Helpers/functions.php`) | Reimplementar com `Str::ascii()`/`Str::slug()` ou eliminar |
| P4 | `translate()` / `ilike` do PostgreSQL | Usar `unaccent` + `ILIKE` com **bindings**, ou normalizar no PHP |
| P5 | `strtotime()` para converter `d/m/Y` | Usar `Carbon::createFromFormat('d/m/Y', ...)` |
| P6 | View SQL `licitantes_sancionados_view` | Substituir por query Eloquent paginada ou um escopo do model |
| P7 | `check.route.permission` (middleware) | Substituir por **Policy** do Laravel |
| P8 | `has_access()` / Sentinel | Substituir por `Gate`/`@can` + `spatie/laravel-permission` ou Policies |
| P9 | `resources` + `roles.permissions` (JSON) | Modelar como tabela `permissions` + `role_user`/`permission_role` ou Policies |
| P10 | `flash()` do `laracasts/flash` | Usar `session()->flash()` + view de notificação, ou `laracasts/flash` v4+ |
| P11 | `activity()` (spatie) ausente | **Adicionar** log de atividade (created/updated/deleted) |
| P12 | `Form::open`/`Form::model` (laravelcollective/html) | Usar `<form>` + `@method('PUT')` + Blade components |
| P13 | `admin.layouts.app` / AdminLTE 1.22 | Migrar para Blade/AdminLTE 3 ou um design system próprio |
| P14 | `getTemplateActived()` lendo `configurations.layout` | Manter, mas **validar** a existência das views do módulo por template |
| P15 | Coluna `arquivo` com JSON em `text` | Criar tabela `sanction_attachments` (1:N) ou coluna `jsonb` |
| P16 | Ausência de índice único em `slug` | Criar `unique` em `slug` e gerar no `creating`/`saving` |
| P17 | Sem `fillable` para `slug` em mass-assignment | Manter `slug` fora do `$fillable` |
