# Concursos e PSS — Regras de Negócio e Portabilidade

> Módulo exclusivo da v5. As "armadilhas" listadas aqui são o que precisa ser
> **corrigido** na v7 sem quebrar o comportamento observável.

---

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

| # | Regra | Onde está | Consequência prática |
|---|---|---|---|
| R1 | `slug = str_slug("{tipo}-{numero}-{ano}-{id}")` | `v5/app/Http/Model/Prefecture/ConcursoPss.php:31-38` | O slug muda quando `tipo`, `numero` ou `ano` mudam — **quebra links públicos já publicados**. |
| R2 | O slug é gravado **depois** do INSERT | hook `created` (linha 31) | Duas escritas no cadastro; o `created` dispara `save()` → `updating` → recalcula o mesmo valor. |
| R3 | `tipo` e `situacao` são **texto livre** | `ConcursoPssRequest` linhas 19 e 23 | Sem normalização: "Concurso Público", "CONCURSO PÚBLICO" e "Concurso Publico" são valores distintos; o filtro de tipo do portal é exato. |
| R4 | A lista de tipos do portal é **fixa** | `v5/app/Http/Controllers/Portal/ConcursosPssController.php:39-43` | Certames com tipo fora da lista não podem ser filtrados por tipo. |
| R5 | A busca textual afeta **somente `descricao`** | `ConcursosPssController@index` linha 29 | Buscar pelo número do edital ou pela situação **não** encontra o registro. |
| R6 | A busca é insensível a acento **e** a maiúsculas | `translate(lower(descricao), ...)` | "SAUDE" encontra "Saúde" — desde que o termo buscado também seja normalizado por `clean_slug()`. |
| R7 | A exclusão é soft delete | `SoftDeletes` no model | O registro sai da listagem (view SQL) mas continua no banco e é encontrado por `ConcursoPss::find($id)` no CMS. |
| R8 | `validade` é obrigatória apenas se enviada; formato `dd/mm/aaaa` | `ConcursoPssRequest` linha 26 | Uma data em `Y-m-d` é **rejeitada** pela validação, embora seja o formato armazenado. |
| R9 | Anexos são **acumulativos** | `ConcursoPssController@update` | Editar sem anexar mantém os arquivos anteriores; não há como substituir um arquivo pelo formulário. |
| R10 | A remoção de anexo é por **índice posicional** | `destroyArquivo()` | Remover o 1º arquivo renumera os demais; o botão da tela usa o índice renderizado no momento do carregamento. |
| R11 | A exportação respeita os filtros e ignora a paginação | `ConcursosPssController@index` linhas 47-49 | `?type=xls` baixa **todos** os registros filtrados. |
| R12 | Os 4 formatos de exportação usam o **mesmo template Blade** | `ConcursosPssExport` + `exports/concursos-pss.blade.php` | PDF, XLSX e CSV compartilham a mesma view; só o PDF recebe o cabeçalho com título e data. |
| R13 | A ordenação pública é `ano DESC, created_at DESC` | `ConcursosPssController@index` linha 19 | Os certames mais antigos do ano mais recente aparecem antes dos mais novos do mesmo ano. |
| R14 | A numeração "ID" do CMS é o `row_number()` da view | `concursos_pss_view` | O número exibido **muda** a cada inserção/exclusão e não coincide com a ordem do portal. |
| R15 | Vagas são exibidas como `-` quando nulas | views do portal e de exportação | Não há valor default; usa `?? '-'` no Blade. |

## 2. Acoplamentos

### 2.1 Dependências (o módulo usa)

| Dependência | Ponto de acoplamento |
|---|---|
| `configurations.layout` | `getTemplateActived()` escolhe o tema do portal; valor inválido **ativa a manutenção** e retorna 404. |
| `resources` (id 16) + `roles.permissions` | Autorização via `has_access()` / `CheckRoutePermission`. |
| `menu_orders` (id 36) | Criado pela migration, **nunca lido** por este módulo. |
| `menu_items` / `category_contents` | O menu visível do portal é o item estático "Concurso Público" (id 35/59), **não** o módulo novo. |
| `storage/app/public/docs` | Diretório de upload (`saveUpload($file, 'docs', ...)`). |
| `public/storage` symlink | Necessário para os downloads (`url('storage/' . path)`); criado por `artisan storage:link`, não versionado. |
| `adminlte::page` | Renderiza o flash message do CMS. |
| `assets/js/custom/utilities/table.js` | `runDatatables()`, `createBTN()`, `addDelete()`. |

### 2.2 Dependências de outros módulos

| Módulo | Relação |
|---|---|
| **Autenticação (Sentinel)** | `check.logged`, `check.route.permission`, `has_access()`. |
| **Configurações do portal** | `configurations.layout` define o tema ativo. |
| **Menus do site** | O item "Concurso Público" (`menu_items` id 35 e 59 → `category_contents_id = 18`) é a página estática legada que coexistia com o módulo. |
| **Dashboard / "mais populares"** | A v6 ainda exibe o texto "Concurso público" como link morto em `v6/resources/views/portal/layouts/modelo6/layout/footer/mais-populares.blade.php:26`. |

### 2.3 O módulo **não** depende de

Licitações — não há relação com `biddings`, `archives`, `estagiarios` ou
`licitantes_sancionados`. É um módulo **isolado**: nenhuma FK, nenhuma relação
Eloquent, nenhum Repository compartilhado.

## 3. Armadilhas

### 3.1 SQL concatenado / `whereRaw`

```php
$q->whereRaw(
    "translate(lower(descricao), ' ''àáâãäéèëêíìïîóòõöôúùüûçÇ', '--aaaaaeeeeiiiiooooouuuucc') ilike ?",
    ['%' . clean_slug($busca) . '%']
);
```

| Problema | Detalhe |
|---|---|
| SQL cru com literal accentuado | O mapa de tradução está embutido na string; trocar a normalização exige editar SQL. |
| **Função na coluna** | `translate(lower(descricao))` impede o uso de índice em `descricao`; a busca é varredura sequencial. |
| **Dupla normalização** | O lado esquerdo normaliza no SQL; o lado direito normaliza em PHP (`clean_slug`). As tabelas precisam coincidir — `clean_slug` também remove `ñ`→`n`, que o `translate` **não** cobre. |
| `%` e `_` não escapados | O termo é usado dentro de `%...%` sem `LIKE ... ESCAPE`; um `_` na busca casa qualquer caractere. |
| Coluna sem índice | `descricao` é `text`; sem `pg_trgm` a busca é O(n). |

**Equivalência na v7:** manter o comportamento (busca sem acento, só em `descricao`),
mas com índice de trigonometria (`pg_trgm`) ou `unaccent` do PostgreSQL, e com
escape de `%`/`_`.

### 3.2 Paginação em memória (sem paginação no banco)

```php
$result = $query->get();                     // carrega TUDO
if ($request->type) { return $this->export($result, $request->type); }
$result = $this->paginate($result->all(), 15, $request->get('page'));
```

```php
private function paginate($items, $perPage, $page)
{
    $page   = $page ?: 1;
    $offset = ($page - 1) * $perPage;
    $items  = collect($items);

    return new \Illuminate\Pagination\LengthAwarePaginator(
        $items->slice($offset, $perPage)->values(),
        $items->count(),
        $perPage,
        $page,
        ['path' => request()->url(), 'query' => request()->query()]
    );
}
```

| Problema | Impacto |
|---|---|
| `->get()` sem `limit` | Memória e tempo lineares no número de certames. |
| Paginação em PHP | Não há `LIMIT`/`OFFSET` no banco; o `total()` vem do `count()` da coleção. |
| Parâmetros de URL não validados | `?page=abc` → `$offset` nulo/errado; `?page=-1` → `slice()` com offset negativo. |
| `$perPage` fixo em 15 | Não configurável. |
| `query` no construtor + `appends(request()->query())` na view | Duplicação da query string. |

**Equivalência na v7:** `->paginate(15)` real no banco, com `per_page` validado.

### 3.3 N+1 e queries desnecessárias

| Local | Query | Observação |
|---|---|---|
| Portal `index()` | `selectRaw('ano')->distinct()->pluck('ano')` | Query extra a cada render, mesmo sem uso do filtro. |
| Portal `index()` | `$query->get()` + `->all()` | Duplicação do array em memória. |
| CMS `index()` | nenhuma | A listagem do CMS busca tudo via AJAX do DataTable (client-side). |
| Exportação | reusa o `$result` já carregado | Sem query extra, mas sem limite de volume. |

**Não há N+1 clássico** (nenhum loop com query dentro), mas há **carregamento
integral** da tabela em dois pontos.

### 3.4 `flash()` — duplo flash no tratamento de erro

```php
} catch (\Exception $e) {
    flash($e->getMessage())->error();
}

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

O `catch` **não** retorna: a execução cai no `flash()` genérico, que **sobrescreve**
a mensagem original da exceção. O usuário sempre vê a mensagem genérica e a causa
real (stack trace) é perdida. O mesmo padrão está no `update()`.

### 3.5 `activity()` — ausência de auditoria

O pacote `spatie/laravel-activitylog` está instalado e é usado em outros módulos
(ex.: `v5/app/Http/Controllers/Prefecture/ContractController.php:341`), mas
**`ConcursoPssController` não registra nenhuma atividade**. Cadastrar, editar,
excluir e remover anexos de um certame **não deixam rastro** em `activity_log`.

### 3.6 `menuClicks()` — contador órfão

A migration `2026_05_27_000002_insert_concurso_pss_menu_and_resource.php` cria a
linha `menu_orders.slug = 'concurso-pss'` (id 36), mas o módulo **nunca** chama
`menuClicks('concurso-pss')`. O contador fica em `0` para sempre. A função existe
em `v5/app/Helpers/functions.php:822` e é usada por outros módulos
(`menuClicks('licitacao')`, `menuClicks('agenda-evento')`, ...).

### 3.7 `str_slug()` — helper depreciado

`str_slug()` foi renomeado para `Str::slug()` no Laravel 5.8 e **removido** no 6.0.
A v7 precisa de `Str::slug()` com o mesmo resultado (o legado usa o transliterator
padrão do Laravel).

### 3.8 Manipulação de arquivo fora do model

O controller grava o JSON do `arquivo` à mão em três lugares (`store`, `update`,
`destroyArquivo`), com normalizações ligeiramente diferentes. O arquivo físico
removido da lista **continua no disco** — `FileTrait::deleteFile()` nunca é chamado.

### 3.9 Inconsistência de namespace do model

`ConcursoPss` está em `App\Http\Model\Prefecture` (40 arquivos do diretório) e
não em `App\Prefecture` (55 arquivos). A v7 deve unificar em `App\Models`.

### 3.10 URLs relativas nos botões do DataTable

`concursos-pss/editar/{id}` e `concursos-pss/excluir/{id}` dependem da URL atual.
Funcionam em `/cms/concursos-pss`, quebram em qualquer outro prefixo ou subpasta.

### 3.11 Ausência de `use` do `Request` tipado no controller do CMS

`prepareData($request)` e `destroyArquivo($id, $index)` recebem o request **sem
tipagem** (`prepareData` não tem type hint de `Request`), o que impede a análise
estática e o IDE de completar métodos.

### 3.12 `authorize()` sempre `true`

`ConcursoPssRequest::authorize()` retorna `true`; a autorização depende
inteiramente do middleware `check.route.permission`, o que **não** acontece em
chamadas internas (testes, console, jobs).

## 4. Código duplicado / copiado

| Duplicação | Onde |
|---|---|
| 18 views de portal quase idênticas | `v5/resources/views/portal/layouts/modelo{1..6}/concursos-pss/` |
| Normalização do JSON `arquivo` em 3 lugares | `store`, `update`, `destroyArquivo` + 2 views |
| Exportação Excel/CSV reaproveitando a view de PDF | `ConcursosPssExport` + `exports/concursos-pss.blade.php` |
| Bloco de anexos | idêntico ao de `estagiarios` e `licitantes-sancionados` (`v5/resources/views/prefecture/estagiarios/form.blade.php`, `.../licitantes-sancionados/form.blade.php`) — inclusive a função `deleteConcursoArquivo` reproduz a dos vizinhos. |

## 5. Onde a v6 divergiu da v5

A v6 **não implementou** o módulo. Não há controller, model, request, export, view,
rota, migration nem resource para `concurso-pss`. Detalhamento em
`08-GAPS-E-DIVIDAS.md` (seção "Regressão v5 -> v6").

O que a v6 **mantém** do domínio: apenas o texto "Concurso público" como link
estático (`<a href="#">`) no rodapé "mais procurados"
(`v6/resources/views/portal/layouts/modelo6/layout/footer/mais-populares.blade.php:26`)
e a entrada estática "Concurso Público" nos menus da v5
(`menu_items` id 35 e 59 → `category_contents_id = 18`).

## 6. Checklist de equivalência funcional para a v7

| # | Item que **não pode** mudar |
|---|---|
| E1 | URL pública `/concursos-pss` e `/concurso-pss/{slug}`. |
| E2 | Nomes dos parâmetros de filtro: `ano`, `tipo`, `busca`, `type`, `page`. |
| E3 | Semântica da busca: "contém", sem acento, **só em `descricao`**. |
| E4 | Formato do slug (`tipo-numero-ano-id`) e o fato de o portal usar esse slug para localizar o registro. |
| E5 | Os 4 formatos de exportação (`xls`, `pdf`, `csv`, `txt`) e os nomes de arquivo (`concursos-pss.*`). |
| E6 | A exportação baixa todos os registros filtrados, não só a página. |
| E7 | Paginação de 15 itens. |
| E8 | Campos e obrigatoriedade: `tipo`, `numero`, `ano`, `descricao`, `situacao` obrigatórios; vagas, validade e anexos opcionais. |
| E9 | Limite de 10 MB e formato PDF dos anexos. |
| E10 | Soft delete (o registro não some do banco). |
| E11 | Slugs de rota do CMS: `concurso-pss.index`, `.create`, `.edit`, `.update`, `.store`, `.destroy`, `.destroy-arquivo`. |
| E12 | Slug da resource de permissão: `concurso-pss` (singular). |
