# Formulários Dinâmicos — Modelagem de Dados

> **Slug:** `infraestrutura-formulariosdinamicos` · **Versão analisada:** `lotep`
> **Banco:** PostgreSQL (`jsonb` nativo) · **Raiz de referência:** `/home/one/projects/maxima/v7/lotep`
>
> Há **dois conjuntos de tabelas**: as 4 do motor (`v2_formulario_dinamico_*`) e as 4 do módulo
> legado (`v2_form*`). Só as primeiras são usadas pelo produto.

---

## 1. Motor — `v2_formulario_dinamico_formularios`

Criada por `V2/Services/FormularioDinamico/Migrations/2026_09_28_000001_create_v2_formulario_dinamico_formularios_table.php`.

| Coluna | Tipo | Nulo | Default | Observação |
|---|---|---|---|---|
| `id` | varchar(36) | não | — | PK; preenchido com `v2_uuid()` (UUID v4) |
| `slug` | varchar(120) | não | — | **unique**; usado na URL do portal e da API |
| `title` | varchar(255) | não | — | título exibido |
| `description` | text | sim | — | subtítulo do portal e `pageDescription` |
| `status` | varchar(20) | não | `'draft'` | `draft` \| `active` \| `archived`; indexado |
| `current_version` | integer | não | `1` | aponta para `versoes.version` |
| `published_at` | timestamp | sim | — | preenchido quando status vira `active` |
| `expires_at` | timestamp | sim | — | `scopeActive` exige nulo ou `>= now` |
| `metadata` | jsonb | sim | — | `score_enabled` (flag da UI) e dados de importação |
| `created_at` / `updated_at` | timestamps | sim | — | |
| `deleted_at` | timestamp | sim | — | soft delete |
| `v1_id` | integer | sim | — | idempotência do importador (migration de 2026-10-22) |
| `imported_at` | timestamp | sim | — | data da importação V1 |

Índices: PK, unique `slug`, index `status`, unique parcial `v2_formulario_dinamico_formularios_v1_id_unique (v1_id)
WHERE v1_id IS NOT NULL` (`V2/Services/FormularioDinamico/Migrations/2026_10_22_000001_add_v1_import_tracking_to_v2_formulario_dinamico_tables.php:68-73`).

**Semântica de `metadata`.** `FormularioCrudService::create()` grava
`['score_enabled' => bool]` (`FormularioCrudService.php:50`); `update()` preserva outras chaves e
regrava a flag. O importador V1 grava `imported_from` e `legado.{v1_id, identifier, has_result,
result_config}`. Não há cast tipado: é `array`.

## 2. Motor — `v2_formulario_dinamico_versoes`

Criada por `2026_09_28_000002_create_v2_formulario_dinamico_versoes_table.php`.

| Coluna | Tipo | Nulo | Default | Observação |
|---|---|---|---|---|
| `id` | varchar(36) | não | — | PK |
| `formulario_id` | varchar(36) | não | — | FK → formularios, `onDelete cascade` |
| `version` | integer | não | `1` | unique composto com `formulario_id` |
| `schema` | jsonb | **não** | — | comentado como "estrutura das perguntas"; na prática `{"campos": N}` |
| `scoring_config` | jsonb | sim | — | `bands`, `consent_required`, `consent_text`, `disclaimer` |
| `published_at` | timestamp | sim | — | não é escrito por `FormularioCrudService` |
| `created_at` / `updated_at` | timestamps | sim | — | |
| `deleted_at` | timestamp | sim | — | soft delete |

Índices: unique `(formulario_id, version)`, index `version`, index implícito da FK.

**Estrutura real de `scoring_config`** (o que o motor lê):

```json
{
  "consent_required": true,
  "consent_text": "Li e concordo com a <a href=\"...\">Política</a>...",
  "disclaimer": "Este resultado é apenas orientativo...",
  "bands": [
    {"min_percent": 0,  "max_percent": 31,  "level": "faixa_1", "title": "...", "message": "...", "color": "#198754", "recomendacoes": [], "canais": []},
    {"min_percent": 32, "max_percent": 65,  "level": "faixa_2", "title": "...", "message": "...", "color": "#0dcaf0", "recomendacoes": [], "canais": []}
  ]
}
```

O editor do CMS produz exatamente esse formato (`FormularioDinamicoCmsController.php:523-606`) e
`SubmissionService::consentRequired()` lê `consent_required` com default `true`
(`SubmissionService.php:179-186`). Faixas absolutas usam `max_points` em vez de `max_percent` e
são aceitas pelo banco do mesmo jeito (jsonb não valida forma).

## 3. Motor — `v2_formulario_dinamico_campos`

Criada por `2026_09_28_000003_create_v2_formulario_dinamico_campos_table.php`.

| Coluna | Tipo | Nulo | Default | Observação |
|---|---|---|---|---|
| `id` | varchar(36) | não | — | PK |
| `versao_id` | varchar(36) | não | — | FK → versoes, `onDelete cascade` |
| `type` | varchar(40) | não | — | `text`\|`textarea`\|`select`\|`multiselect`\|`radio`\|`checkbox`\|`range`\|`file`\|`satisfaction` |
| `name` | varchar(80) | não | — | identificador estável; unique por versão |
| `label` | varchar(255) | não | — | pergunta exibida |
| `help` | text | sim | — | texto de apoio |
| `required` | boolean | não | `false` | |
| `order` | integer | não | `0` | ordenação da renderização |
| `weight` | decimal(10,3) | não | `0` | valor da pergunta no scoring |
| `options` | jsonb | sim | — | `[{value,text,weight}]` |
| `rules` | jsonb | sim | — | `min`, `max`, `pattern`, `email`, `url`, `min_items`, `max_items`, `accept`, `max_size_kb`, `rows`, `labels` |

Índices: unique `(versao_id, name)`, index `(versao_id, order)`, index `type`, FK.

**Como `options` é gravado.** `normalizeOptions()` prioriza `opts` (editor: linhas
`{text, nota}`), depois `options_txt` (vírgulas, legado), depois `options_json`. Em `opts`, o texto
vira `value` e `text` ao mesmo tempo e `nota` é limitada a 0–100 (`FormularioCrudService.php:241-281`).
Duplicatas e vazios são descartados preservando a primeira ocorrência.

**Como `rules` é gravado.** `rules_json` é decodificado por `decodeJsonArray()`
(`FormularioCrudService.php:294-306`). JSON malformado lança `InvalidArgumentException` — que o
controller converte em erro de formulário (`config_json`).

## 4. Motor — `v2_formulario_dinamico_submissoes`

Criada por `2026_09_28_000004_create_v2_formulario_dinamico_submissoes_table.php`.

| Coluna | Tipo | Nulo | Default | Observação |
|---|---|---|---|---|
| `id` | varchar(36) | não | — | PK |
| `formulario_id` | varchar(36) | não | — | FK → formularios, cascade |
| `versao_id` | varchar(36) | não | — | FK → versoes, cascade |
| `answers` | jsonb | não | — | `{name: valor normalizado}` |
| `score` | decimal(10,2) | não | `0` | soma dos pontos |
| `max_score` | decimal(10,2) | não | `0` | soma dos máximos por campo |
| `result_level` | varchar(40) | sim | — | `level` da faixa; indexado por formulário |
| `result_data` | jsonb | sim | — | faixa + textos + canais + `disclaimer`, congelados |
| `ip_hash` | varchar(64) | sim | — | HMAC-SHA256; **nunca** IP cru |
| `user_agent` | text | sim | — | |
| `session_id` | varchar(100) | sim | — | |
| `origin` | varchar(40) | sim | — | `portal`\|`api`\|`module`\|`e2e` |
| `result_token` | varchar(64) | sim | — | **unique**; UUID v4, não assinado |
| `consent_at` | timestamp | sim | — | preenchido quando consentimento exigido e dado |
| `created_at` / `updated_at` | timestamps | sim | — | |
| `deleted_at` | timestamp | sim | — | soft delete |
| `v1_id` | integer | sim | — | idempotência do importador |
| `imported_at` | timestamp | sim | — | |

Índices e constraints:

```sql
CREATE INDEX v2_fdd_submissoes_answers_gin_idx
    ON v2_formulario_dinamico_submissoes USING gin (answers jsonb_path_ops);
```

(`V2/Services/FormularioDinamico/Migrations/2026_09_28_000004_create_v2_formulario_dinamico_submissoes_table.php:41`), além de `(formulario_id, versao_id)`, `(formulario_id,
created_at)`, `(formulario_id, result_level)`, unique `result_token`, unique parcial
`v2_formulario_dinamico_submissoes_v1_id_unique (v1_id) WHERE v1_id IS NOT NULL`.

**Nenhum código consulta o índice GIN.** As leituras são por `formulario_id`, `result_token` ou
`result_level`; `answers` só é lido no PHP (ver `05-REGRAS-E-PORTABILIDADE.md`).

## 5. Relacionamentos

```php
V2FormularioDinamico::versoes()      hasMany(V2FormularioDinamicoVersao, 'formulario_id')
V2FormularioDinamico::submissoes()   hasMany(V2FormularioDinamicoSubmissao, 'formulario_id')

V2FormularioDinamicoVersao::formulario()  belongsTo(V2FormularioDinamico, 'formulario_id')
V2FormularioDinamicoVersao::campos()      hasMany(V2FormularioDinamicoCampo, 'versao_id')->orderBy('order')
V2FormularioDinamicoVersao::submissoes()  hasMany(V2FormularioDinamicoSubmissao, 'versao_id')

V2FormularioDinamicoCampo::versao()       belongsTo(V2FormularioDinamicoVersao, 'versao_id')
V2FormularioDinamicoSubmissao::formulario() belongsTo(V2FormularioDinamico, 'formulario_id')
V2FormularioDinamicoSubmissao::versao()     belongsTo(V2FormularioDinamicoVersao, 'versao_id')
```

Todos os models usam `SoftDeletes`, `$incrementing = false` e `$keyType = 'string'`. As FKs de
banco são `onDelete('cascade')`; como o soft delete do Eloquent não dispara cascade, apagar um
formulário **preserva** versões, campos e submissões fisicamente — que é o comportamento desejado.

## 6. Legado — tabelas `v2_form*`

### 6.1 `v2_forms`

`V2/Modules/Infraestrutura/FormulariosDinamicos/Migrations/2026_07_17_000001_create_v2_forms_table.php`

| Coluna | Tipo | Nulo | Default |
|---|---|---|---|
| `id` | varchar(36) | não | PK |
| `title` | varchar(255) | não | — |
| `slug` | varchar(255) | não | unique |
| `description` | text | sim | — |
| `is_active` | boolean | não | `true` |
| `has_result` | boolean | não | `false` |
| `hide_title` | boolean | não | `false` |
| `submit_info` | text | sim | — |
| `published_at` | datetime | sim | — |
| `metadata` | jsonb | sim | — |
| `created_at` / `updated_at` / `deleted_at` | timestamps | sim | — |

Índices: unique `slug`, index `slug`, index `is_active`.

### 6.2 `v2_form_categories`

`2026_07_17_000002_create_v2_form_categories_table.php`: `id`, `name`, `order` (default 0),
`v2_form_id` (FK cascade), `parent_id` (FK self, `set null`), `metadata` jsonb, timestamps, soft
delete. Suporta árvore de categorias, mas **nenhuma tela ou controller atual a utiliza**.

### 6.3 `v2_form_questions`

`2026_07_17_000003_create_v2_form_questions_table.php`: `id`, `title`, `description`
(varchar 500), `type` (default `text`), `options` jsonb, `config` jsonb, `required` (default
false), `order` (default 0), `v2_form_id` (FK cascade), `v2_form_category_id` (FK `set null`),
`metadata` jsonb, timestamps, soft delete. Índices em `v2_form_id` e `type`.

### 6.4 `v2_form_submissions`

`2026_07_17_000004_create_v2_form_submissions_table.php` — **arquivo inválido**: usa
`function (Blueprint$t)`, `catch (\Exception$e)` e `throw$e` (linhas 17, 29, 31, 44, 46). O PHP
não compila esse arquivo; por isso a tabela pode existir em ambientes antigos e **não** ser criada
em instalações novas.

| Coluna | Tipo | Nulo |
|---|---|---|
| `id` | varchar(36) | não (PK) |
| `v2_form_id` | varchar(36) | não (FK cascade) |
| `answers` | jsonb | não |
| `ip` | varchar(45) | sim |
| `metadata` | jsonb | sim |
| timestamps + `deleted_at` | | |

## 7. Migrations

| Arquivo | Data no nome | Efeito |
|---|---|---|
| `V2/Services/FormularioDinamico/Migrations/2026_09_28_000001_create_v2_formulario_dinamico_formularios_table.php` | 2026-09-28 | cria formulários |
| `.../2026_09_28_000002_create_v2_formulario_dinamico_versoes_table.php` | 2026-09-28 | cria versões |
| `.../2026_09_28_000003_create_v2_formulario_dinamico_campos_table.php` | 2026-09-28 | cria campos |
| `.../2026_09_28_000004_create_v2_formulario_dinamico_submissoes_table.php` | 2026-09-28 | cria submissões + GIN |
| `.../2026_10_22_000001_add_v1_import_tracking_to_v2_formulario_dinamico_tables.php` | 2026-10-22 | adiciona `v1_id`/`imported_at` + unique parcial |
| `V2/Modules/Infraestrutura/FormulariosDinamicos/Migrations/2026_07_17_000001_create_v2_forms_table.php` | 2026-07-17 | legado |
| `.../2026_07_17_000002_create_v2_form_categories_table.php` | 2026-07-17 | legado |
| `.../2026_07_17_000003_create_v2_form_questions_table.php` | 2026-07-17 | legado |
| `.../2026_07_17_000004_create_v2_form_submissions_table.php` | 2026-07-17 | legado, inválido |

Todas as migrations do motor fazem `DB::beginTransaction()` e, no `down()`, **renomeiam** a tabela
para `backup_<tabela>_<YmdHis>` em vez de `DROP` (padrão do legado V2, não o padrão do V7).

A migration de rastreio V1 é idempotente (`Schema::hasColumn`, checagem de índice em `pg_indexes`)
e usa `SET lock_timeout = '3s'`. No `down()` remove índice e colunas — único ponto do conjunto que
usa `dropColumn`.

## 8. Seeds

`V2/Modules/Infraestrutura/FormulariosDinamicos/Seeds/FormDefaultSeeder.php` cria:

- formulário `pesquisa-satisfacao`, ativo, `has_result = false`;
- três perguntas: `text` "Qual seu nome?", `select` "Como avalia nosso atendimento?" com quatro
  opções, `textarea` "Deixe sua sugestão".

O seeder **não é referenciado** por `DatabaseSeeder` nem por teste; é resquício do módulo legado.
Não há seed para as tabelas do motor no `lotep`.

## 9. Dados derivados e ausentes

| Item | Situação |
|---|---|
| `schema` da versão | não contém a estrutura; apenas a contagem de campos |
| `weight` da pergunta | não é editável no editor do CMS (só a nota por opção); fica `0` quando não vem no payload |
| `published_at` da versão | coluna existe, nunca é preenchida |
| `expires_at` do formulário | não há campo no CMS para defini-lo; só é respeitado na leitura |
| `result_token` | unique, mas não há índice de expiração nem coluna de validade |
| `v2_form_categories` | sem uso |
