# Obras — Modelagem de Dados

> Versão analisada: `v5` (PostgreSQL 10). Fonte: `\d` no banco `prefeituras_v5`
> do container `v5_3f273858-postgres-1` (leitura apenas).

---

## 1. Diagrama relacional

```
work_categories (id, name, deleted_at, timestamps)
        │  ▲
        │  │ works_work_category_id_foreign  ON DELETE SET NULL
        │  │
        ▼  │
┌───────────────────────────────────────────────────────────┐
│ works                                                     │
│  id, title, edital, contract,                             │
│  work_category_id ───────────► work_categories.id        │
│  status_id ──────────────────► work_statuses.id          │
│  value, value_paid, start_date, end_date,                 │
│  cep, state, city, neighborhood, street,                  │
│  house_number, complement, latitude, longitude,           │
│  company_holder, holder, gallery,                         │
│  justification, description,                              │
│  estimated_completion_date, completion_percentage,        │
│  created_at, updated_at, deleted_at                       │
└───────────────────────────────────────────────────────────┘
        ▲                                    ▲
        │ work_archives_work_id_foreign      │ work_archives_archive_id_foreign
        │ ON DELETE CASCADE                  │ ON DELETE CASCADE
        │                                    │
┌───────────────────────────┐      ┌──────────────────────────────┐
│ work_statuses             │      │ work_archives                │
│  id, name, deleted_at,    │      │  id, work_id, archive_id,     │
│  timestamps               │      │  type ('file' | 'gallery'),  │
└───────────────────────────┘      │  deleted_at, timestamps      │
                                   └──────────────────────────────┘
                                            ▲
                                            │
                                   ┌────────────────────────────┐
                                   │ archives  (TABELA GLOBAL)  │
                                   │  id, title, description,    │
                                   │  type, hash, path, name,    │
                                   │  extension, status_create,  │
                                   │  highlight, view_pdf,       │
                                   │  contents_id, static_pages_id│
                                   │  biddings_id,               │
                                   │  calendar_events_id, order, │
                                   │  reference_id, type_size_id,│
                                   │  width, height,             │
                                   │  requerimento_id,           │
                                   │  highlight_id, category_id, │
                                   │  deleted_at, timestamps     │
                                   └────────────────────────────┘
```

## 2. Tabela `works`

Colunas exatas (de `psql -c "\d works"`):

| # | Coluna | Tipo | Null | Default | Observação |
|---|---|---|---|---|---|
| 1 | `id` | `integer` | NOT NULL | `nextval('works_id_seq')` | PK `works_pkey` (btree) |
| 2 | `title` | `varchar(191)` | NOT NULL | — | Título da obra |
| 3 | `edital` | `varchar(191)` | NOT NULL | — | Número do edital |
| 4 | `contract` | `varchar(191)` | NOT NULL | — | Número do contrato |
| 5 | `work_category_id` | `bigint` | NULL | — | FK → `work_categories.id` (SET NULL) |
| 6 | `status_id` | `bigint` | NULL | — | FK → `work_statuses.id` (SET NULL) |
| 7 | `value` | `varchar(191)` | NOT NULL | — | Valor total — **texto, não numérico** |
| 8 | `value_paid` | `varchar(191)` | NULL | — | Valor pago — **texto** |
| 9 | `start_date` | `date` | NOT NULL | — | Data de início |
| 10 | `end_date` | `date` | NULL | — | Data de fim |
| 11 | `cep` | `varchar(191)` | NOT NULL | — | CEP (máscara no front) |
| 12 | `state` | `varchar(191)` | NOT NULL | — | Sigla da UF |
| 13 | `city` | `varchar(191)` | NOT NULL | — | Município |
| 14 | `neighborhood` | `varchar(191)` | NOT NULL | — | Bairro |
| 15 | `street` | `varchar(191)` | NOT NULL | — | Logradouro |
| 16 | `house_number` | `varchar(191)` | NOT NULL | — | Número |
| 17 | `complement` | `varchar(191)` | NULL | — | Complemento |
| 18 | `latitude` | `varchar(191)` | NULL | — | **Texto** (não `numeric`) |
| 19 | `longitude` | `varchar(191)` | NULL | — | **Texto** (não `numeric`) |
| 20 | `company_holder` | `varchar(191)` | NULL | — | Empresa responsável (no banco é NULL-able, mas o form exige) |
| 21 | `holder` | `varchar(191)` | NOT NULL | — | Responsável pela prefeitura |
| 22 | `gallery` | `boolean` | NOT NULL | `false` | Flag da galeria de fotos |
| 23 | `created_at` | `timestamp(0)` | NULL | — | |
| 24 | `updated_at` | `timestamp(0)` | NULL | — | |
| 25 | `deleted_at` | `timestamp(0)` | NULL | — | Soft delete |
| 26 | `justification` | `text` | NULL | — | Justificativa (Suspensa/Cancelada) — adicionada em 2026-05-21 |
| 27 | `description` | `text` | NULL | — | Descrição da obra — adicionada em 2026-05-27 |
| 28 | `estimated_completion_date` | `date` | NULL | — | Previsão de conclusão — adicionada em 2026-05-27 |
| 29 | `completion_percentage` | `integer` | NULL | — | Percentual concluído — adicionada em 2026-05-27 |

**Índices:** apenas `works_pkey`. **Não há índice** em `work_category_id`,
`status_id`, `start_date`, `contract` ou `edital` — os filtros do portal fazem
`WHERE` nessas colunas.

**Chaves estrangeiras:**

| Constraint | Coluna | Referência | ON DELETE |
|---|---|---|---|
| `works_status_id_foreign` | `status_id` | `work_statuses(id)` | `SET NULL` |
| `works_work_category_id_foreign` | `work_category_id` | `work_categories(id)` | `SET NULL` |

**Referenciada por:** `work_archives_work_id_foreign` (`work_archives.work_id`
ON DELETE CASCADE).

**Volume atual na base:** `SELECT count(*) FROM works` → **0 linhas**.

## 3. Tabela `work_categories`

| Coluna | Tipo | Null | Default |
|---|---|---|---|
| `id` | `integer` | NOT NULL | `nextval('work_categories_id_seq')` |
| `name` | `varchar(191)` | NOT NULL | — |
| `deleted_at` | `timestamp(0)` | NULL | — |
| `created_at` | `timestamp(0)` | NULL | — |
| `updated_at` | `timestamp(0)` | NULL | — |

PK `work_categories_pkey`. Referenciada por
`works_work_category_id_foreign` (SET NULL). Volume: **1 linha** —
`Infraestrutura` (id 1), criada pela migration de setup de recursos.

## 4. Tabela `work_statuses`

| Coluna | Tipo | Null | Default |
|---|---|---|---|
| `id` | `integer` | NOT NULL | `nextval('work_statuses_id_seq')` |
| `name` | `varchar(191)` | NOT NULL | — |
| `deleted_at` | `timestamp(0)` | NULL | — |
| `created_at` | `timestamp(0)` | NULL | — |
| `updated_at` | `timestamp(0)` | NULL | — |

PK `work_statuses_pkey`. Referenciada por `works_status_id_foreign` (SET NULL).
Volume: **5 linhas**, todas criadas pela migration de setup de recursos:

| id | name |
|---|---|
| 1 | Aberta |
| 2 | Em Andamento |
| 3 | Finalizada |
| 4 | Cancelada |
| 5 | Suspensa |

> Observação: o `show.blade.php` do portal compara o nome da situação com
> **minúsculas** (`'aberta'`, `'em andamento'`), enquanto a base grava
> `Aberta`, `Em Andamento` — a comparação **nunca** casa, e todas as obras caem
> no estilo `text-secondary`.

## 5. Tabela `work_archives` (pivot)

| Coluna | Tipo | Null | Default |
|---|---|---|---|
| `id` | `integer` | NOT NULL | `nextval('work_archives_id_seq')` |
| `work_id` | `bigint` | NOT NULL | — |
| `archive_id` | `bigint` | NOT NULL | — |
| `type` | `varchar(191)` | NOT NULL | — |
| `deleted_at` | `timestamp(0)` | NULL | — |
| `created_at` | `timestamp(0)` | NULL | — |
| `updated_at` | `timestamp(0)` | NULL | — |

PK `work_archives_pkey`. Volume atual: **0 linhas**.

**Chaves estrangeiras:**

| Constraint | Coluna | Referência | ON DELETE |
|---|---|---|---|
| `work_archives_work_id_foreign` | `work_id` | `works(id)` | `CASCADE` |
| `work_archives_archive_id_foreign` | `archive_id` | `archives(id)` | `CASCADE` |

**A coluna `type` distingue a natureza do vínculo** — é o que separa "arquivo
para download" de "foto da galeria":

| Valor | Constante | Origem | Onde é lido |
|---|---|---|---|
| `file` | `ArchiveTypesEnum::FILE` | `WorkController::storeArchives()` | `WorkController@edit` (lista de arquivos enviados) e `v5/resources/views/portal/components/obras/arquivos.blade.php` |
| `gallery` | `ArchiveTypesEnum::GALLERY` | `WorkController::saveArchivesFromGallery()` | `WorkController@getArchivesFromGallery` e `v5/resources/views/portal/components/galerias/index.blade.php` |

> Observação: **não há CHECK constraint nem índice** em `work_archives.type`.
> Um valor inválido gravado manualmente quebraria silenciosamente os dois
> consumidores do pivot.

## 6. Tabela `archives` (compartilhada com todo o sistema)

A tabela `archives` é **global** — é usada por conteúdos, licitações,
destaques, páginas estáticas, agenda, requerimentos de diárias e agora também
pelas obras. As colunas relevantes para o módulo:

| Coluna | Tipo | Papel no módulo Obras |
|---|---|---|
| `id` | `integer` PK | alvo do pivot `work_archives.archive_id` |
| `title` | `varchar(300)` | preenchido com o nome do arquivo no upload do CMS (`storeArchives`); NULL nas imagens da galeria |
| `description` | `text` | descrição digitada pelo operador junto do arquivo |
| `type` | `varchar(191)` | **MIME type** do arquivo (`$file->getMimeType()`) — não confundir com `work_archives.type` |
| `hash` | `varchar(300)` | `md5_file()` — preenchido **apenas** no upload da galeria |
| `path` | `varchar(300)` | caminho público do arquivo |
| `name` | `varchar(300)` | nome gerado (`uniqid` + 5 aleatórios + extensão) |
| `extension` | `varchar(191)` | extensão sem ponto |
| `view_pdf` | `boolean` | controlado pelo toggle "Ativar exibição na página" (`archive[][view]`) |
| `highlight` | `boolean` | filtro usado por `getArchivesFromGallery` (`whereNull('highlight')`) |
| `category_id` | `bigint` | FK → `archive_categories(id)` (SET NULL) — não usada pelas obras |
| `deleted_at` | `timestamp(0)` | soft delete; `Archive` usa `SoftDeletes` |

> **Acoplamento crítico:** `work_archives_archive_id_foreign` é `ON DELETE
> CASCADE`. Excluir fisicamente um registro de `archives` remove o vínculo
> `work_archives` — e, por consequência, some da listagem de arquivos da obra.

## 7. Relations Eloquent

| Model | Relação | Tipo | Coluna |
|---|---|---|---|
| `Work` | `workCategory()` | `belongsTo(WorkCategory::class)` | `work_category_id` |
| `Work` | `status()` | `belongsTo(WorkStatus::class)` | `status_id` |
| `Work` | `archives()` | `belongsToMany(Archive::class, 'work_archives')` | pivot `work_archives` |
| `WorkCategory` | `works()` | `hasMany(Work::class)` | `work_category_id` |
| `Archive` | `works()` | `hasMany(WorkArchive::class, 'archive_id', 'id')` | `work_archives.archive_id` |

> **Assimetria importante:** `Work::archives()` é `belongsToMany` **sem**
> `withPivot('type')` e **sem** filtro por tipo. Logo, `$work->archives` devolve
> **arquivos e imagens da galeria misturados**. O `show.blade.php` do portal
> include `v5/resources/views/portal/components/obras/arquivos.blade.php` (que itera
> `$work->archives`) e, em seguida, `v5/resources/views/portal/components/galerias/index.blade.php`
> (que filtra por extensão de imagem) — as imagens aparecem **duas vezes** na
> ficha pública.
>
> Já o `WorkController@edit` **filtra** corretamente por tipo:
> `whereHas('works', fn($q) => $q->where('work_id', $id)->where('type', ArchiveTypesEnum::FILE))`.

### Casts e `$fillable` do model `Work`

```php
protected $fillable = [
    'title', 'edital', 'contract', 'work_category_id', 'status_id',
    'value', 'value_paid', 'start_date', 'end_date', 'cep', 'state',
    'city', 'neighborhood', 'street', 'house_number', 'complement',
    'latitude', 'longitude', 'company_holder', 'holder', 'gallery',
    'justification', 'description', 'estimated_completion_date',
    'completion_percentage'
];

protected $casts = [
    'start_date' => 'date',
    'end_date' => 'date',
    'estimated_completion_date' => 'date',
    'gallery' => 'boolean',
];
```

> `value`, `value_paid`, `latitude` e `longitude` **não têm cast** — são
> strings. O portal formata o dinheiro com
> `number_format((float) str_replace(['.', ','], ['', '.'], $work->value), 2, ',', '.')`,
> o que **quebra** se o usuário digitar "1.234,56" com separador de milhar
> diferente do padrão brasileiro.

## 8. Migrations

Todas em `v5/database/migrations/`:

| Arquivo | Data | O que faz |
|---|---|---|
| `2025_04_01_014508_create_work_statuses_table.php` | 2025-04-01 | Cria `work_statuses` (id, name, softDeletes, timestamps). |
| `2025_04_01_014942_create_work_categories_table.php` | 2025-04-01 | Cria `work_categories` (id, name, softDeletes, timestamps). |
| `2025_04_01_014952_create_works_table.php` | 2025-04-01 | Cria `works` com 22 colunas + `gallery` boolean default false + softDeletes + as duas FKs (SET NULL). |
| `2025_04_01_021111_create_work_archives_table.php` | 2025-04-01 | Cria `work_archives` (work_id, archive_id, type, softDeletes, timestamps) com as duas FKs (CASCADE). |
| `2025_04_01_022047_set_up_works_resources.php` | 2025-04-01 | **Seed dentro de migration**: cria `menu_orders.slug='obras'`, `resources.slug='obras'` (nome "Obras", descrição "Formulários de Obras"), 5 `work_statuses` e 1 `work_category` ("Infraestrutura"). O `down()` apaga tudo e faz `forceDelete` nas situações/categorias. |
| `2025_04_01_025035_create_listagem_obras_view.php` | 2025-04-01 | `DROP VIEW IF EXISTS` + `CREATE OR REPLACE VIEW listagem_obras`. |
| `2025_04_01_045658_create_listagem_categorias_obras_view.php` | 2025-04-01 | `CREATE OR REPLACE VIEW listagem_categorias_obras` (sem `DROP` prévio — depende do `CREATE OR REPLACE`). |
| `2026_05_21_000000_add_justification_to_works_table.php` | 2026-05-21 | `Schema::table('works')` → `text('justification')->nullable()->after('gallery')`. |
| `2026_05_27_140000_add_extra_fields_to_works_table.php` | 2026-05-27 | `Schema::table('works')` → `description` (text, null), `estimated_completion_date` (date, null), `completion_percentage` (integer, null). |

**Sequência do setup (migration `2025_04_01_022047`):**

```php
MenuOrder::query()->updateOrCreate(['slug' => 'obras'], ['clicks' => 0]);

Resource::query()->updateOrCreate(['slug' => 'obras'], [
    'name' => 'Obras',
    'description' => 'Formulários de Obras',
]);

$statuses = ['Aberta', 'Em Andamento', 'Finalizada', 'Cancelada', 'Suspensa'];

foreach ($statuses as $status) {
    WorkStatus::query()->updateOrCreate(['name' => $status]);
}

WorkCategory::query()->updateOrCreate(['name' => 'Infraestrutura']);
```

> **Fragilidade:** a migration usa **models** (`WorkStatus`, `WorkCategory`,
> `Resource`, `MenuOrder`) para popular dados. Se um model mudar de namespace ou
> de nome, a migration quebra; se rodar em um banco já populado, o
> `updateOrCreate` é idempotente, mas o `down()` executa `forceDelete` nas
> situações/categorias — operações destrutivas em rollback.

## 9. Seeds

**Não há seeder dedicado** ao módulo (nenhum arquivo em `v5/database/seeds/`
referencia `Work`, `WorkCategory` ou `WorkStatus`). Todo o seed inicial está
embutido na migration `2025_04_01_022047_set_up_works_resources.php`.

---

> **Continua em `09-VIEWS-SQL-E-PERMISSOES.md`:** as **views SQL**
> `listagem_obras` e `listagem_categorias_obras` (o que elas abrem e seus
> defeitos) e as **permissões derivadas** do resource `obras` (id 13), incluindo
> a matriz de verificação por rota.

