# Licitantes Sancionados — Modelagem de Dados

> Esquema verificado no banco v5 (`v5_3f273858-postgres-1`, database `prefeituras_v5`),
> em modo **read-only**. A v6 não possui nenhuma das duas Relation Objects.

---

## 1. Tabela `licitantes_sancionados`

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

```php
Schema::create('licitantes_sancionados', function (Blueprint $table) {
    $table->increments('id');
    $table->integer('ano');
    $table->string('numero_sancao', 50);
    $table->string('nome_razao_social', 255);
    $table->string('cpf_cnpj', 18);
    $table->date('inicio_sancao');
    $table->date('fim_sancao');
    $table->text('arquivo')->nullable();
    $table->timestamps();
    $table->softDeletes();
});
```

### Colunas (estado real no banco v5)

| # | Coluna | Tipo | Null | Default | Observação |
|---|---|---|---|---|---|
| 1 | `id` | `integer` | não | `nextval('licitantes_sancionados_id_seq')` | PK, `increments` |
| 2 | `ano` | `integer` | não | — | Ano da sanção; validado entre 1900 e 2100 |
| 3 | `numero_sancao` | `character varying(50)` | não | — | Número do processo/sanção |
| 4 | `nome_razao_social` | `character varying(255)` | não | — | Razão social ou nome do sancionado |
| 5 | `cpf_cnpj` | `character varying(18)` | não | — | Formatado no formulário (máscara JS) |
| 6 | `inicio_sancao` | `date` | não | — | Início da vigência da sanção |
| 7 | `fim_sancao` | `date` | não | — | Fim da vigência da sanção |
| 8 | `arquivo` | `text` | **sim** | — | JSON com a lista de anexos (`null` quando sem anexo) |
| 9 | `created_at` | `timestamp(0) without time zone` | sim | — | `timestamps()` do Eloquent |
| 10 | `updated_at` | `timestamp(0) without time zone` | sim | — | `timestamps()` do Eloquent |
| 11 | `deleted_at` | `timestamp(0) without time zone` | sim | — | `softDeletes()` |
| 12 | `slug` | `character varying(191)` | **sim** | — | Adicionado depois por migration; ver §4 |

### Índices

```sql
-- saída de \d licitantes_sancionados (banco v5)
Indexes:
    "licitantes_sancionados_pkey" PRIMARY KEY, btree (id)
```

| Índice | Tipo | Colunas | Observação |
|---|---|---|---|
| `licitantes_sancionados_pkey` | UNIQUE (btree) | `id` | Único índice existente na tabela |

> **Não existe índice único em `slug`.** A coluna `slug` é `varchar(191)` **nullable** e
> **sem nenhum índice** (nem único, nem não-único). A unicidade é apenas *consequente*
> do formato `str_slug(nome-id)`, que inclui a PK — mas o banco não a garante. Ver
> `08-GAPS-E-DIVIDAS.md`, G1.

### Volume atual

```sql
SELECT count(*) FROM licitantes_sancionados;  -- 0 (banco v5)
```

A tabela está **vazia** no banco v5: nenhuma sanção foi cadastrada.

## 2. View SQL `licitantes_sancionados_view`

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

```php
DB::statement("DROP VIEW IF EXISTS public.licitantes_sancionados_view");
DB::statement("
    CREATE OR REPLACE VIEW public.licitantes_sancionados_view AS
    SELECT
        row_number() OVER (ORDER BY ls.created_at DESC) AS value,
        ls.id,
        ls.ano,
        ls.numero_sancao,
        ls.nome_razao_social,
        ls.cpf_cnpj,
        ls.inicio_sancao,
        ls.fim_sancao,
        ls.arquivo
    FROM licitantes_sancionados ls
    WHERE ls.deleted_at IS NULL
    ORDER BY ls.created_at DESC
");
```

Definição effective no banco (`pg_get_viewdef`):

```sql
SELECT row_number() OVER (ORDER BY ls.created_at DESC) AS value,
       ls.id, ls.ano, ls.numero_sancao, ls.nome_razao_social, ls.cpf_cnpj,
       ls.inicio_sancao, ls.fim_sancao, ls.arquivo
FROM licitantes_sancionados ls
WHERE ls.deleted_at IS NULL
ORDER BY ls.created_at DESC
```

| Coluna da view | Origem | Uso |
|---|---|---|
| `value` | `row_number()` sobre `created_at DESC` | Coluna ordinal exibida pelo DataTable do CMS (`runDatatables` cria uma coluna `value` automaticamente) |
| `id` | `licitantes_sancionados.id` | Usado pelas ações (editar/excluir) do CMS |
| `ano` … `fim_sancao` | idem | Colunas exibidas no CMS |
| `arquivo` | idem | **Exposto no JSON do DataTable** (apesar de não ser exibido na tabela) |

Pontos importantes:

- A view **não expõe `slug`**, `created_at`, `updated_at` nem `deleted_at`.
- A view **aplica** o filtro `deleted_at IS NULL`, replicando o escopo global de
  `SoftDeletes` — mas isso é **duplicação**: a regra de exclusão existe em dois lugares
  (model e view) e pode divergir.
- A view é consumida **somente** pelo `DatatableController`; o portal usa o model.

## 3. Migrations do módulo

| Ordem | Arquivo | Data | Efeito |
|---|---|---|---|
| 1 | `v5/database/migrations/2026_05_26_000000_create_licitantes_sancionados_table.php` | 2026-05-26 | Cria a tabela `licitantes_sancionados` (idempotente: `if (!Schema::hasTable(...))`) |
| 2 | `v5/database/migrations/2026_05_26_000001_create_view_licitantes_sancionados.php` | 2026-05-26 | Drop + create da view `licitantes_sancionados_view` |
| 3 | `v5/database/migrations/2026_05_26_000002_add_slug_to_licitantes_sancionados_table.php` | 2026-05-26 | Adiciona a coluna `slug` (nullable) e **preenche** os registros existentes |

As três migrations têm **a mesma data** (`2026_05_26`), com sufixos `000000`, `000001`
e `000002` para forçar a ordem. O `down()` da migration 2 e da 3 remove a view e a
coluna; o da 1 derruba a tabela.

## 4. Coluna `slug` — duas rotinas de geração divergentes

### 4.1 Migration (backfill em SQL puro)

```php
DB::statement("
    UPDATE licitantes_sancionados
    SET slug = trim('-' FROM regexp_replace(
        lower(
            translate(
                nome_razao_social || '-' || id::text,
                'àáâãäèéêëìíîïòóôõöùúûüçñÀÁÂÃÄÈÉÊËÌÍÎÏÒÓÔÕÖÙÚÛÜÇÑ',
                'aaaaaeeeeiiiioooouuuucnaaaaaeeeeiiiioooouuuucn'
            )
        ),
        '[^a-z0-9]+', '-', 'g'
    ))
    WHERE slug IS NULL OR slug = ''
");
```

### 4.2 Model (runtime)

```php
static::created(function ($model) {
    $slug = str_slug($model->nome_razao_social . '-' . $model->id);
    $model->slug = $slug;
    $model->save();
});

static::updating(function ($model) {
    $model->slug = str_slug($model->nome_razao_social . '-' . $model->id);
});
```

### 4.3 Resultado comparativo (verificado)

| Entrada (`nome_razao_social`, id) | Migration (SQL) | Model (`str_slug`) |
|---|---|---|
| `Construções Ação & Cia Ltda`, 42 | `construnoes-anao-cia-ltda-42` | `construcoes-acao-cia-ltda-42` |
| `JOÃO DA SILVA LTDA`, 7 | — | `joao-da-silva-ltda-7` |

Motivo: a migration usa a tabela `translate()` do PostgreSQL com **48 caracteres de
origem e 46 de destino** — desalinhada a partir do 18º caractere (`ö` vira `u`,
`ç` vira `n`, `ñ` vira `a`). Já `str_slug()` (Laravel) usa `Str::ascii()`, que
translitera corretamente. As duas rotinas **não produzem o mesmo slug** para o mesmo
nome. Ver `08-GAPS-E-DIVIDAS.md`, G4.

## 5. Relacionamentos

**Não há nenhum relacionamento** (nenhuma FK, pivot ou relação Eloquent) —
`licitantes_sancionados` é uma tabela isolada. A chave `id` é referenciada apenas
pela URL do portal (`/licitante-sancionado/{slug}`) e pelas rotas do CMS.

O model declara:

```php
protected $table = 'licitantes_sancionados';

protected $fillable = [
    'ano', 'numero_sancao', 'nome_razao_social', 'cpf_cnpj',
    'inicio_sancao', 'fim_sancao', 'arquivo', 'slug',
];

protected $dates = ['inicio_sancao', 'fim_sancao', 'deleted_at'];
```

Observações:

- `slug` está no `$fillable`, mas o controller nunca o envia no `create()`/`update()` —
  quem o define é o evento do model. Ainda assim, estar no `$fillable` permite
  mass-assignment de `slug` caso algum código futuro passe a enviá-lo.
- `deleted_at` aparece em `$dates` **além** do `SoftDeletes`; redundante, porém inofensivo.
- Não há `$casts`; as datas viram `Carbon` por causa de `$dates`.

## 6. Serialização da coluna `arquivo`

Formato persistido (JSON em coluna `text`):

```json
[
  {"path": "docs/5f3c1a2b3c.pdf", "description": "Processo 123/2026 - fls. 4"},
  {"path": "docs/9d8e7f6a5b.pdf", "description": ""}
]
```

- Gravação (CMS): `LicitanteSancionadoController@store` e `@update` montam o array e
  fazem `json_encode($items)` / `json_encode(array_values($existing))`.
- Leitura (CMS e portal): as views fazem
  `json_decode($sancionado->arquivo, true) ?: [$sancionado->arquivo]` e depois
  normalizam itens antigos (string pura) para `['path' => ..., 'description' => '']`.
- Remoção de um anexo (`destroyArquivo`): `array_splice` + re-encode; o **arquivo
  físico permanece no storage**.

## 7. Permissões e recursos

O módulo depende de permissões com o prefixo **`licitante-sancionado`** (singular),
usado em três lugares:

| Uso | Slug | Arquivo |
|---|---|---|
| Item de menu "Sancionados" | `licitante-sancionado.index` | `v5/app/Providers/MenuServiceProvider.php:106` |
| Botão "Cadastrar" | `licitante-sancionado.create` | `v5/resources/views/prefecture/licitantes-sancionados/index.blade.php` |
| Botões de linha (editar/excluir) | `licitante-sancionado.edit` / `.delete` | `v5/app/Http/Controllers/Prefecture/DatatableController.php:188-192` |

**A tabela `resources` do banco v5 NÃO possui um registro com slug
`licitante-sancionado`.** Verificação:

```sql
SELECT id, slug, name FROM resources WHERE slug ILIKE '%licitante%';
-- 0 rows
```

Como as permissões do `cartalyst/sentinel` são strings `"<resource-slug>.<permissao>"`
gravadas no JSON da coluna `roles.permissions`, e nenhum papel do banco v5 possui
chaves `licitante-sancionado.*`:

```sql
SELECT count(*) FROM roles WHERE permissions::text LIKE '%licitante-sancionado%';
-- 0
```

apenas o papel `super-admin` (que contorna a checagem em `has_access()`) consegue
operar o módulo. Ver `08-GAPS-E-DIVIDAS.md`, G5.

## 8. Ausência de seeds

Não há seeder para `licitantes_sancionados` em `v5/database/seeds/` nem em
`v5/database/factories/`. A tabela começa vazia e é populada apenas pela operação
do CMS.

## 9. Referência rápida de DDL

```sql
-- equivalente para a v7 (PostgreSQL)
CREATE TABLE licitantes_sancionados (
    id              serial PRIMARY KEY,
    ano             integer      NOT NULL,
    numero_sancao   varchar(50)  NOT NULL,
    nome_razao_social varchar(255) NOT NULL,
    cpf_cnpj        varchar(18)  NOT NULL,
    inicio_sancao   date         NOT NULL,
    fim_sancao      date         NOT NULL,
    arquivo         text         NULL,
    created_at      timestamp    NULL,
    updated_at      timestamp    NULL,
    deleted_at      timestamp    NULL,
    slug            varchar(191) NULL
);
```
