# Ouvidoria — Modelagem de Dados

> **Slug:** `Ouvidoria` · **Versão analisada:** `lotep` (Laravel 5.5.50 / PHP 7.4)
>
> Tabelas, colunas, relacionamentos, migrations e seeds. Não há views SQL no módulo.

---

## 1. Tabelas

### 1.1 `v2_ombudsman_requests`

Criada em `V2/Modules/Servicos/Ouvidoria/Migrations/2026_07_17_000005_create_v2_ombudsman_requests_table.php:17-41`.

| Coluna | Tipo | Null | Default | Key | Observação |
|---|---|---|---|---|---|
| `id` | varchar(36) | no | — | PK | UUID v4 gerado por `v2_uuid()` |
| `protocol` | varchar(50) | no | — | UNIQUE + INDEX | `OUV-{Ymd}-{8}` gerado no `creating` |
| `name` | varchar(255) | sim | — | — | nome do manifestante |
| `email` | varchar(255) | sim | — | — | sem índice |
| `phone` | varchar(50) | sim | — | — | sem índice |
| `description` | text | no | — | — | texto puro; sem limite |
| `address` | varchar(500) | sim | — | — | não validado no portal |
| `status` | integer | no | `1` | INDEX | 1/2/3 sem enum nem check |
| `ip` | varchar(45) | sim | — | — | IP cru (IPv4/IPv6) |
| `is_anonymous` | boolean | no | `false` | — | injetável no portal |
| `v2_type_id` | varchar(36) | sim | — | FK | → `v2_ombudsman_manifestation_types.id` |
| `v2_subject_id` | varchar(36) | sim | — | FK | → `v2_ombudsman_subjects.id` |
| `v2_origin_id` | varchar(36) | sim | — | FK | → `v2_ombudsman_origins.id` |
| `v2_response_method_id` | varchar(36) | sim | — | FK | → `v2_ombudsman_response_methods.id` |
| `metadata` | jsonb | sim | — | — | declarada, sem uso no módulo |
| `created_at` | timestamp | sim | — | — | `timestamps()` |
| `updated_at` | timestamp | sim | — | — | `timestamps()` |
| `deleted_at` | timestamp | sim | — | — | `softDeletes()` |
| `v1_id` | integer | sim | — | UNIQUE parcial | adicionada depois (importação) |
| `imported_at` | timestamp | sim | — | — | adicionada depois (importação) |

**Índices:** `protocol` (unique — o `$t->index('protocol')` da linha 35 é redundante, pois o
`unique()` da linha 19 já cria índice), `status`, e
`v2_ombudsman_requests_v1_id_unique` (parcial, `WHERE v1_id IS NOT NULL`).

**FKs:** todas as quatro com `onDelete('set null')`; as colunas são nullable, portanto a
operação é válida. O PostgreSQL **não** cria índice no lado referenciante — não há índice em
`v2_type_id`, `v2_subject_id`, `v2_origin_id` nem `v2_response_method_id`.

> Observação: `ip` foi omitido do `$fillable` do model, mas é gravado pelo controller via
> `$data['ip'] = $r->ip()`; o filtro de mass assignment não protege um valor montado no servidor.

### 1.2 `v2_ombudsman_answers`

Criada em `V2/Modules/Servicos/Ouvidoria/Migrations/2026_07_17_000006_create_v2_ombudsman_answers_table.php:17-27`.

| Coluna | Tipo | Null | Default | Key | Observação |
|---|---|---|---|---|---|
| `id` | varchar(36) | no | — | PK | UUID v4 |
| `answer` | longtext | no | — | — | texto da resposta |
| `file_url` | varchar(500) | sim | — | — | não preenchido pelo módulo; o importador V1 grava a URL |
| `v2_ombudsman_request_id` | varchar(36) | no | — | FK + INDEX | → `v2_ombudsman_requests.id` |
| `metadata` | jsonb | sim | — | — | declarada, sem uso |
| `created_at` | timestamp | sim | — | — | |
| `updated_at` | timestamp | sim | — | — | |
| `deleted_at` | timestamp | sim | — | — | |

**FK:** `onDelete('cascade')` (`...000006...:26`) — apagar a manifestação apaga as respostas.

### 1.3 Dicionários (quatro tabelas de schema idêntico)

`v2_ombudsman_manifestation_types`, `v2_ombudsman_subjects`, `v2_ombudsman_origins` e
`v2_ombudsman_response_methods`. As migrations são quatro arquivos com o mesmo timestamp
`2026_07_17_000001_*`.

| Coluna | Tipo | Null | Default | Key | Observação |
|---|---|---|---|---|---|
| `id` | varchar(36) | no | — | PK | UUID v4 |
| `title` | varchar(255) | no | — | — | rótulo exibido no portal e no CMS |
| `is_active` | boolean | no | `true` | — | filtra o portal (`scopeActive`) |
| `metadata` | jsonb | sim | — | — | sem uso |
| `created_at` | timestamp | sim | — | — | |
| `updated_at` | timestamp | sim | — | — | |
| `deleted_at` | timestamp | sim | — | — | soft delete |

Nenhuma das quatro tem índice explícito. Todas são criadas com
`DB::beginTransaction()`/`commit()` e, no `down()`, renomeiam a tabela para
`backup_<tabela>_<YmdHis>` em vez de `DROP` — o mesmo padrão da migration de requests
(`...000005...:52-63`).

> Divergência real: `OmbudsmanSubject::$fillable` inclui `secretary_email` e `responsible`
> (`V2/Modules/Servicos/Ouvidoria/Models/OmbudsmanSubject.php:46`), colunas que **não existem**
> na tabela. Um `create()` com esses campos gera erro de SQL.

---

## 2. Relacionamentos

| Origem | Destino | Tipo | Cardinalidade | Onde declarado |
|---|---|---|---|---|
| `OmbudsmanRequest` | `OmbudsmanManifestationType` | `belongsTo('v2_type_id')` | N:1 | `OmbudsmanRequest.php:95-98` |
| `OmbudsmanRequest` | `OmbudsmanSubject` | `belongsTo('v2_subject_id')` | N:1 | `OmbudsmanRequest.php:102-105` |
| `OmbudsmanRequest` | `OmbudsmanOrigin` | `belongsTo('v2_origin_id')` | N:1 | `OmbudsmanRequest.php:109-112` |
| `OmbudsmanRequest` | `OmbudsmanResponseMethod` | `belongsTo('v2_response_method_id')` | N:1 | `OmbudsmanRequest.php:116-119` |
| `OmbudsmanRequest` | `OmbudsmanAnswer` | `hasMany('v2_ombudsman_request_id')` | 1:N | `OmbudsmanRequest.php:123-126` |
| `OmbudsmanAnswer` | `OmbudsmanRequest` | `belongsTo('v2_ombudsman_request_id')` | N:1 | `OmbudsmanAnswer.php:64-67` |
| cada dicionário | `OmbudsmanRequest` | `hasMany` | 1:N | ex.: `OmbudsmanManifestationType.php:65-68` |

Todos os models usam `SoftDeletes`, `public $incrementing = false`, `$keyType = 'string'` e um
`boot()` que gera `id = v2_uuid()` quando vazio (o de `OmbudsmanRequest` também gera o
`protocol`). O `OmbudsmanAnswer` **não** declara relação inversa com o dicionário — não há
dicionário de resposta.

---

## 3. Views SQL

Nenhuma. Diferentemente da V1 (que tinha `view_ombudsman_requests` e quatro views de
dicionário, documentadas em `wiki/v1/modules/ouvidoria.md:113-120`), o V2 consulta as tabelas
diretamente e não tem `CREATE VIEW`.

---

## 4. Migrations

| Data | Arquivo | Operação | Tabela afetada |
|---|---|---|---|
| 2026-07-17 | `V2/Modules/Servicos/Ouvidoria/Migrations/2026_07_17_000001_create_v2_ombudsman_manifestation_types_table.php` | create | `v2_ombudsman_manifestation_types` |
| 2026-07-17 | `.../2026_07_17_000001_create_v2_ombudsman_origins_table.php` | create | `v2_ombudsman_origins` |
| 2026-07-17 | `.../2026_07_17_000001_create_v2_ombudsman_response_methods_table.php` | create | `v2_ombudsman_response_methods` |
| 2026-07-17 | `.../2026_07_17_000001_create_v2_ombudsman_subjects_table.php` | create | `v2_ombudsman_subjects` |
| 2026-07-17 | `.../2026_07_17_000005_create_v2_ombudsman_requests_table.php` | create | `v2_ombudsman_requests` |
| 2026-07-17 | `.../2026_07_17_000006_create_v2_ombudsman_answers_table.php` | create | `v2_ombudsman_answers` |
| 2026-10-22 | `.../2026_10_22_000001_add_v1_import_tracking_to_v2_ombudsman_requests_table.php` | add colunas + índice único parcial | `v2_ombudsman_requests` |

A migration de rastreio é defensiva e idempotente: checa `Schema::hasTable`/`hasColumn`,
executa `SET lock_timeout = '3s'`, cria `v1_id`, `imported_at` e
`CREATE UNIQUE INDEX ... WHERE v1_id IS NOT NULL`
(`...2026_10_22_000001...:28-63`). O `down()` remove índice e colunas.

Ordem de dependência: as quatro tabelas de dicionário são criadas antes de
`v2_ombudsman_requests` (000001 < 000005 < 000006). Todas as migrations são carregadas pelo
`V2ServiceProvider` a partir de `V2/Modules/<Grupo>/<Modulo>/Migrations`.

---

## 5. Seeds

| Arquivo | O que popula | Volume |
|---|---|---|
| `V2/Modules/Servicos/Ouvidoria/Seeds/OuvidoriaDefaultSeeder.php` | tipos, origens, formas de resposta e assuntos | 14 linhas (4 + 4 + 4 + 2) |

Valores exatos (`.../Seeds/OuvidoriaDefaultSeeder.php:23-49`):

| Dicionário | Títulos |
|---|---|
| tipos | Reclamação, Sugestão, Elogio, Denúncia |
| origens | Presencial, Online, Telefone, E-mail |
| formas de resposta | E-mail, Telefone, Carta, Presencial |
| assuntos | Loteria, Atendimento |

A idempotência é por `firstOrCreate(['title' => ...], ['id' => v2_uuid(), 'is_active' => true])`,
portanto rodar duas vezes não duplica. O seeder é chamado por
`V2/Seeds/V2EnvironmentSeeder.php:39`, no bloco “Dicionários de módulos”, e **não** é um seeder
de dados de demonstração.

> Não existe seeder de manifestações: qualquer dado de exemplo vem da importação V1 ou de
> fixtures de teste.

---

## 6. Estados e transições

| Estado | Valor | Como entra | Como sai |
|---|---|---|---|
| Em tramitação | `1` | default da coluna; importador para status V1 não mapeado | update manual para 2/3; resposta força 2 |
| Atendido | `2` | `answer()` (`OmbudsmanRequestCmsController.php:88`); importador para “Atendido”/“Indeferido” | update manual |
| Indeferido | `3` | somente update manual de status | update manual |
| Desconhecido | qualquer outro | `updateStatus()` aceita o valor cru; importador nunca gera | update manual |

O mapa V1→V2 do importador trata **“Indeferido” como 2**, não como 3
(`OuvidoriaLegacyImporter::STATUS_NOMES_RESPONDIDA = ['Atendido', 'Indeferido']` e
`statusCode()`), o que colapsa a distinção entre resposta e indeferimento na importação.
O estado `4` (Prorrogado) da V1 é perdido e vira `1`.

---

## 7. Índices e performance

| Consulta frequente | Índice que a atende | Existe? | Custo de criar |
|---|---|---|---|
| busca `protocol/name/email ILIKE '%t%'` | nenhum (btree não atende `%...%`) | não | `pg_trgm` + GIN |
| listagem ordenada por `created_at` | nenhum | não | btree em `created_at` |
| filtro por `status` | `v2_ombudsman_requests_status_index` | sim | já criado |
| detalhe por `id` (PK) | PK | sim | já criado |
| respostas de uma manifestação | `v2_ombudsman_answers_v2_ombudsman_request_id_index` | sim | já criado |
| junção por `v2_type_id`/`v2_subject_id`/... | nenhum no lado referenciante | não | btree por FK |

A busca é o gargalo real: três `ILIKE '%...%'` sem trigrama forçam *sequential scan* em
`v2_ombudsman_requests`; com volume pequeno (ouvidoria municipal) é aceitável, mas não escala
para histórico de anos. O índice de `status` existe mas **não é usado** porque o controller
não oferece filtro por status. O índice `unique(protocol)` só atende igualdade exata; a busca
usa `ILIKE` com curinga à esquerda.

---

## 8. Integridade e dados órfãos

- **Soft delete não cascateia:** o `onDelete('cascade')` da FK de respostas só dispara em
  `DELETE` físico. Como `OmbudsmanRequest` usa `SoftDeletes`, uma manifestação “excluída” mantém
  as respostas e continua referenciável por `v2_ombudsman_request_id`.
- **Dicionário excluído:** soft delete no dicionário não altera as FKs; a manifestação continua
  apontando para um registro com `deleted_at` preenchido. Como o dicionário usa `SoftDeletes`, a
  relação `belongsTo` aplica o escopo global e resolve `null`; a view exibe `—` no lugar do
  título. A FK permanece, então o dado histórico não é perdido — comportamento a preservar ou
  corrigir conscientemente.
- **Protocolo duplicado:** o `creating` não trata colisão; se o `substr($id, 0, 8)` repetir em
  dois registros do mesmo dia, o `INSERT` viola a unique e a requisição falha com erro de banco.
- **`metadata`:** coluna jsonb em todas as tabelas, declarada como `array` no cast, mas nenhum
  código do módulo grava ou lê. O importador usa `metadata` na manifestação para guardar
  `imported_from` e `legado` (`OuvidoriaLegacyImporter.php:140-143`).
