# Licitantes Sancionados — Gaps, Bugs e Dívidas

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

---

## 1. Bugs e erros conhecidos

| # | Sev. | Achado | Evidência | Efeito |
|---|---|---|---|---|
| B1 | **Alta** | `slug` **sem índice único** e **nullable** | `\d licitantes_sancionados` (banco v5): único índice = `licitantes_sancionados_pkey`; migration `2026_05_26_000002_add_slug_to_licitantes_sancionados_table.php` cria `$table->string('slug')->nullable()` | Duas linhas podem compartilhar slug; a rota `show` usa `where('slug',$slug)->firstOrFail()` e abriria a **primeira** ocorrência. Não há garantia de unicidade no banco |
| B2 | **Alta** | A busca textual do portal **não funciona com mais de uma palavra** | `v5/app/Http/Controllers/Portal/LicitantesSancionadosController.php:24-28` — a coluna é normalizada por `translate()` (minúsculas + **espaço→hífen**), mas o termo passa por `clean_slug()`, que **preserva** espaços e maiúsculas. Verificado no banco: `ILIKE '%Joao da Silva%'` sobre `joao-da-silva-ltda` → `f`; `ILIKE '%Silva%'` → `t` | O filtro principal do portal, quando usado com nome completo, **retorna zero resultados** |
| B3 | **Alta** | Nenhum perfil além de `super-admin` consegue operar o módulo | `resources` do banco v5 não tem slug `licitante-sancionado` (`SELECT ... WHERE slug ILIKE '%licitante%'` → 0 rows) e `roles.permissions` não tem a chave (`WHERE permissions::text LIKE '%licitante-sancionado%'` → 0) | Item de menu "Sancionados" nunca aparece; `check.route.permission` barra o acesso; botão "Cadastrar" fica oculto para todos os perfis |
| B4 | **Média** | Duas rotinas de geração de `slug` produzem valores **diferentes** para o mesmo nome | migration (SQL `translate` com **48** caracteres de origem e **46** de destino) vs. model (`str_slug` → `Str::ascii`) | Verificado: `'Construções Ação & Cia Ltda'`+id 42 → migration `construnoes-anao-cia-ltda-42`; model `construcoes-acao-cia-ltda-42`. Registros de origem diferente ficam com URLs distintas para o mesmo nome |
| B5 | **Média** | `whereRaw` com **SQL concatenado** | `Portal\LicitantesSancionadosController.php:24,26` | Sem bindings. Hoje não é injetável porque `clean_slug()` remove aspas, mas a proteção é acidental. Também quebra em MySQL (`translate()`/`ilike` são do PostgreSQL) |
| B6 | **Média** | O `catch` do `store`/`update` não interrompe o fluxo | `v5/app/Http/Controllers/Prefecture/LicitanteSancionadoController.php:44-49` e `89-95` | Duas mensagens de erro são exibidas (a exceção **e** a genérica) e o redirecionamento é para `back()`, não para a rota de índice |
| B7 | **Média** | O `slug` é **recalculado** a cada `update` | `v5/app/Http/Model/Prefecture/LicitanteSancionado.php:37-39` | Renomear o sancionado **quebra** a URL pública já divulgada e a migalha de breadcrumb |
| B8 | **Média** | Remover anexo **não apaga** o arquivo do storage | `LicitanteSancionadoController@destroyArquivo` (linhas 109-127) só reescreve o JSON | PDFs acumulados em `storage/app/public/docs/`; o `soft delete` do registro também não os remove |
| B9 | **Média** | Nome original do PDF **perdido** | `v5/app/Http/Traits/FileTrait.php:78-82` (`uniqid` + `str_random(5)`) | O link de download mostra `5f3a1b2c3d.pdf`; o operador não identifica o documento pelo nome |
| B10 | **Média** | O filtro de datas do portal é aplicado a `inicio_sancao` **apenas** | `Portal\LicitantesSancionadosController.php:31-36` | Uma sanção iniciada antes da janela e ainda vigente **não** aparece; uma sanção iniciada dentro da janela e já encerrada **aparece** |
| B11 | **Média** | A rota do DataTable do CMS fica **fora** do grupo `check.logged` | `v5/routes/basepro.php:422` | A proteção vem do construtor de `Prefecture\Controller` (middleware de controller). O nome `datatable.licitante-sancionado` casa com a `$openRoutes` do middleware (`'datatable'`), que retorna `true` antes de checar a permissão — o `check.route.permission` ainda exige sessão, mas **não** a permissão `licitante-sancionado.index` |
| B12 | **Baixa** | Coluna "ID" do DataTable não corresponde aos dados | `v5/resources/views/prefecture/licitantes-sancionados/index.blade.php` (cabeçalho "ID") vs. `assets/js/custom/config/licitantes-sancionados/datatable.js` (sem coluna `id`; a coluna `value` = `row_number()` é inserida por `runDatatables`) | O número exibido é a **posição na view SQL**, não o `id` do registro |
| B13 | **Baixa** | Asterisco de "campo obrigatório" não aparece | `v5/resources/views/components/inputs/input.blade.php` compara `$mandatory == 'true'`; o form passa `true` (booleano) | O operador não visualiza quais campos são obrigatórios, embora o request os exija |
| B14 | **Baixa** | Filtros de data ausentes no template `modelo4` | `diff` entre `modelo1/licitacao/sancionados/filtro.blade.php` e `modelo4/licitacao/sancionados/filtro.blade.php` | Com o layout `modelo4` ativo, o filtro por data não existe; o botão "Limpar campos" também limpa campos diferentes em `modelo5`/`modelo6` |
| B15 | **Baixa** | Views do módulo não existem em `default` nem `newsbit` | `v5/resources/views/portal/layouts/default/` (sem pasta `licitacao/`) e `newsbit/licitacao/` (sem `sancionados/`) | Se `configurations.layout` for `default` ou `newsbit`, a rota do portal retorna *View not found* |
| B16 | **Baixa** | `public/storage` não existe no repositório v5 | `v5/public/storage` ausente | Sem `php artisan storage:link` no deploy, **todos** os downloads de anexo do portal retornam 404 |
| B17 | **Baixa** | A view SQL expõe a coluna `arquivo` (JSON com caminhos) no JSON do DataTable | `v5/database/migrations/2026_05_26_000001_create_view_licitantes_sancionados.php` | Information disclosure desnecessário no payload do CMS (caminhos internos de arquivos) |
| B18 | **Baixa** | A rota do portal aceita qualquer método HTTP | `v5/routes/web.php:158` — `Route::any('licitantes-sancionados', ...)` | `POST /licitantes-sancionados` renderiza a mesma listagem; superfície de ataque desnecessária |

## 2. Funcionalidades incompletas ou mortas

| Item | Situação |
|---|---|
| Mensagem `'arquivo.required' => 'Pelo menos um arquivo PDF é obrigatório.'` | **Inalcançável**: a regra é `'arquivo' => 'nullable\|array'`, nunca `required`. Anexos são de fato opcionais, apesar do texto |
| Variável `$id = $this->route('id');` no `LicitanteSancionadoRequest::rules()` | **Código morto**: atribuída e nunca usada |
| `protected $dates = [..., 'deleted_at']` no model | Redundante: `SoftDeletes` já trata `deleted_at` |
| `slug` no `$fillable` do model | Nunca mass-assigned pelo controller (o model define o valor), mas permite sobrescrita se algum código futuro enviar o campo |
| Flags `active` / `inactivate` injetadas por `struct_views()` | Não utilizadas pelo JS deste módulo (só `edit` e `delete` são consumidos) |
| Exportação no portal | Inexistente na v5 (ao contrário de `TransparencyController`, que aceita `?type=`) |
| API pública | Inexistente na v5 — não há rota em `v5/routes/api.php` nem controller correspondente |
| Log de atividade (`activity()`) | Inexistente para este módulo, embora `ContentController`, `ContractController`, `BiddingController` e outros o utilizem |
| Menu no portal | Não há entrada em `menu_items`/`links` para `/licitantes-sancionados` (verificado no banco v5) — a página pública só é acessível por URL direta ou link externo |
| Normalização de CNPJ alfanumérico | A máscara do formulário aceita `AA.AAA.AAA/AAAA-00`, mas não há validação de dígito verificador nem normalização para o formato canônico |
| Filtro "somente sanções vigentes" | Não existe: o portal publica indistintamente sanções encerradas e vigentes |
| Índice para `ano` e `inicio_sancao` | Inexistentes; a listagem pública ordena por `ano DESC, nome_razao_social` e filtra por `inicio_sancao` sem índice dedicado |
| `resources` do módulo | Não cadastrado — o módulo não pode ser habilitado para nenhum perfil pelo CMS |

## 3. Regressão v5 -> v6

A v6 **removeu o módulo por completo**. Não é uma reimplementação com lacunas: não
existe mais **nenhuma** peça do módulo.

| Camada | v5 | v6 | Verificação |
|---|---|---|---|
| **Tabela** | `licitantes_sancionados` (11 colunas, 0 linhas) | **ausente** | `SELECT to_regclass('public.licitantes_sancionados')` → `NULL` no banco v6 |
| **View SQL** | `licitantes_sancionados_view` | **ausente** | `SELECT to_regclass('public.licitantes_sancionados_view')` → `NULL` |
| **Model** | `v5/app/Http/Model/Prefecture/LicitanteSancionado.php` | **ausente** | `grep -r "LicitanteSancionado" v6/` → 0 ocorrências |
| **Controller do CMS** | `v5/app/Http/Controllers/Prefecture/LicitanteSancionadoController.php` (143 linhas) | **ausente** | idem |
| **Controller do portal** | `v5/app/Http/Controllers/Portal/LicitantesSancionadosController.php` (58 linhas) | **ausente** | idem |
| **Form Request** | `v5/app/Http/Requests/LicitanteSancionadoRequest.php` | **ausente** | idem |
| **Migrations (3)** | `v5/database/migrations/2026_05_26_00000{0,1,2}_*.php` | **ausentes** | `ls v6/database/migrations/ \| grep -i licit\|sancion` → vazio |
| **Views do CMS (4)** | `v5/resources/views/prefecture/licitantes-sancionados/{index,create,edit,form}.blade.php` | **ausentes** | idem |
| **Views do portal (19)** | `modelo1..modelo6/licitacao/sancionados/{index,show,filtro}` + `portal/components/sancionados/table.blade.php` | **ausentes** | idem |
| **JS do DataTable** | `v5/public/assets/js/custom/config/licitantes-sancionados/datatable.js` | **ausente** | idem |
| **Rotas do portal** | `v5/routes/web.php:158-159` (`licitantes-sancionados.index`, `.show`) | **ausentes** | `grep -r "licitantes-sancionados" v6/routes/` → 0 |
| **Rotas do CMS** | `v5/routes/basepro.php:347-353` + `:422` (datatable) | **ausentes** | idem |
| **Breadcrumbs** | `v5/routes/breadcrumbs.php:180-188` | **ausentes** | idem |
| **Item de menu do CMS** | `v5/app/Providers/MenuServiceProvider.php:106-108` | **ausente** | idem |
| **Resource `licitante-sancionado`** | ausente | ausente | `resources` do banco v6: 79 registros, `slug ILIKE '%licitante%'` → 0 |
| **Módulo em `modulos`** | tabela `modulos` não existe na v5 | banco v6 tem 58 módulos (`modulos`), **nenhum** de licitantes sancionados | `SELECT id, name, slug FROM modulos` |

### O que a prefeitura perdeu

| Capacidade | Impacto |
|---|---|
| Publicação de sanções a licitantes | O cidadão **não tem mais** onde consultar se está impedido de participar de um certame. Isso é perda de **transparência e de defesa do contraditório**: um licitante impedido só descobre a sanção ao ser inabilitado, sem acesso ao histórico das sanções |
| Download dos documentos comprobatórios | Perde-se o canal oficial de acesso aos processos de sanção |
| Filtro por ano, nome e período | Perde-se a consulta histórica por exercício |
| Registro de sanções com vigência definida | Nenhum substituto existe na v6 |

### Observação sobre a causa

O módulo da v5 já era **inoperável para os perfis comuns** (B3: falta o resource
`licitante-sancionado`) e estava com a tabela **vazia** (0 linhas) e **sem** item de
menu no portal. Isso sugere que a funcionalidade foi implantada e abandonada sem
operacionalização, o que possivelmente motivou a remoção na v6. Ainda assim, a
remoção é **funcionalmente relevante** para o citizenês e deve ser revertida na v7 —
o dossiê `06-MIGRACAO-MODERNA.md` descreve como.

## 4. Riscos de dados (migração de schema)

| Risco | Detalhe | Mitigação na v7 |
|---|---|---|
| `slug` inconsistente entre origens | Registros gerados pelo model e pelo backfill SQL têm slugs diferentes para o mesmo nome | Escolher **uma** rotina (`Str::slug`), normalizar todos os registros e criar índice único |
| Slugs duplicados | Sem índice único, dois registros podem ter o mesmo slug | Detectar duplicatas antes de criar o índice; desambiguar com sufixo numérico |
| JSON em coluna `text` | `arquivo` não é consultável, não tem ordem garantida e não valida | Migrar para tabela 1:N `sanctioned_bidder_attachments` |
| `arquivo` legado em formato string | Registros antigos podem ter `arquivo` como string pura (o código normaliza com `?: [$sancionado->arquivo]`) | Tratar os dois formatos no script de migração |
| Arquivos órfãos no storage | Anexos removidos/registros excluídos permanecem em `storage/app/public/docs/` | Migrar os arquivos e limpar os não referenciados; reportar órfãos |
| `public/storage` ausente | Links `url('storage/...')` quebram | `php artisan storage:link` no deploy + teste de download |
| Sem `deleted_at` na view SQL | A view filtra por `deleted_at IS NULL` — se a regra mudar, a view diverge do model | Eliminar a view e usar um escopo do model |
| Sem índice para os filtros do portal | `ano`, `inicio_sancao` e `company_name` sem índice | `index(['year', 'sanction_starts_at'])`,GIN/trigram em `company_name`/`tax_id` |
| Formato de data ambíguo | `strtotime` com `dd-mm-yyyy` é ambíguo para dias ≤ 12 | `Carbon::createFromFormat('d/m/Y', ...)` no request |
| Ausência de rastreabilidade | Sem log de atividade, não se sabe quem cadastrou a sanção | `spatie/laravel-activitylog` no model + tela de log |

## 5. Perguntas em aberto para o negócio

1. **Escopo do público**: a listagem de sancionados deve ser pública para qualquer
   visitante, ou apenas para licitantes cadastrados? A v5 é pública.
2. **Vigência vs. histórico**: o portal deve listar **todas** as sanções ou apenas as
   vigentes? Hoje publica todas, e o filtro é por data de **início** — o que não
   responde "quem está impedido agora".
3. **Sanções de pessoa física × jurídica**: o campo é `nome_razao_social` e o
   `cpf_cnpj` aceita 11 ou 14 dígitos. A mesma sanção pode ser duplicada com CPF ou
   CNPJ? Como deduplicar?
4. **Origem da sanção**: falta o órgão sancionador (Controladoria, Ordenador,
   Tribunal de Contas). Sem isso, o cidadão não sabe **quem**
   sancionou nem **qual o fundamento** (lei/decreto).
5. **Publicação da decisão**: é preciso publicar o ato decisório (o "decreto"/"decisão")
   ou apenas a listagem e os anexos?
6. **Sigilo de dados pessoais**: para pessoa física, o CPF completo é exibido no
   portal. Isso é compatível com a LGPD? A v7 deve mascarar (há precedent: o
   `DataMaskTrait` da v6 mascara CPF/CNPJ em outros módulos).
7. **Permissões**: quem deve cadastrar/editar sanções — apenas a Controladoria
   interna, ou também procurement? O fluxo de aprovação (rascunho → publicado) faz
   falta?
8. **Auditoria**: é exigida trilha de auditoria (quem alterou, quando, o quê)? Se sim,
   a v5 tem uma lacuna que a v7 precisa fechar.
9. **Filtro por vigência**: o filtro atual é por **ano** e por **período de início**.
   É suficiente, ou é preciso o intervalo de **vigência** (sanções que cobrem a data
   de hoje)?
10. **Integração com o sistema de licitações**: o portal de licitações (`/licitacoes`)
    deve consultar esta lista para avisar o licitante antes de se habilitar?
11. **Exportação**: o portal não exporta. A Controladoria precisa de relatório em
    XLSX/PDF para instrução de processo?
12. **Retenção**: por quanto tempo a sanção permanece publicada após o fim da vigência?
    A LGPD e a Lei de Improbidade prescrevem prazos; hoje não há regra.

## 6. Dívidas técnicas prioritárias

| Prioridade | Dívida | Esforço na v7 |
|---|---|---|
| P0 | Reverter a regressão: recriar tabela, model, controllers, request, rotas, views, permissões e API | Médio |
| P0 | Corrigir B1 (índice único em `slug` + geração determinística) | Baixo |
| P0 | Corrigir B2 (busca multi-palavra) usando bindings e normalização consistente | Baixo |
| P0 | Corrigir B3 (criar o resource/permissão e vincular ao módulo de Licitações) | Baixo |
| P1 | Corrigir B4 (uma única rotina de slug, com backfill unificado) | Baixo |
| P1 | Corrigir B7 (slug estável, sem recalcular no update) | Baixo |
| P1 | Corrigir B6 (retornar dentro do `catch`; uma única mensagem de erro) | Baixo |
| P1 | Corrigir B8/B9 (apagar o arquivo do storage ao remover o anexo; preservar o nome original) | Médio |
| P1 | Adicionar log de atividade (created/updated/deleted) | Baixo |
| P1 | Corrigir B10 (permitir filtrar por vigência: início ≤ ref AND fim ≥ ref) | Baixo |
| P1 | Migrar anexos para tabela 1:N com metadados | Médio |
| P2 | Corrigir B5 (bindings em vez de `whereRaw` concatenado) | Baixo |
| P2 | Corrigir B11 (mover a rota do DataTable para dentro do grupo protegido / corrigir a `$openRoutes`) | Baixo |
| P2 | Corrigir B12 (alinear o cabeçalho "ID" com os dados, ou exibir o `id` real) | Baixo |
| P2 | Corrigir B13 (`@if ($mandatory)` em vez de `== 'true'`) | Baixo |
| P2 | Corrigir B14 (unificar o filtro de datas em todos os templates ou levar as views para fora do eixo de template) | Médio |
| P2 | Corrigir B15 (garantir a presença das views em todos os templates ou desacoplar do template) | Médio |
| P2 | Corrigir B15/B16 (`storage:link` no deploy + teste de download) | Baixo |
| P3 | Corrigir B17 (remover `arquivo` da view SQL) | Baixo |
| P3 | Corrigir B18 (`Route::get` em vez de `Route::any` no portal) | Baixo |
| P3 | Adicionar paginação server-side no DataTable do CMS | Baixo |
| P3 | Normalizar e validar CPF/CNPJ (dígito verificador), com suporte ao alfanumérico | Médio |
| P3 | Adicionar exportação no portal (XLSX/PDF/CSV) | Médio |
| P3 | Cache da lista de anos e índices de performance | Baixo |
