# Carta de Serviços — Modelagem

> Fonte: migrations em `v6/database/migrations/` e schema real do banco `prefeituras_v5` (container `v6_6f22e1bd-postgres-1`), consultado apenas com `SELECT`/`\d`.

## 1. Panorama das tabelas

| # | Tabela | Linhas | Propósito |
|---|---|---|---|
| 1 | `service_letters` | entidade central | Serviço público prestado |
| 2 | `service_letter_subjects` | catálogo | Assunto (categoria) do serviço |
| 3 | `service_letters_subjects_rel` | pivot N:N | serviço ↔ assunto |
| 4 | `service_letter_configs` | singleton | Configuração visual/editorial do módulo e do PDF |
| 5 | `service_letter_channels` | filha | Canal de atendimento |
| 6 | `service_letter_steps` | filha | Etapa do fluxo do serviço |
| 7 | `service_letter_documents` | filha | Documento exigido do cidadão |
| 8 | `service_letter_attachments` | filha | Anexo (arquivo) do serviço |
| 9 | `service_letter_responsaveis` | filha | Responsável pelo atendimento |
| 10 | `service_letter_pdfs` | filha | PDF consolidado gerado |
| 11 | `service_letter_access_logs` | órfã | Log diário de acessos — **nunca populada** |

## 2. `service_letters`

Criada por `v6/database/migrations/2026_01_20_160719_create_service_letters_table.php`.

| Coluna | Tipo (Postgres) | Null | Default | Origem |
|---|---|---|---|---|
| `id` | integer (serial) | não | seq | PK |
| `created_by` | bigint | sim | — | FK → `users.id` ON DELETE SET NULL |
| `updated_by` | bigint | sim | — | FK → `users.id` ON DELETE SET NULL |
| `nome` | varchar(191) | **não** | — | — |
| `slug` | varchar(191) | **não** | — | UNIQUE |
| `descricao_curta` | varchar(500) | sim | — | — |
| `secretaria_id` | bigint | sim | — | FK → `contents.id` ON DELETE SET NULL |
| `menu_acesso_rapido` | varchar(20) | não | `'cidadao'` | ∈ {`cidadao`,`empresa`,`servidor`} |
| `tipo_exibicao` | varchar(20) | não | `'pagina'` | ∈ {`pagina`,`link_externo`} |
| `link_externo` | varchar(500) | sim | — | — |
| `acesso_online` | boolean | não | `false` | — |
| `acesso_telefone` | boolean | não | `false` | — |
| `acesso_presencial` | boolean | não | `false` | — |
| `sobre` | text | sim | — | HTML (Summernote) |
| `documentacoes` | text | sim | — | HTML (Summernote) |
| `custos` | text | sim | — | HTML (Summernote) |
| `gratuito` | boolean | não | `true` | — |
| `valor_taxa` | numeric(10,2) | sim | — | — |
| `etapas` | text | sim | — | HTML — **descrição livre em paralelo à tabela `service_letter_steps`** |
| `requisitos` | text | sim | — | HTML |
| `justificativa` | text | sim | — | — |
| `previsao_atendimento` | varchar(191) | sim | — | — |
| `previsao_atendimento_detalhes` | text | sim | — | — |
| `prioridades_atendimento` | text | sim | — | — |
| `horario_atendimento` | varchar(191) | sim | — | — |
| `horario_atendimento_detalhes` | text | sim | — | — |
| `endereco_atendimento` | text | sim | — | — |
| `endereco_cep` | varchar(10) | sim | — | — |
| `endereco_latitude` | numeric(10,8) | sim | — | — |
| `endereco_longitude` | numeric(11,8) | sim | — | — |
| `responsavel_nome` | varchar(191) | sim | — | — |
| `responsavel_cargo` | varchar(191) | sim | — | — |
| `responsavel_foto` | varchar(500) | sim | — | — |
| `responsavel_email` | varchar(191) | sim | — | — |
| `responsavel_telefone` | varchar(20) | sim | — | — |
| `informacoes_adicionais` | text | sim | — | HTML |
| `ordem` | smallint | não | `0` | — |
| `ativo` | boolean | não | `true` | — |
| `publicado` | boolean | não | `false` | — |
| `publicado_em` | timestamp(0) | sim | — | — |
| `destaque` | boolean | não | `false` | — |
| `visualizacoes` | bigint | não | `0` | incremented no portal |
| `created_at` / `updated_at` | timestamp(0) | sim | — | — |
| `deleted_at` | timestamp(0) | sim | — | soft delete (migration de 2026-03-30) |

**Casts do model** (`v6/app/Http/Model/Prefecture/ServiceLetter/ServiceLetter.php`): `acesso_online`, `acesso_telefone`, `acesso_presencial`, `gratuito`, `ativo`, `publicado`, `destaque` → `boolean`; `publicado_em` → `datetime`; `valor_taxa` → `decimal:2`; `endereco_latitude` → `decimal:8`; `endereco_longitude` → `decimal:8`.

> **Observação factual:** os casts de latitude/longitude usam `decimal:8`, enquanto a coluna é `numeric(10,8)` / `numeric(11,8)`. O `decimal:8` do Laravel fixa 8 casas decimais sem padding — o valor é truncado na apresentação, não na gravação.

## 3. `service_letter_subjects`

`v6/database/migrations/2026_01_20_160714_create_service_letter_subjects_table.php`

| Coluna | Tipo | Null | Default |
|---|---|---|---|
| `id` | integer | não | seq |
| `created_by` | bigint | sim | — | FK → `users.id` SET NULL |
| `updated_by` | bigint | sim | — | FK → `users.id` SET NULL |
| `nome` | varchar(191) | não | — |
| `slug` | varchar(191) | não | — | **UNIQUE** |
| `descricao` | text | sim | — |
| `tipo_visual` | varchar(10) | não | `'icone'` | ∈ {`icone`,`imagem`} |
| `icone` | varchar(100) | sim | — | classe CSS (ex.: `fa fa-heart`) |
| `icone_cor` | varchar(7) | não | `'#000000'` | — |
| `imagem` | varchar(500) | sim | — | — |
| `ordem` | smallint | não | `0` | — |
| `ativo` | boolean | não | `true` | — |
| `created_at`/`updated_at`/`deleted_at` | timestamp | sim | — | soft delete em 2026-03-30 |

## 4. `service_letters_subjects_rel` (pivot)

`v6/database/migrations/2026_01_20_160720_service_letters_subjects_rel.php`

| Coluna | Tipo | Null | Notes |
|---|---|---|---|
| `id` | integer | não | seq |
| `service_letter_id` | **bigint** | não | FK → `service_letters.id` CASCADE |
| `subject_id` | **bigint** | não | FK → `service_letter_subjects.id` CASCADE |
| `created_at` | timestamp | não | `useCurrent()` |

UNIQUE (`service_letter_id`, `subject_id`).

> **Divergência de tipo verificada:** as chaves estrangeiras do pivot são `bigint`, mas `service_letters.id` e `service_letter_subjects.id` são `integer` (serial). As constraints **existem** no banco (`pg_constraint` confirma `service_letters_subjects_rel_service_letter_id_foreign` e `..._subject_id_foreign`): o PostgreSQL aceita a FK porque existe operador de igualdade entre `int4` e `int8`, aplicando cast implícito na verificação. A integridade referencial **é** mantida; o risco é apenas de performance (índice não diretamente utilizável) e de comportamento inesperado em migrações futuras que recriem as tabelas.

## 5. `service_letter_configs` (singleton)

`v6/database/migrations/2026_01_20_160648_create_service_letter_configs_table.php`

| Coluna | Tipo | Null | Default |
|---|---|---|---|
| `id` | integer | não | seq |
| `updated_by` | bigint | sim | — | FK → `users.id` |
| `nome` | varchar(191) | não | `'Carta de Serviços'` |
| `descricao` | text | sim | — |
| `cabecalho` | text | sim | — | HTML do cabeçalho do PDF |
| `rodape` | text | sim | — | HTML do rodapé do PDF |
| `texto_antes_sumario` | text | sim | — | HTML |
| `texto_depois_sumario` | text | sim | — | HTML |
| `texto_encerramento` | text | sim | — | HTML |
| `exibir_sumario` | boolean | não | `true` | — |
| `tipo_capa` | varchar(20) | não | `'editor'` | ∈ {`editor`,`pdf`} |
| `capa_conteudo_editor` | text | sim | — | **campo órfão**: o bloco do editor está comentado no blade |
| `capa_arquivo_pdf` | varchar(500) | sim | — | — |
| `ordenacao_servicos` | varchar(20) | não | `'secretaria'` | ∈ {`secretaria`,`assunto`} (o controller trata qualquer valor ≠ `secretaria` como "ordenar por nome") |
| `fonte_texto` | varchar(100) | não | `'Arial'` | — |
| `tamanho_fonte_texto` | smallint | não | `12` | — |
| `cor_texto` | varchar(7) | não | `'#000000'` | — |
| `fonte_titulo` | varchar(100) | não | `'Arial'` | — |
| `tamanho_fonte_titulo` | smallint | não | `16` | — |
| `cor_titulo` | varchar(7) | não | `'#000000'` | — |
| `ativo` | boolean | não | `true` | — |
| `publicado` | boolean | não | `false` | — |
| `publicado_em` | timestamp | sim | — | — |
| `created_at`/`updated_at` | timestamp | sim | — | — |

Não há `deleted_at` — o model `ServiceLetterConfig` **não** usa `SoftDeletes`, coerente.

## 6. Tabelas filhas

### 6.1 `service_letter_channels`
`v6/database/migrations/2026_01_20_160723_create_service_letter_channels_table.php`

| Coluna | Tipo | Null | Default | Notas |
|---|---|---|---|---|
| `id` | integer | não | seq | — |
| `service_letter_id` | integer | não | — | FK → `service_letters.id` CASCADE |
| `tipo` | varchar(20) | **não** | — | ∈ {`online`,`telefone`,`presencial`} |
| `nome` | varchar(191) | sim | — | — |
| `descricao` | text | sim | — | — |
| `url` | varchar(500) | sim | — | usado quando `tipo=online` |
| `telefone` | varchar(20) | sim | — | usado quando `tipo=telefone` |
| `endereco` | text | sim | — | usado quando `tipo=presencial` |
| `horario_funcionamento` | varchar(191) | sim | — | — |
| `ordem` | smallint | não | `0` | — |
| `ativo` | boolean | não | `true` | — |
| `created_at`/`updated_at`/`deleted_at` | timestamp | sim | — | — |

### 6.2 `service_letter_steps`
`v6/database/migrations/2026_01_20_160726_create_service_letter_steps_table.php`

| Coluna | Tipo | Null | Default | Notas |
|---|---|---|---|---|
| `id` | integer | não | seq | — |
| `service_letter_id` | integer | não | — | FK → `service_letters.id` CASCADE |
| `numero` | smallint | **não** | — | número da etapa (exibido no portal) |
| `titulo` | varchar(191) | **não** | — | — |
| `descricao` | text | sim | — | — |
| `prazo` | varchar(100) | sim | — | texto livre (ex.: "5 dias úteis") |
| `ordem` | smallint | não | `0` | ordenação da relação Eloquent |
| `ativo` | boolean | não | `true` | — |
| `created_at`/`updated_at`/`deleted_at` | timestamp | sim | — | — |

### 6.3 `service_letter_documents`
`v6/database/migrations/2026_01_20_160729_create_service_letter_documents_table.php`

| Coluna | Tipo | Null | Default |
|---|---|---|---|
| `id` | integer | não | seq |
| `service_letter_id` | integer | não | — | FK CASCADE |
| `nome` | varchar(191) | não | — |
| `descricao` | text | sim | — |
| `obrigatorio` | boolean | não | `true` |
| `ordem` | smallint | não | `0` |
| `ativo` | boolean | não | `true` |
| `created_at`/`updated_at`/`deleted_at` | timestamp | sim | — |

### 6.4 `service_letter_attachments`
`v6/database/migrations/2026_01_20_160732_create_service_letter_attachments_table.php`

| Coluna | Tipo | Null | Default | Notas |
|---|---|---|---|---|
| `id` | integer | não | seq | — |
| `service_letter_id` | integer | não | — | FK CASCADE |
| `nome` | varchar(191) | **não** | — | — |
| `descricao` | varchar(500) | sim | — | — |
| `arquivo` | varchar(500) | **não** | — | caminho relativo prefixado com `storage/` |
| `tipo_arquivo` | varchar(100) | sim | — | mime type |
| `tamanho_bytes` | bigint | sim | — | — |
| `ordem` | smallint | não | `0` | — |
| `ativo` | boolean | não | `true` | — |
| `created_at`/`updated_at`/`deleted_at` | timestamp | sim | — | — |

### 6.5 `service_letter_responsaveis`
`v6/database/migrations/2026_01_20_231045_create_service_letters_responsaveis_table.php`

| Coluna | Tipo | Null | Default | Notas |
|---|---|---|---|---|
| `id` | integer | não | seq | — |
| `service_letter_id` | bigint | sim | — | FK → `service_letters.id` CASCADE (nullable) |
| `nome` | varchar(191) | sim | — | — |
| `cargo` | varchar(191) | sim | — | — |
| `email` | varchar(191) | sim | — | — |
| `telefone` | varchar(20) | sim | — | — |
| `foto` | varchar(500) | sim | — | — |
| `ordem` | smallint | não | `0` | — |
| `ativo` | boolean | não | `true` | — |
| `created_at`/`updated_at`/`deleted_at` | timestamp | sim | — | — |

> O model `ServiceLetterResponsavel` define `protected $table = 'service_letter_responsaveis';` explicitamente (o plural automático seria `service_letter_responsaveis` de `ServiceLetterResponsavel` — divergência resolvida à mão).

### 6.6 `service_letter_pdfs`
`v6/database/migrations/2026_01_20_160736_create_service_letter_pdfs_table.php`

| Coluna | Tipo | Null | Default | Notas |
|---|---|---|---|---|
| `id` | integer | não | seq | — |
| `config_id` | integer | **não** | — | FK → `service_letter_configs.id` CASCADE |
| `arquivo` | varchar(500) | **não** | — | caminho relativo prefixado com `storage/` |
| `nome_arquivo` | varchar(191) | **não** | — | nome para download |
| `tamanho_bytes` | bigint | sim | — | — |
| `total_servicos` | integer | não | `0` | — |
| `total_paginas` | integer | sim | — | — |
| `ordenacao_utilizada` | varchar(20) | sim | — | — |
| `incluiu_sumario` | boolean | sim | — | — |
| `status` | varchar(20) | não | `'gerado'` | ∈ {`gerado`,`publicado`,`erro`} (o blade conhece esses 3 + fallback "Gerando...") |
| `erro_mensagem` | text | sim | — | — |
| `gerado_em` | timestamp | não | `useCurrent()` | — |
| `gerado_por` | bigint | sim | — | — |
| `publicado_em` | timestamp | sim | — | — |
| `publicado_por` | bigint | sim | — | — |
| `created_at`/`updated_at`/`deleted_at` | timestamp | sim | — | — |

O model `ServiceLetterPdf` tem `public $timestamps = false;` — mas a tabela **tem** `created_at`/`updated_at` (criados pela migration). Divergência verificada.

### 6.7 `service_letter_access_logs` (órfã)
`v6/database/migrations/2026_01_20_160739_create_service_letter_access_logs_table.php`

| Coluna | Tipo | Null | Default |
|---|---|---|---|
| `id` | integer | não | seq |
| `service_letter_id` | integer | não | — | FK CASCADE |
| `data_acesso` | date | não | — |
| `quantidade` | integer | não | `1` |
| `origem` | varchar(50) | não | `'portal'` |
| `created_at`/`updated_at` | timestamp | sim | — |

UNIQUE (`service_letter_id`, `data_acesso`, `origem`).

O model `ServiceLetterAccessLog` tem `public $timestamps = false;` **e** `protected $table = 'service_letters_access_logs';` (com "s" em `letters`) — enquanto a tabela real é `service_letter_access_logs` (singular). **Divergência verificada**: mesmo que alguém passe a gravar, a relation `ServiceLetter::accessLogs()` apontaria para uma tabela inexistente. Ver `08-GAPS-E-DIVIDAS.md`.

## 8. Relacionamentos (resumo)

| Origem | Cardinalidade | Destino | Chave |
|---|---|---|---|
| `ServiceLetter.subjects()` | N:N | `ServiceLetterSubject` | `service_letters_subjects_rel` (`service_letter_id`, `subject_id`) |
| `ServiceLetterSubject.serviceLetters()` | N:N | `ServiceLetter` | idem (roles invertidos) |
| `ServiceLetter.secretaria()` | N:1 | `Content` (`contents`) | `secretaria_id` |
| `ServiceLetter.channels()` | 1:N | `ServiceLetterChannel` | `service_letter_id` + `orderBy('ordem')` |
| `ServiceLetter.steps()` | 1:N | `ServiceLetterStep` | `service_letter_id` + `orderBy('ordem')` |
| `ServiceLetter.documents()` | 1:N | `ServiceLetterDocument` | `service_letter_id` + `orderBy('ordem')` |
| `ServiceLetter.attachments()` | 1:N | `ServiceLetterAttachment` | `service_letter_id` + `orderBy('ordem')` |
| `ServiceLetter.responsaveis()` | 1:N | `ServiceLetterResponsavel` | `service_letter_id` + `orderBy('ordem')` |
| `ServiceLetter.accessLogs()` | 1:N | `ServiceLetterAccessLog` | `service_letter_id` (quebrado) |
| `ServiceLetterConfig.pdfs()` | 1:N | `ServiceLetterPdf` | `config_id` |

