# Concursos e PSS — Migração para Laravel Moderno

> Plano de reconstrução em PHP 8.x + Laravel 11/12 (a "v7"). Como o módulo
> **não existe** na v6, trata-se de uma **reimplementação**, não de uma porta.
> Os caminhos `v7/...` abaixo são propostas; não existem no repositório atual.

---

## 1. Decisões de equivalência funcional (o que NÃO pode mudar)

| Comportamento legado | Decisão na v7 |
|---|---|
| URL pública de listagem `/concursos-pss` | Manter (link externo e indexação já existentes). |
| URL de detalhe `/concurso-pss/{slug}` | Manter, com slug no mesmo formato `tipo-numero-ano-id`. |
| Parâmetros `ano`, `tipo`, `busca`, `page` | Manter os nomes (URLs salvas e links externos). |
| Exportação pela mesma URL com `?type=` | Manter **e** adicionar rota dedicada `/concursos-pss/exportar/{tipo}`. |
| 4 formatos (`xls`, `pdf`, `csv`, `txt`) e nomes `concursos-pss.*` | Manter. |
| Exportação baixa todos os registros filtrados | Manter (com teto configurável e aviso). |
| Busca "contém", sem acento, **só em `descricao`** | Manter; ampliar para `descricao` + `numero` apenas se o negócio aprovar. |
| Paginação de 15 | Manter como padrão, com `per_page` configurável. |
| Soft delete | Manter. |
| Anexos PDF até 10 MB, com descrição por arquivo | Manter; migrar para disco privado + download autenticado. |
| Slugs de rota do CMS (`concurso-pss.*`) | Manter, para não quebrar links salvos no AdminLTE. |
| Slug da resource `concurso-pss` (singular) | Manter — é a chave de permissão em `roles.permissions`. |

## 2. Schema de migração

```php
// v7/database/migrations/2026_10_21_000000_create_concursos_pss_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('concursos_pss', function (Blueprint $table) {
            $table->id();
            $table->string('tipo', 60);
            $table->string('numero', 20);
            $table->unsignedSmallInteger('ano');
            $table->text('descricao');
            $table->string('situacao', 60);
            $table->unsignedInteger('vagas_ofertadas')->nullable();
            $table->unsignedInteger('vagas_preenchidas')->nullable();
            $table->date('validade')->nullable();
            $table->json('arquivo')->nullable();
            $table->string('slug', 255)->unique();
            $table->timestampsTz();
            $table->softDeletesTz();

            $table->index(['ano', 'tipo']);
            $table->index('deleted_at');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('concursos_pss');
    }
};
```

Notas de migração de dados (a base v5 está **vazia** — `count(*) = 0` —, mas o
procedimento precisa estar pronto para outros ambientes):

1. `arquivo` era `text` com JSON. Migrar com um `UPDATE` que normalize os três
   formatos legados (`[{path,description}]`, `["path"]`, `"path"`) para o JSON
   canônico e só então aplicar o `CAST(arquivo AS json)`.
2. `slug` pode ser `NULL` na origem (linha inserida antes do hook `created`);
   recalcular com `Str::slug("{tipo}-{numero}-{ano}-{id}")`.
3. `timestamp(0)` sem timezone → `timestampTz`; aplicar `AT TIME ZONE 'America/Sao_Paulo'`.
4. Índice de busca: criar `pg_trgm` sobre `descricao` para a busca sem acento:
   ```sql
   CREATE EXTENSION IF NOT EXISTS pg_trgm;
   CREATE INDEX concursos_pss_descricao_trgm ON concursos_pss USING gin (descricao gin_trgm_ops);
   ```
5. Se o volume for grande, migrar `vagas_ofertadas`/`vagas_preenchidas` para
   `check` (`vagas_preenchidas <= vagas_ofertadas`), após saneamento.

### 2.1 View SQL

A v7 **não deve** precisar de view: o `row_number()` existia só para numerar as
linhas do DataTable do CMS. Com paginação server-side, o id real substitui o
`value`. Se ainda for desejado, criar:

```sql
-- v7/database/migrations/2026_10_21_000001_create_view_concursos_pss.php
CREATE OR REPLACE VIEW public.concursos_pss_view AS
SELECT row_number() OVER (ORDER BY cp.created_at DESC) AS value, cp.*
FROM concursos_pss cp
WHERE cp.deleted_at IS NULL
ORDER BY cp.created_at DESC;
```

### 2.2 Resource e módulo (integração com o `modulos` da v6)

O par `resources`/`menu_orders` da v5 é substituído por `modulos` +
`resources.modulo_id` (conforme
`v6/database/migrations/2024_11_01_150206_create_modulos_table.php` e
`v6/database/migrations/2024_11_05_104026_add_modulo_id_to_resources_table.php`).

```php
// v7/database/migrations/2026_10_21_000002_create_concurso_pss_modulo_and_resource.php
DB::table('modulos')->insert([
    'name' => 'Concursos e PSS',
    'slug' => 'concursos-pss',
    'status' => 1,
]);

DB::table('resources')->insert([
    'slug' => 'concurso-pss',
    'name' => 'Concursos e PSS',
    'description' => 'Módulo de Concursos Públicos e Processo Seletivo Simplificado',
    'modulo_id' => <id do módulo>,
]);
```

> O slug do **resource** deve continuar sendo `concurso-pss` (singular) para
> casar com as permissões já atribuídas em `roles.permissions` da v5.

## 3. Model

```php
// v7/app/Models/ConcursoPss.php
namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;
use Spatie\Activitylog\Traits\LogsActivity;

class ConcursoPss extends Model
{
    use HasFactory, SoftDeletes, LogsActivity;

    protected $table = 'concursos_pss';

    protected $fillable = [
        'tipo', 'numero', 'ano', 'descricao', 'situacao',
        'vagas_ofertadas', 'vagas_preenchidas', 'validade', 'arquivo',
    ];

    protected $casts = [
        'ano' => 'integer',
        'vagas_ofertadas' => 'integer',
        'vagas_preenchidas' => 'integer',
        'validade' => 'date',
        'arquivo' => 'array',
    ];

    protected static $logName = 'concurso-pss';

    protected static $logAttributes = [
        'tipo', 'numero', 'ano', 'descricao', 'situacao',
        'vagas_ofertadas', 'vagas_preenchidas', 'validade',
    ];

    protected static function booted(): void
    {
        static::saving(function (ConcursoPss $model) {
            $model->slug = Str::slug(
                $model->tipo.'-'.$model->numero.'-'.$model->ano.'-'.$model->getKey()
            );
        });
    }
}
```

Mudanças em relação à v5:

| Legado | v7 | Motivo |
|---|---|---|
| Hooks `created` + `updating` | Hook único `saving` | Elimina o segundo `save()` no INSERT e a corrida de slug nulo. |
| `str_slug()` | `Str::slug()` | `str_slug` foi removido no Laravel 6. |
| `$fillable` com `slug` | `slug` fora do `$fillable` | Impede mass-assignment do slug. |
| Sem casts | `casts` declarados | `validade` vira `Carbon`; `arquivo` vira array. |
| Sem auditoria | `LogsActivity` | Cria a trilha que faltava (ver Gaps). |
| Sem relacións | idem | O módulo é isolado; não há FK. |

**Sobre a quebra de slug:** recalcular o slug a cada `saving` replica o legado, mas
quebra links públicos. Alternativa recomendada: manter o slug original e expor um
`slug_anterior` (ou redirecionamento 301 do slug antigo). Decisão de negócio —
ver `08-GAPS-E-DIVIDAS.md`.

## 4. Enums e tabelas de apoio

```php
// v7/app/Enums/TipoConcurso.php
enum TipoConcurso: string
{
    case ConcursoPublico = 'Concurso Público';
    case ProcessoSeletivo = 'Processo Seletivo Simplificado';
    case TesteSeletivo = 'Teste Seletivo';
}

// v7/app/Enums/SituacaoConcurso.php
enum SituacaoConcurso: string
{
    case InscricoesAbertas = 'Inscrições Abertas';
    case InscricoesEncerradas = 'Inscrições Encerradas';
    case EmAnalise = 'Em Análise';
    case Homologado = 'Homologado';
    case Encerrado = 'Encerrado';
}
```

| Aspecto | Observação |
|---|---|
| Origem dos valores | A lista de `tipo` já existe hardcoded no controller v5 (3 valores) — o enum **formaliza** essa lista. |
| `situacao` | Não há lista de referência no legado (texto livre). Criar o enum exige **mapeamento do histórico**; enquanto não houver decisão, manter `string` com normalização (`Str::title`) e uma tabela `concursos_pss_situacoes`. |
| Migração | Se a base estiver vazia (como na v5), a migração é trivial; caso contrário, mapear valores livres para o enum mais próximo e registrar as divergências. |
| Filtro do portal | Com enum, o filtro de tipo passa a ser uma lista **derivada dos dados**, e não mais hardcoded. |

## 5. Form Requests

```php
// v7/app/Http/Requests/StoreConcursoPssRequest.php
namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class StoreConcursoPssRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()?->can('create', \App\Models\ConcursoPss::class) ?? false;
    }

    public function rules(): array
    {
        return [
            'tipo' => ['required', 'string', 'max:60', Rule::enum(TipoConcurso::class)],
            'numero' => ['required', 'string', 'max:20'],
            'ano' => ['required', 'integer', 'between:1900,2100'],
            'descricao' => ['required', 'string', 'max:5000'],
            'situacao' => ['required', 'string', 'max:60'],
            'vagas_ofertadas' => ['nullable', 'integer', 'min:0'],
            'vagas_preenchidas' => ['nullable', 'integer', 'min:0', 'lte:vagas_ofertadas'],
            'validade' => ['nullable', 'date'],
            'arquivo' => ['nullable', 'array', 'max:10'],
            'arquivo.*' => ['file', 'mimes:pdf', 'max:10240'],
            'arquivo_desc' => ['nullable', 'array', 'max:10'],
            'arquivo_desc.*' => ['nullable', 'string', 'max:255'],
        ];
    }
}
```

Mudanças em relação à v5:

| Legado | v7 | Motivo |
|---|---|---|
| `authorize()` → `true` | delega à Policy | Autorização também vale para chamadas internas. |
| `validade` com `date_format:d/m/Y` | `date` | O input nativo (`<input type="date">`) grava `Y-m-d`; o formato brasileiro vira apenas máscara de exibição. |
| Sem limite de `descricao` | `max:5000` | `text` sem teto é risco de payload. |
| Sem limite de quantidade de anexos | `arquivo: max:10` | Evita requisições gigantes. |
| Sem regra entre as vagas | `lte:vagas_ofertadas` | Coerência de dados. |
| `$isUpdate` não usado | removido | — |
| `UpdateConcursoPssRequest` | herda as regras | Unificar `Store`/`Update` numa classe só, ou usar `Rule::unique` quando aplicável. |

## 6. Policies e autorização

```php
// v7/app/Policies/ConcursoPssPolicy.php
class ConcursoPssPolicy
{
    public function viewAny(User $user): bool  { return $user->can('concurso-pss.index'); }
    public function view(User $user, ConcursoPss $m): bool { return $user->can('concurso-pss.show'); }
    public function create(User $user): bool   { return $user->can('concurso-pss.create'); }
    public function update(User $user, ConcursoPss $m): bool { return $user->can('concurso-pss.edit'); }
    public function delete(User $user, ConcursoPss $m): bool { return $user->can('concurso-pss.delete'); }
    public function deleteAttachment(User $user, ConcursoPss $m): bool { return $user->can('concurso-pss.edit'); }
}
```

- As permissões passam a vir do sistema de papéis da v7 (`modulos` +
  `resources.modulo_id`), e não do par `resources`/`permissions` da v5.
- O módulo precisa ser **recriado** em `modulos` + `resources` (ver 2.2) e vinculado
  aos papéis — na v5 **nenhum papel** tinha `concurso-pss.*`.
- No portal público **não há** Policy: listagem e detalhe são livres.

## 7. Endpoints sugeridos

### Portal (público)

| Método | URI | Descrição |
|---|---|---|
| GET | `/concursos-pss` | Listagem paginada + filtros (HTML). |
| GET | `/concursos-pss/{slug}` | Detalhe (HTML) — **manter a URL singular** do legado. |
| GET | `/concursos-pss/exportar/{tipo}` | Exportação (`xls`, `pdf`, `csv`, `txt`) com os filtros da query string. |
| GET | `/api/v1/concursos-pss` | Listagem JSON (paginado, com `ano`, `tipo`, `busca`). |
| GET | `/api/v1/concursos-pss/{slug}` | Detalhe JSON. |

### CMS (`/cms`)

| Método | URI | Descrição |
|---|---|---|
| GET | `/cms/concursos-pss` | Listagem (DataTable server-side ou Inertia). |
| GET | `/cms/concursos-pss/cadastrar` | Formulário. |
| POST | `/cms/concursos-pss` | Cadastro. |
| GET | `/cms/concursos-pss/editar/{id}` | Formulário de edição. |
| PUT | `/cms/concursos-pss/atualizar/{id}` | Atualização. |
| DELETE | `/cms/concursos-pss/excluir/{id}` | Exclusão (soft). |
| POST | `/cms/concursos-pss/{id}/arquivos` | Upload de anexos. |
| DELETE | `/cms/concursos-pss/{id}/arquivos/{uuid}` | Remoção de anexo (**uuid**, não índice). |
| GET | `/cms/concursos-pss/{id}/arquivos/{uuid}/download` | Download autenticado. |

## 8. API Resource

```php
// v7/app/Http/Resources/ConcursoPssResource.php
public function toArray(Request $request): array
{
    return [
        'id' => $this->id,
        'tipo' => $this->tipo,
        'numero' => $this->numero,
        'ano' => $this->ano,
        'descricao' => $this->descricao,
        'situacao' => $this->situacao,
        'vagas_ofertadas' => $this->vagas_ofertadas,
        'vagas_preenchidas' => $this->vagas_preenchidas,
        'validade' => $this->validade?->toDateString(),
        'arquivos' => collect($this->arquivo ?? [])->map(fn ($a) => [
            'uuid' => $a['uuid'] ?? null,
            'descricao' => $a['description'] ?? null,
            'url' => $a['uuid'] ? route('cms.concursos-pss.arquivo.download', [$this->id, $a['uuid']]) : null,
        ]),
        'slug' => $this->slug,
    ];
}
```

A API **pública** não deve expor o caminho interno do arquivo nem a chave
`descricao` completa se ela for o objeto do certame — avaliar com o negócio.

## 9. Views modernas

- **CMS:** Blade com componentes `<x-concurso-pss.form>` (ou Inertia/Vue), com
  `<input type="date">`, `<input type="number">` e um componente de upload
  reutilizável (`<x-file-upload>`), alimentado por `livewire`/Alpine. Separar
  `create`/`edit` apenas pelo `route()`.
- **Portal:** **eliminar as 18 views duplicadas**. Criar
  `resources/views/portal/concursos-pss/{index,show,filtro}.blade.php` únicas,
  incluídas por todos os temas, com o layout resolvido por
  `configurations.layout` e **fallback** para o layout `default`.
- **Tabela do portal:** manter um componente único
  (`resources/views/portal/components/concursos-pss/table.blade.php`), como já
  existe na v5.
- **Exportação:** uma única classe `ConcursoPssExport` (`FromView`,
  `ShouldAutoSize`, `WithColumnFormatting`) e um template Blade de exportação com
  cabeçalho **sempre** presente (título, município, data) — corrigindo o bug em
  que XLSX/CSV saíam sem cabeçalho.
- **Filtros:** incluir `situacao` e `numero` como filtros opcionais (o legado não
  tinha), preservando os parâmetros atuais.

## 10. Fila, cache e storage

| Item | Decisão |
|---|---|
| Anexos | Disco **privado** (`storage/app/private/concursos-pss/{id}/{uuid}.pdf`); download via rota autenticada com `response()->download()` e `Content-Disposition: attachment`. |
| Exclusão de anexo | Remover também o arquivo físico (o legado deixava o órfão no disco). |
| Exportações | Geradas sob demanda; para volume alto, enfileirar (`QUEUE_CONNECTION`) e notificar por e-mail. |
| Cache | `Cache::remember` para a lista de `anos` (e de `tipos`, se vier do banco), invalidando ao alterar um registro. |
| Busca | Índice `pg_trgm` em `descricao`; `unaccent` do PostgreSQL ou `Str::ascii` no PHP. |
| PDF | `barryvdh/laravel-dompdf` ou `mpdf`; template único para XLSX/CSV/PDF. |

## 11. Riscos e decisões de equivalência

| Risco | Impacto | Mitigação na v7 |
|---|---|---|
| Slug mudando após edição quebra links públicos | Alto | Manter o slug estável + redirecionamento do slug antigo, ou data de publicação do certame. |
| Volume na exportação | Médio | Limitar linhas, enfileirar ou paginar a exportação. |
| Extensão de arquivo executável | Alto | Validar MIME real (não só extensão) e gravar sempre com extensão `.pdf`. |
| Perda da trilha de auditoria | Médio | `LogsActivity` com `logName = 'concurso-pss'`. |
| Perda da resource de permissão | Médio | Recriar `modulos` + `resources` com `modulo_id` e vincular aos papéis. |
| Buscar por acento/símbolo | Baixo | `unaccent` + escape de `%`/`_` no `LIKE`. |
| Normalização de `situacao` com histórico | Médio | Migrar em duas fases: criar tabela de apoio, mapear, só então impor FK. |
| Migração do campo `arquivo` (text → json) | Médio | Script de saneamento dos 3 formatos legados antes do `CAST`. |
| Busca só em `descricao` | Baixo | Ampliar para `numero`/`situacao` apenas com aprovação do negócio. |
| Remoção de anexo por índice posicional | Médio | Trocar por UUID. |
| Ausência de filtro por situação no portal | Baixo | Adicionar `situacao` como filtro opcional, sem remover os atuais. |
