# Concursos e PSS — Modelagem de Dados

> Esquema verificado no banco v5 (`prefeituras_v5`, PostgreSQL 10 + PostGIS) e nas
> migrations `v5/database/migrations/2026_05_27_0000*`.
> A v6 **não possui** nenhuma tabela, view ou seed deste módulo.

---

## 1. Tabela `concursos_pss`

Criada por `v5/database/migrations/2026_05_27_000000_create_concursos_pss_table.php`.

```php
Schema::create('concursos_pss', function (Blueprint $table) {
    $table->increments('id');
    $table->string('tipo', 60);
    $table->string('numero', 20);
    $table->integer('ano');
    $table->text('descricao');
    $table->string('situacao', 60);
    $table->integer('vagas_ofertadas')->nullable();
    $table->integer('vagas_preenchidas')->nullable();
    $table->date('validade')->nullable();
    $table->text('arquivo')->nullable();
    $table->string('slug', 255)->nullable();
    $table->timestamps();
    $table->softDeletes();
});
```

### 1.1 Colunas (estado real no banco)

| # | Coluna | Tipo PostgreSQL | Null | Default | Origem no formulário | Observações |
|---|---|---|---|---|---|---|
| 1 | `id` | `integer` (serial) | não | `nextval('concursos_pss_id_seq')` | — | PK `concursos_pss_pkey`. |
| 2 | `tipo` | `character varying(60)` | não | — | texto, máx. 60 | Texto livre (ex.: "Concurso Público"). |
| 3 | `numero` | `character varying(20)` | não | — | texto, máx. 20 | Número do edital (ex.: "001"). |
| 4 | `ano` | `integer` | não | — | inteiro 1900–2100 | Mascara `0000` no formulário. |
| 5 | `descricao` | `text` | não | — | textarea | Objeto do certame. |
| 6 | `situacao` | `character varying(60)` | não | — | texto, máx. 60 | Texto livre (ex.: "Inscrições Abertas"). |
| 7 | `vagas_ofertadas` | `integer` | sim | `NULL` | inteiro ≥ 0 | Exibido como `-` quando nulo. |
| 8 | `vagas_preenchidas` | `integer` | sim | `NULL` | inteiro ≥ 0 | Exibido como `-` quando nulo. |
| 9 | `validade` | `date` | sim | `NULL` | `dd/mm/aaaa` | Convertida para `Y-m-d` em `prepareData()`. |
| 10 | `arquivo` | `text` | sim | `NULL` | upload multi-PDF | **JSON** com `[{"path":"docs/...","description":"..."}]`. |
| 11 | `slug` | `character varying(255)` | sim | `NULL` | gerado | `str_slug(tipo-numero-ano-id)`. **Sem índice único.** |
| 12 | `created_at` | `timestamp(0)` | sim | `NULL` | — | `timestamp(0)` = sem timezone. |
| 13 | `updated_at` | `timestamp(0)` | sim | `NULL` | — | — |
| 14 | `deleted_at` | `timestamp(0)` | sim | `NULL` | — | Soft delete. |

**Índices:** apenas `concursos_pss_pkey` (btree em `id`). **Não há** índice em `ano`,
`tipo`, `slug` nem em `deleted_at` — a listagem pública faz varredura sequencial
sobre a view.

**Volume atual na base v5:** `SELECT count(*) FROM concursos_pss` → **0 linhas**.
O módulo foi instalado com o schema, mas sem dados de exemplo.

### 1.2 Chaves estrangeiras

Nenhuma. A tabela é órfã: não há FK para `resources`, `users`, `secretarias` nem
`menu_orders`.

## 2. View SQL `concursos_pss_view`

> **Atenção ao nome:** a view real é **`concursos_pss_view`** (não
> `view_concursos_pss`). Criada por
> `v5/database/migrations/2026_05_27_000001_create_view_concursos_pss.php`.

```sql
CREATE OR REPLACE VIEW public.concursos_pss_view AS
SELECT
    row_number() OVER (ORDER BY cp.created_at DESC) AS value,
    cp.id,
    cp.tipo,
    cp.numero,
    cp.ano,
    cp.descricao,
    cp.situacao,
    cp.vagas_ofertadas,
    cp.vagas_preenchidas,
    cp.validade,
    cp.arquivo,
    cp.slug
FROM concursos_pss cp
WHERE cp.deleted_at IS NULL
ORDER BY cp.created_at DESC;
```

Verificado com `SELECT pg_get_viewdef('public.concursos_pss_view'::regclass, true)`.

| O que a view abre | Por quê |
|---|---|
| `row_number() ... AS value` | Numeração sequencial para a primeira coluna do DataTable (coluna "ID" da tela do CMS). |
| Filtro `deleted_at IS NULL` | Esconde soft-deletes da listagem do CMS — o portal, porém, consulta a **tabela**, não a view. |
| Colunas de arquivo | `arquivo` e `slug` vêm junto, mas **não** são exibidas no DataTable do CMS. |

**Divergência de ordenação:** a view ordena por `created_at DESC`; o portal ordena
por `ano DESC, created_at DESC`. O número exibido na coluna "ID" do CMS, portanto,
**não** coincide com a ordem da listagem pública.

**Quem usa a view:** apenas `DatatableController::get_concurso_pss()`
(`v5/app/Http/Controllers/Prefecture/DatatableController.php:194-198`):

```php
public function get_concurso_pss()
{
    $concursos = DB::table('concursos_pss_view')->get();
    return DataTables::of($this->struct_views($concursos, 'concurso-pss'))->make(true);
}
```

## 3. Migrations do módulo

| Migration | Efeito |
|---|---|
| `v5/database/migrations/2026_05_27_000000_create_concursos_pss_table.php` | Cria `concursos_pss` (guardada por `Schema::hasTable`). |
| `v5/database/migrations/2026_05_27_000001_create_view_concursos_pss.php` | `DROP VIEW IF EXISTS` + `CREATE OR REPLACE VIEW public.concursos_pss_view`. |
| `v5/database/migrations/2026_05_27_000002_insert_concurso_pss_menu_and_resource.php` | Insere `menu_orders` e `resources` (idempotente por `slug`). |

### 3.1 Migration de menu e resource (índice 000002)

```php
if (!DB::table('menu_orders')->where('slug', 'concurso-pss')->exists()) {
    DB::table('menu_orders')->insert([
        'slug'       => 'concurso-pss',
        'clicks'     => 0,
        'created_at' => now(),
        'updated_at' => now(),
    ]);
}

if (!DB::table('resources')->where('slug', 'concurso-pss')->exists()) {
    DB::table('resources')->insert([
        'slug'        => 'concurso-pss',
        'name'        => 'Concursos e PSS',
        'description' => 'Módulo de Concursos Públicos e Processo Seletivo Simplificado',
        'created_at'  => now(),
        'updated_at'  => now(),
    ]);
}
```

**Estado real no banco v5:**

| Tabela | Id | slug | Valor | Observação |
|---|---|---|---|---|
| `resources` | **16** | `concurso-pss` | name "Concursos e PSS" | `status_create` = `NULL`. |
| `menu_orders` | **36** | `concurso-pss` | `clicks = 0` | Nunca incrementado (ver Gaps). |

Sequência de `resources` na base v5: 13 `obras`, 14 `terceirizados`, 15 `estagiario`,
**16 `concurso-pss`**, 17 `fila-espera-sus`, 18 `usuario`, 19 `perfil`, 20 `permissao`.
O **índice 16 é o `concurso-pss`** e está **ausente** no banco da v6.

`menu_orders` é apenas contador de cliques (`menuClicks()` em
`v5/app/Helpers/functions.php:822`); **não** é a tabela de menu do CMS. O menu
efetivo é `menu_items` (ver `08-GAPS-E-DIVIDAS.md`).

## 4. Seeds

Nenhum. Não há seeder em `v5/database/seeds/` para `concursos_pss`, nem
`DatabaseSeeder` que a invoque. A tabela está vazia na base v5.

## 5. Serialização do campo `arquivo`

Coluna `text` que guarda JSON. Há **três** formatos legados aceitos pelos leitores:

```php
// 1) JSON canônico (produzido por store/update)
[{"path":"docs/6821ab12cd.pdf","description":"Edital 001/2026"}]

// 2) JSON de caminho único (fallback do json_decode)
["docs/6821ab12cd.pdf"]

// 3) Texto puro com um caminho (fallback de json_decode)
"docs/6821ab12cd.pdf"
```

Normalização usada nas views (`v5/resources/views/prefecture/concursos-pss/form.blade.php`):

```php
$arquivosExistentes = json_decode($concurso->arquivo, true) ?: [$concurso->arquivo];
$arquivosExistentes = array_map(function ($i) {
    return is_array($i) ? $i : ['path' => $i, 'description' => ''];
}, $arquivosExistentes);
```

O mesmo padrão (com `array_values`) está no `update()` do controller e em
`destroyArquivo()`. O índice usado para remover um arquivo é **posicional na
lista**, não um identificador estável.

## 6. Relacionamentos Eloquent

O model `v5/app/Http/Model/Prefecture/ConcursoPss.php` **não declara nenhuma
relação** (`belongsTo`, `hasMany`, `belongsToMany`). É um model isolado, sem
`resources`, `users` ou `secretarias` vinculados.

```php
class ConcursoPss extends Model
{
    use SoftDeletes;

    protected $table = 'concursos_pss';

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

| Aspecto | Valor | Consequência |
|---|---|---|
| Namespace | `App\Http\Model\Prefecture` | Convenção minoritária no legado: 40 arquivos do diretório usam `App\Http\Model\Prefecture` e 55 usam `App\Prefecture`. O `use` no controller acompanha o arquivo. |
| `$fillable` | inclui `slug` | Permite mass-assignment do slug, embora o controller nunca o envie. |
| `$casts` | **ausente** | `validade` retorna string; o controller converte com `Carbon::parse()` na view. |
| Hooks | `created` e `updating` | Recalculam o slug e salvam de novo no `created` (2 escritas no INSERT). |
| `SoftDeletes` | sim | `deleted_at` preenchido; a view SQL e o filtro do portal excluem. |

## 7. Onde o slug é usado

| Consumidor | Uso |
|---|---|
| `ConcursoPssController@show` (portal) | `ConcursoPss::where('slug', $slug)->firstOrFail()` — 404 se não achar. |
| Breadcrumb do portal | `concursos-pss.show` mostra `"{tipo} nº {numero}/{ano}"`. |
| Tabela do portal | `route('concursos-pss.show', $concurso->slug)`. |

O slug **não** é único no banco (não há índice), embora `tipo-numero-ano-id` o
tornaria praticamente único — o `id` entra na composição.

## 8. Mapeamento para a v7 (schema equivalente)

| Coluna v5 | Destino v7 | Observação |
|---|---|---|
| `id` | `id` | `bigIncrements` ou `id()`. |
| `tipo` | `tipo` | Virar `enum` (`Concurso Público`, `Processo Seletivo Simplificado`, `Teste Seletivo`) ou FK para tabela de apoio. |
| `numero` | `numero` | `string(20)`. |
| `ano` | `ano` | `smallInteger` + CHECK de faixa. |
| `descricao` | `descricao` | `text`. |
| `situacao` | `situacao` | `enum` ou tabela `concursos_pss_situacoes`. |
| `vagas_ofertadas` / `vagas_preenchidas` | idem | `unsignedInteger` nullable; adicionar CHECK `preenchidas <= ofertadas` (o legado não valida). |
| `validade` | `validade` | `date` nullable, `cast => 'date'`. |
| `arquivo` (text com JSON) | `arquivo` `json` nullable | Migrar com `CAST(arquivo AS json)` após normalizar o formato legado. |
| `slug` | `slug` | `string` **unique** + índice. |
| `created_at`/`updated_at`/`deleted_at` | idem | `timestampTz`. |
| — | **novos** | `idx_ano_tipo` composto, `idx_slug`, `idx_deleted_at`. |
