# Obras Paralisadas — Modelagem

> Módulo **existente apenas na v5**. Estrutura de dados verificada no banco
> `prefeituras_v5` (container `v5_3f273858-postgres-1`) e nos arquivos de
> migration do `v5/`.

---

## 1. Tabela `obras_paralisadas`

É a **única** tabela do módulo. Não há tabelas de apoio, pivôs, views SQL nem
tabela de anexos própria.

```sql
-- saída real de: docker exec v5_3f273858-postgres-1 psql -U postgres -d prefeituras_v5 -c "\d obras_paralisadas"
  Column       |              Type              | Nullable |                    Default
---------------+--------------------------------+-----------+-----------------------------------------------
 id            | integer                        | not null | nextval('obras_paralisadas_id_seq'::regclass)
 identificacao | character varying(255)         | not null |
 motivo_paralisacao | text                     | not null |
 responsavel   | character varying(255)         | not null |
 previsao_reinicio | character varying(255)      | not null |
 arquivo       | text                           |          |
 slug          | character varying(191)         |          |
 created_at    | timestamp(0) without time zone |          |
 updated_at    | timestamp(0) without time zone |          |
 deleted_at    | timestamp(0) without time zone |          |
Indexes:
    "obras_paralisadas_pkey" PRIMARY KEY, btree (id)
```

### 1.1 Detalhamento coluna a coluna

| Coluna | Tipo SQL | Null | Origem no PHP | Observações |
|---|---|---|---|---|
| `id` | `integer` (serial) | não | auto-incremento | `increments('id')` na migration. |
| `identificacao` | `varchar(255)` | não | `$fillable` | Texto livre; é a "chave de negócio" exibida ao cidadão. Sem índice e sem unicidade. |
| `motivo_paralisacao` | `text` | não | `$fillable` | Justificativa do travamento. |
| `responsavel` | `varchar(255)` | não | `$fillable` | Engenheiro, construtora ou secretaria (texto livre). |
| `previsao_reinicio` | `varchar(255)` | não | `$fillable` | No CMS o input é `type="date"`, mas a coluna é `varchar` — a data é gravada como string `YYYY-MM-DD`, sem constraint e sem timezone. |
| `arquivo` | `text` | sim | `$fillable` | **JSON** com a lista de anexos: `[{"path":"docs/68f0c1a9b2d1e4.pdf","description":"Parecer tecnico"}]`. Sem FK, sem índice, sem normalização. |
| `slug` | `varchar(191)` | sim | `$fillable` | Gerado pelo model. `varchar(191)` vem de `Schema::defaultStringLength(191)` em `v5/app/Providers/AppServiceProvider.php:24`. **Sem índice único** — a unicidade vem do `id` embutido no slug. |
| `created_at` | `timestamp(0)` | sim | Eloquent | |
| `updated_at` | `timestamp(0)` | sim | Eloquent | |
| `deleted_at` | `timestamp(0)` | sim | `SoftDeletes` | Soft delete: a exclusão no CMS preenche esta coluna. |

### 1.2 Volume e integridade

| Verificação | Resultado |
|---|---|
| `SELECT count(*) FROM obras_paralisadas;` | **0** |
| `SELECT count(*) FROM obras_paralisadas WHERE deleted_at IS NULL;` | **0** |
| Índices além da PK | nenhum |
| Triggers / check constraints | nenhum |
| Chaves estrangeiras | nenhuma |

A base de dados da v5 **não contém nenhum registro** do módulo. O código está
completo e publicado, mas o recurso nunca foi alimentado nesse ambiente.

## 2. Migration

**Arquivo:** `v5/database/migrations/2026_05_26_000003_create_obras_paralisadas_table.php`
**Classe:** `CreateObrasParalisadasTable` (autoload por classmap).

```php
public function up()
{
    if (!Schema::hasTable('obras_paralisadas')) {
        Schema::create('obras_paralisadas', function (Blueprint $table) {
            $table->increments('id');
            $table->string('identificacao', 255);
            $table->text('motivo_paralisacao');
            $table->string('responsavel', 255);
            $table->string('previsao_reinicio', 255);
            $table->text('arquivo')->nullable();
            $table->string('slug')->nullable();
            $table->timestamps();
            $table->softDeletes();
        });
    }
}

public function down()
{
    Schema::dropIfExists('obras_paralisadas');
}
```

Pontos de atenção:

- O `if (!Schema::hasTable(...))` torna a migration **idempotente**, mas
  mascara falha de criação silenciosamente: se a tabela já existir com outro
  formato, a migration "passa" sem alterar nada.
- `$table->string('slug')` sem comprimento explícito usa o default 191
  (`Schema::defaultStringLength(191)`), por isso `varchar(191)` no banco.
- Não há `down()` seguro: apaga a tabela e todos os registros.
- A data no nome do arquivo (`2026_05_26`) é posterior às migrations do
  projeto original — a migration foi acrescentada depois do deploy da v5.

**Seeds:** nenhum arquivo em `v5/database/seeds/` referencia `obras_paralisadas`
(nenhum `ObraParalisada` no codebase fora dos arquivos já listados).

## 3. Model `ObraParalisada`

**Arquivo:** `v5/app/Http/Model/Prefecture/ObraParalisada.php`
**Namespace:** `App\Http\Model\Prefecture` (autoload por classmap).

```php
class ObraParalisada extends Model
{
    use SoftDeletes;

    protected $table = 'obras_paralisadas';

    protected $fillable = [
        'identificacao',
        'motivo_paralisacao',
        'responsavel',
        'previsao_reinicio',
        'arquivo',
        'slug',
    ];

    protected $dates = ['deleted_at'];

    protected static function boot()
    {
        parent::boot();

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

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

| Aspecto | Comportamento |
|---|---|
| Soft delete | `use SoftDeletes` → `deleted_at` preenchido no `delete()`; escopo global filtra os registros apagados em **todas** as consultas (portal e CMS). |
| Slug | `str_slug($identificacao . '-' . $id)` (helper global do Laravel 5.5 → `Str::slug` com separador `-` e transliteração de acentos via `Str::ascii`). |
| Momento da geração | No evento `created` (com um `save()` extra, gerando um segundo UPDATE) e no evento `updating` (apenas em memória, persistido pelo UPDATE do chamador). |
| Efeito colateral | Renomear `identificacao` **muda o slug** e invalida a URL pública já diffusionada. |
| Casts | Nenhum cast declarado. `arquivo` é `text` e por isso **não** é convertido em array: o model devolve a string JSON crua, e o código faz `json_decode` manual nas views e no controller. |
| `$dates` | `['deleted_at']` → serialização como data ISO. |
| Relações | **Nenhuma** relação Eloquent declarada. |
| `$hidden` / `$appends` | Nenhum: o model expõe todos os 11 campos na serialização JSON, o que expõe `arquivo` (caminho interno do storage) em qualquer serialização. |

### 3.1 Relations com `archives` — inexistentes

Este ponto merece registro porque a nomenclatura da plataforma sugere o
contrário:

| Afirmação | Verificação |
|---|---|
| "O módulo reutiliza a tabela `archives` para anexos" | **Falso.** `grep -rn "ObraParalisada\|obras_paralisadas" v5/app v5/routes v5/database` não retorna nenhuma ocorrência em `v5/app/Http/Model/Prefecture/Archive.php`, `v5/app/Http/Controllers/Prefecture/ArchiveController.php` nem em qualquer código que use `archives`. |
| Existe coluna de FK para obras paralisadas em `archives` | **Falso.** O `\d archives` mostra apenas `contents_id`, `static_pages_id`, `biddings_id`, `calendar_events_id`, `highlight_id`, `requerimento_id`, `category_id`, `type_size_id`, `reference_id`. |
| Existe pivot para anexos de obra paralisada | **Falso.** O único pivot de `archives` ligado a obras é `work_archives` (FK `work_id` → `works.id`), que pertence ao módulo "Obras". |
| `ArchiveController@defaultOrder` / `putOrder` são usados | **Falso.** Esses métodos (`v5/app/Http/Controllers/Prefecture/ArchiveController.php:51` e `:69`) ordenam arquivos da tabela `archives` e **não** são chamados por este módulo. |
| `archives` contém dados deste módulo | Não aplicável: `archives` tem 91 linhas, todas de outros módulos (conteúdos, licitações, diários, requerimentos). |

**Consequência prática:** os anexos deste módulo são filas em uma coluna `text`
com JSON — não há integridade referencial, ordenação, thumbnail, categoria,
tamanho de arquivo, hash nem "destaque" como existe em `archives`.

## 4. Formato do JSON de `arquivo`

Gravado pelo `ObraParalisadaController@store` e `@update`:

```php
$items[] = ['path' => "docs/{$path}", 'description' => $descs[$i] ?? ''];
```

| Chave | Tipo | Conteúdo |
|---|---|---|
| `path` | string | Caminho relativo dentro do disk `public`, prefixado com `docs/`. Ex.: `docs/5f8c1a2b3x4.pdf`. |
| `description` | string | Texto digitado pelo operador no campo paralelo `arquivo_desc[]`. Pode ser vazio. |

Exemplo de valor stored:

```json
[
  {"path":"docs/68f0c1a9b2d1e4.pdf","description":"Parecer técnico da-paralisação"},
  {"path":"docs/68f0c2b7c4e5f6.pdf","description":""}
]
```

O leitor do portal e o do CMS aplicam a mesma normalização defensiva antes de
iterar (`v5/app/Http/Controllers/Portal/ObrasParalisadasController.php` e
`v5/resources/views/prefecture/obras-paralisadas/form.blade.php`):

```php
$arquivos = json_decode($obra->arquivo, true) ?: [$obra->arquivo];
$arquivos = array_map(function($i) { return is_array($i) ? $i : ['path' => $i, 'description' => '']; }, $arquivos);
```

Ou seja, o código aceita tanto o formato novo (array de objetos) quanto o
formato legado (string simples com o caminho), o que sugere que houve uma
transição de formato sem migração de dados.

## 5. Relacionamentos (resumo)

| Relação | Existe? | Detalhe |
|---|---|---|
| `obras_paralisadas` → `archives` | **Não** | Anexos em JSON na própria linha. |
| `obras_paralisadas` → `works` | **Não** | Nenhum identificador de obra do módulo "Obras". |
| `obras_paralisadas` → `resources` | **Não** | A permissão é resolvida por *alias* de string, não por FK. |
| `obras_paralisadas` → `users` | **Não** | Sem autor nem responsável por usuário do CMS; `responsavel` é texto livre. |
| Auto-relação / polimorfismo | **Não** | — |

## 6. Consultas SQL geradas pelo módulo

| Contexto | Consulta |
|---|---|
| Listagem do portal | `select * from "obras_paralisadas" where "obras_paralisadas"."deleted_at" is null order by "identificacao" asc limit 15 offset 0` (paginate) e a mesma sem `limit` para `$allData`. |
| Busca do portal | filtro com `whereRaw("translate(lower(identificacao), ...) ilike ?")` **ou** `whereRaw("lower(motivo_paralisacao) ilike ?")`, agrupados em um `where(function ($inner) {...})`. |
| DataTable do CMS | `DB::table('obras_paralisadas')->whereNull('deleted_at')->get()` — carrega a tabela inteira e a paginação é **client-side** (DataTables JS). |
| Ficha pública | `select * from "obras_paralisadas" where "slug" = ? and "obras_paralisadas"."deleted_at" is null limit 1`. |

## 7. Pontos de modelagem a corrigir na v7

1. `arquivo` deve virar coluna `json` (ou tabela `obra_paralisada_archives`),
   com FK e ordem.
2. `slug` deve ter **índice único** e não deve ser recalculado quando o
   identificador muda (ou deve manter um slug histórico para URLs antigas).
3. `previsao_reinicio` deve ser `date` (o formulário já produz `YYYY-MM-DD`).
4. `identificacao` merece um índice para a ordenação e para a busca.
5. Falta a coluna de autoria (`created_by`) e de data efetiva da paralisação.
6. Falta `updated_by` / trilha de auditoria: o módulo **não** chama
   `activity()`, ao contrário de `Admin\RoleController` e
   `Admin\PermissionController`, que registram em `activity_logs`.
