# Legislação — Modelagem

> **Slug:** `Legislacao` · Banco: PostgreSQL · Tabelas com prefixo `v2_`
>
> Caminhos de migration relativos a `lotep/`.

---

## 1. Visão geral

```mermaid
erDiagram
    v2_legislation_categories ||--o{ v2_legislation : "classifica"
    v2_legislation ||--o{ v2_legislation_files : "anexa"

    v2_legislation_categories {
        string id PK
        string name
        string slug UK
        text description
        boolean is_active
        integer order
        jsonb metadata
    }
    v2_legislation {
        string id PK
        string title
        string slug UK
        string summary
        longtext content
        string number
        integer year
        datetime publication_date
        string file_url
        boolean is_active
        boolean is_highlighted
        string v2_legislation_category_id FK
        jsonb metadata
        integer v1_id UK
    }
    v2_legislation_files {
        string id PK
        string legislation_id FK
        string file_url
        string title
        string description
        integer order
        boolean view_pdf
        string original_name
        string mime_type
        bigint file_size
    }
```

Não há **views SQL** no módulo. Toda leitura é feita por Eloquent ou `DB::table`
no DataSource de estatísticas.

## 2. `v2_legislation_categories`

Criada em `V2/Modules/Publicacoes/Legislacao/Migrations/2026_07_17_000001_create_v2_legislation_categories_table.php:17-28`.

| Coluna | Tipo | Nulo | Default | Observação |
|---|---|---|---|---|
| `id` | `varchar(36)` | não | — | PK; UUID v4 gerado por `v2_uuid()`. |
| `name` | `varchar(255)` | não | — | Nome exibido; usado para gerar slug e detectar duplicidade. |
| `slug` | `varchar(255)` | não | — | `UNIQUE`; também indexado explicitamente. |
| `description` | `text` | sim | `NULL` | Texto livre até 500 caracteres na validação. |
| `is_active` | `boolean` | não | `true` | Categoria inativa não aparece no filtro do portal. |
| `order` | `integer` | não | `0` | Ordenação ascendente; empate resolvido por `name`. |
| `metadata` | `jsonb` | sim | `NULL` | Reservado; o módulo não grava nada. |
| `created_at`/`updated_at` | `timestamp` | sim | `NULL` | Padrão Eloquent. |
| `deleted_at` | `timestamp` | sim | `NULL` | Soft delete. |

Índices/constraints:

- `PRIMARY KEY (id)`
- `UNIQUE (slug)`
- `INDEX (slug)` (redundante com o unique, criado explicitamente na migration)

**Regra de slug.** O model tem dois hooks:

```php
static::saving(function ($m) {
    $base = $m->slug ?: (is_string($m->name) ? Str::slug($m->name) : '');
    if ($base === '') {
        return;
    }
    $slug = $base;
    $counter = 1;
    $query = static::withTrashed()->where('slug', $slug);
    if (!empty($m->id)) {
        $query->where('id', '!=', $m->id);
    }
    while ($query->exists()) {
        $slug = $base . '-' . $counter;
        $counter++;
        $query = static::withTrashed()->where('slug', $slug);
        if (!empty($m->id)) {
            $query->where('id', '!=', $m->id);
        }
    }
    $m->slug = $slug;
});
```

(`V2/Modules/Publicacoes/Legislacao/Models/V2LegislationCategory.php:69-92`)

No `updating`, se `name` mudou e `slug` não foi tocado, o slug é regenerado
(`:93-99`). O controller, porém, **sempre envia** um slug recalculado no update
(`LegislationCmsController.php:200-209`), então o hook de `updating` quase nunca
entra em ação pela UI.

## 3. `v2_legislation`

Criada em `V2/Modules/Publicacoes/Legislacao/Migrations/2026_07_17_000002_create_v2_legislation_table.php:17-36`;
campos de importação acrescentados em `2026_08_24_000003_add_v1_import_tracking_to_v2_legislation_table.php:38-56`.

| Coluna | Tipo | Nulo | Default | Observação |
|---|---|---|---|---|
| `id` | `varchar(36)` | não | — | PK UUID v4. |
| `title` | `varchar(255)` | não | — | Título da norma; base do slug. |
| `slug` | `varchar(255)` | não | — | `UNIQUE`; sem unicidade tratada no model. |
| `summary` | `varchar(500)` | sim | `NULL` | Ementa curta; validada em 500. |
| `content` | `longtext` | sim | `NULL` | **Texto rico em JSON de AST TipTap**, não HTML. |
| `number` | `varchar(50)` | sim | `NULL` | Número do ato, preservando zeros (`"028"`). |
| `year` | `integer` | sim | `NULL` | Ano do ato; indexado. |
| `publication_date` | `datetime` | sim | `NULL` | Data/hora; base do agendamento. |
| `file_url` | `varchar(500)` | sim | `NULL` | Documento principal legado (path ou URL). |
| `is_active` | `boolean` | não | `true` | Publicação exige `true`. |
| `is_highlighted` | `boolean` | não | `false` | Destaque; sem UI no módulo. |
| `v2_legislation_category_id` | `varchar(36)` | sim | `NULL` | FK `ON DELETE SET NULL`. |
| `metadata` | `jsonb` | sim | `NULL` | Importador legado grava `{"imported_from":"v1"}`. |
| `created_at`/`updated_at` | `timestamp` | sim | `NULL` | — |
| `deleted_at` | `timestamp` | sim | `NULL` | Soft delete. |
| `v1_id` | `integer` | sim | `NULL` | PK na V1; único parcial `WHERE v1_id IS NOT NULL`. |
| `imported_at` | `timestamp` | sim | `NULL` | Marca da importação legada. |

Índices/constraints:

- `PRIMARY KEY (id)`
- `UNIQUE (slug)`
- `INDEX (slug)`, `INDEX (year)`
- `FOREIGN KEY (v2_legislation_category_id) REFERENCES v2_legislation_categories(id) ON DELETE SET NULL`
- `CREATE UNIQUE INDEX v2_legislation_v1_id_unique ON v2_legislation (v1_id) WHERE v1_id IS NOT NULL`

### 3.1 Escopos do model

| Escopo | SQL efetivo | Uso |
|---|---|---|
| `active()` | `is_active = true` | CMS/consultas administrativas. |
| `published()` | `is_active = true AND publication_date <= now()` | Portal e download. |
| `fromDate($d)` | `date(publication_date) >= $d` (ignora string vazia) | Filtro do portal. |
| `toDate($d)` | `date(publication_date) <= $d` | Filtro do portal. |
| `ordered()` | `publication_date DESC, created_at DESC` | Listagem do portal. |
| `search($t)` | `(title ILIKE %t% OR summary ILIKE %t% OR number ILIKE %t%)` em closure | Busca. |

`publication_date` nula **não** passa em `published()`; é um efeito colateral
relevante para dados importados sem data.

### 3.2 Hooks de ciclo de vida

```php
static::creating(function ($m) {
    if (empty($m->id)) {
        $m->id = \v2_uuid();
    }
    if (empty($m->slug) && !empty($m->title)) {
        $m->slug = Str::slug($m->title);
    }
});
static::updating(function ($m) {
    if ($m->isDirty('title') && !$m->isDirty('slug')) {
        $m->slug = Str::slug($m->title);
    }
});
```

(`V2/Modules/Publicacoes/Legislacao/Models/V2Legislation.php:82-94`)

Não há tratamento de colisão: dois títulos iguais produzem o mesmo slug e a
segunda gravação falha na constraint `UNIQUE` (erro 500), a menos que a
importação use `uniqueLegislationSlug()`.

## 4. `v2_legislation_files`

Criada em `V2/Modules/Publicacoes/Legislacao/Migrations/2026_08_24_000004_create_v2_legislation_files_table.php:66-81`;
enriquecida por `2026_09_28_000001_add_file_metadata_to_v2_legislation_files_table.php:29-41`
e `2026_09_28_000002_add_upload_metadata_to_v2_legislation_files_table.php:29-48`.

| Coluna | Tipo | Nulo | Default | Observação |
|---|---|---|---|---|
| `id` | `varchar(36)` | não | — | PK UUID v4. |
| `legislation_id` | `varchar(36)` | sim | `NULL` | FK; `ON DELETE SET NULL`; indexado. |
| `file_url` | `varchar(500)` | não | — | Path no disco `public` **ou** URL absoluta. |
| `title` | `varchar(255)` | sim | `NULL` | Nome exibido; fallback para `original_name`. |
| `order` | `integer` | sim | `0` | Ordem de exibição. |
| `created_at`/`updated_at` | `timestamp` | sim | `NULL` | — |
| `deleted_at` | `timestamp` | sim | `NULL` | Soft delete. |
| `description` | `varchar(500)` | sim | `NULL` | Legenda ao lado do link. |
| `view_pdf` | `boolean` | não | `false` | Abrir em leitor embutido quando `is_pdf`. |
| `original_name` | `varchar(255)` | sim | `NULL` | Nome enviado pelo CMS. |
| `mime_type` | `varchar(100)` | sim | `NULL` | MIME detectado no upload. |
| `file_size` | `bigint` unsigned | sim | `NULL` | Bytes. |

Índices/constraints:

- `PRIMARY KEY (id)`
- `INDEX (legislation_id)`
- `FOREIGN KEY (legislation_id) REFERENCES v2_legislation(id) ON DELETE SET NULL`

### 4.1 Semântica dos accessors

| Accessor | Regra |
|---|---|
| `display_name` | `title` → `original_name` → `"Documento anexo"`. |
| `is_pdf` | MIME `application/pdf` **ou** extensão `pdf` do `file_url`/`original_name`. |
| `url` | `resolveMediaUrl(file_url)`: URL `http(s)://` ou `//` é mantida; caminho relativo vira `v2_storage_url()`. |

### 4.2 Ordenação

A relação `files()` já ordena por `order ASC, created_at ASC`
(`V2Legislation.php:106-111`), e o escopo `ordered()` repete a regra
(`V2LegislationFile.php:140-143`). A reordenação grava a posição zero-based
recebida do front (`LegislationFileService.php:174-182`).

## 5. Migrations

| Data | Arquivo | O que faz | `down()` |
|---|---|---|---|
| 2026-07-17 | `2026_07_17_000001_create_v2_legislation_categories_table.php` | Cria categorias. | Renomeia para `backup_v2_legislation_categories_<timestamp>`. |
| 2026-07-17 | `2026_07_17_000002_create_v2_legislation_table.php` | Cria a norma e a FK. | Renomeia para `backup_v2_legislation_<timestamp>`. |
| 2026-08-24 | `2026_08_24_000003_add_v1_import_tracking_to_v2_legislation_table.php` | `v1_id`, `imported_at`, unique parcial. | Drop de índice e colunas (guardado por `hasColumn`). |
| 2026-08-24 | `2026_08_24_000004_create_v2_legislation_files_table.php` | Cria anexos; renomeia índices órfãos de backups antes. | Renomeia para `backup_v2_legislation_files_<timestamp>`. |
| 2026-09-28 | `2026_09_28_000001_add_file_metadata_to_v2_legislation_files_table.php` | `description`, `view_pdf`. | Drop guardado. |
| 2026-09-28 | `2026_09_28_000002_add_upload_metadata_to_v2_legislation_files_table.php` | `original_name`, `mime_type`, `file_size`. | Drop guardado. |

Padrões repetidos: transação explícita, `SET lock_timeout = '3s'` nas alterações
de tabela existente, `hasTable`/`hasColumn` para idempotência e `down()` que
preserva dados renomeando a tabela para `backup_*`. A criação de
`v2_legislation_files` contém uma rotina extra `renameBackupConflicts()` para
renomear índices/constraints órfãos que permanecem apontando para tabelas
`backup_` após um ciclo migrate → rollback → migrate.

## 6. Seeds

`V2/Modules/Publicacoes/Legislacao/Seeds/LegislationSeeder.php`:

- **Sempre executa:** cria/reativa as categorias `leis` (ordem 1), `decretos`
  (2) e `portarias` (3), buscando por slug com `withTrashed()` (`:33-47`).
- **Somente com `run(true)`:** três documentos demo (Lei 10.000/2025, Decreto
  45.800/2025 e Portaria 028/2026), o último com dois anexos. O `content` demo é
  convertido de HTML para AST via `HtmlToTiptapConverter` (`:105-118`).
- **Idempotência:** documentos são pulados se o slug já existir, inclusive
  soft-deleted (`:107-110`).

O seeder não é pré-requisito do sistema no V7 (ADR-041: seeds só demonstração);
a taxonomia inicial deve vir de migration idempotente.

## 7. Integridade e dados ausentes

- `metadata` existe nas três tabelas, mas só é preenchido pelo importador legado
  (`v2_legislation`) e nunca por categorias/anexos.
- Não há `NOT NULL` em `publication_date`, nem `CHECK` de coerência entre
  `number` e `year`; a unicidade lógica `number+year` é apenas convenção do
  importador, **sem índice único**.
- `file_url` é `NOT NULL` em anexos, mas aceita string vazia; o portal tenta
  resolver e pode produzir link vazio.
- O soft delete das três tabelas não é acompanhado de constraints parciais nos
  índices únicos: `slug` continua único globalmente, inclusive entre linhas
  apagadas (por isso a renomeação na exclusão de categoria).
