# Obras Paralisadas — Regras e Portabilidade

> Módulo **existente apenas na v5**.

---

## 1. Regras de negócio observáveis

| # | Regra | Onde está implementada |
|---|---|---|
| R1 | Os quatro campos de texto são **obrigatórios**; os anexos são opcionais. | `v5/app/Http/Requests/ObraParalisadaRequest.php` |
| R2 | Anexos devem ser **PDF** com no máximo **10 MB** cada. | regra `file\|mimes:pdf\|max:10240` |
| R3 | Anexos são **múltiplos** e cada um pode ter uma descrição de até 255 caracteres. | campos `arquivo[]` e `arquivo_desc[]` |
| R4 | A exclusão do registro é **lógica**; o registro some do CMS e do portal mas permanece no banco. | `SoftDeletes` no model |
| R5 | O anexo pode ser removido **individualmente**, por posição. | `destroyArquivo($id, $index)` |
| R6 | Ao editar, os anexos **novos são somados** aos existentes; os existentes são preservados na ordem original. | `update()` do controller |
| R7 | A listagem pública é **ordenada alfabeticamente** por `identificacao`. | `buildQuery()` |
| R8 | A paginação pública é de **15 registros** por página. | `$query->paginate(15)` |
| R9 | A busca cobre `identificacao` **ou** `motivo_paralisacao` (OR). | `buildQuery()` |
| R10 | A exportação usa **todos** os registros filtrados, sem paginação. | `$allData` |
| R11 | Somente `csv`, `xls` e `txt` são aceitos como `type`; qualquer outro valor devolve 404. | `exportar()` |
| R12 | O PDF é gerado no **navegador** e cobre todos os registros filtrados. | `gerar-pdf-obras-paralisadas.js` |
| R13 | O portal só mostra os botões de exportação quando há ao menos um registro. | `$result->isNotEmpty()` |
| R14 | O slug público é `str_slug(identificacao . '-' . id)` e é **recalculado** a cada edição. | hooks `created`/`updating` do model |
| R15 | O botão "Cadastrar" e os botões por linha dependem de permissões (`obra-paralisada.create/edit/delete`, resolvidas para `obras.*`). | `has_access()` + `struct_views()` |

## 2. Regras implícitas e efeitos colaterais

### 2.1 Slug dependente do identificador

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

- Editar "Obra da Praça Central" para "Obra da Praça Central - Fase 2" troca a
  URL pública de `/obra-paralisada/obra-da-praca-central-12` para
  `/obra-paralisada/obra-da-praca-central-fase-2-12`.
- Links já diffusionados (imprensa, redes sociais) passam a responder **404**,
  porque `show()` faz `where('slug', $slug)->firstOrFail()`.
- Como o `id` entra no slug, dois registros com a mesma `identificacao`
  coexistem sem colisão. Não há, porém, **índice único** em `slug`: a
  unicidade é apenas convenção do código.

### 2.2 `created` com `save()` dentro do evento

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

- Cada criação dispara **um INSERT + dois UPDATE** (o `save()` dentro de
  `created` dispara `updating`, que recalcula o mesmo slug, e persiste).
- O segundo `save()` também **reescreve `updated_at`**, então `updated_at` fica
  praticamente igual a `created_at` no momento da criação.
- Se `identificacao` for alterado para vazio após a criação, o slug passa a
  ser apenas `"-{id}"` → `str_slug` remove o hífen inicial e devolve
  `"12"`.

### 2.3 Normalização dupla da busca

```php
$inner->whereRaw("translate(lower(identificacao), ' ''àáâãäéèëêíìïîóòõöôúùüûçÇ', '--aaaaaeeeeiiiiooooouuuucc') ilike ?", ['%' . clean_slug($busca) . '%'])
      ->orWhereRaw("lower(motivo_paralisacao) ilike ?", ['%' . mb_strtolower($busca) . '%']);
```

- `clean_slug()` (`v5/app/Helpers/functions.php:780-806`) remove acentos e
  remove **todo caractere** que não seja `[A-Za-z0-9. ]` — inclusive o
  hífen. O termo buscado é, portanto, apenas letras, dígitos, ponto e
  espaço.
- O `translate(...)` converte, na coluna, espaço e apóstrofo em hífen e
  acentos em ASCII. O resultado é que **palavras compostas não são
  localizáveis**: "obras publicas" (com espaço) não casa com
  "obras-publicas" no banco, e "obras-publicas" (com hífen) perde o hífen no
  `clean_slug` e também não casa.
- Em `motivo_paralisacao` não há `translate`: apenas `lower()`. Termos com
  acento ("paralisação") **não** encontram "paralisacao".
- `mb_strtolower` é aplicado ao termo, mas o `lower()` do PostgreSQL é
  locale-dependent; com o cluster em `C` locale o `lower()` só trata ASCII.

### 2.4 Exclusão de anexo por índice

```php
array_splice($arquivos, $index, 1);
$obra->arquivo = !empty($arquivos) ? json_encode(array_values($arquivos)) : null;
```

- A posição é reindexada (`array_values`) após a remoção; os índices exibidos
  na tela são recalculados no próximo render.
- Se o índice informado não existir, devolve 404 com `{"erro":"Arquivo não encontrado."}`.
- **O arquivo físico não é apagado** do storage: o PDF continua acessível em
  `/storage/docs/<arquivo>` se alguém tiver a URL. Isso é um vazamento de
  arquivo e um acúmulo de espaço em disco.
- A mesma lacuna existe no `store` e no `update`: se o `insert`/`update`
  falhar depois do upload, o arquivo já gravado no storage fica órfão.

## 3. Armadilhas técnicas

| # | Armadilha | Onde | Impacto |
|---|---|---|---|
| A1 | `Route::any` na rota pública do índice | `v5/routes/web.php:211` | A mesma URI aceita POST/PUT/DELETE; o controller ignora o verbo. |
| A2 | `$allData = (clone $query)->get()` executado **sempre**, mesmo sem `type` | `Portal\ObrasParalisadasController@index` | Carrega a tabela inteira na memória a cada visita ao portal e injeta **todos** os registros no HTML (`json_encode($allData)`), mesmo com paginação de 15. |
| A3 | `json_encode($allData)` dentro do `<script>` | `index.blade.php` do portal | Exposição de todos os dados no HTML da página (inclusive colunas que podem ser sensíveis em outros cenários). |
| A4 | `whereRaw` com `translate` e `ilike` | `buildQuery()` | SQL montado à mão, dependente de collation/locale do PostgreSQL. |
| A5 | `clean_slug()` remove o hífen do termo buscado | `v5/app/Helpers/functions.php:780` | Busca por termos compostos falha. |
| A6 | `file_exist()` (helper customizado, não `file_exists`) com caminho **relativo** | `v5/app/Http/Traits/FileTrait.php:84-87` | O caminho `"storage/docs/..."` é relativo ao diretório de trabalho do PHP; no servidor web é `public/`, onde a pasta `storage` não existe → a verificação quase sempre retorna falso e a proteção contra nome repetido não atua. |
| A7 | `catch (\Exception $e)` seguido de `flash()` fora do `catch` | `store()` e `update()` do controller CMS | A mensagem original da exceção é **sobrescrita** pela mensagem genérica; o operador nunca vê a causa real. |
| A8 | `catch (\Exception $e)` não captura `Error` do PHP 7 | idem | Exceções fatais (TypeError, Error) escapam e viram HTTP 500 sem flash. |
| A9 | Sem `activity()` | controller CMS | Nenhuma entrada no log de atividades (`activity_logs`) para criação, edição ou exclusão — ao contrário de `Admin\RoleController` e `Admin\PermissionController`. |
| A10 | `authorize()` retorna `true` | `ObraParalisadaRequest` | Autorização depende 100% do middleware. |
| A11 | Permissão derivada do **nome da rota** e do **segmento da URL** | `v5/app/Http/Middleware/CheckRoutePermission.php:95-122` | Rotas com verbos/segmentos fora do padrão (`atualizar`, `excluir`) caem em permissões como `atualizar.index` que ninguém tem. |
| A12 | `DB::table('obras_paralisadas')->get()` no DataTable | `DatatableController@get_obras_paralisadas` | Sem paginação no servidor: todo o volume vai para o navegador. |
| A13 | `Form::model($obra, ...)` + inputs com `name` explícito | `edit.blade.php` | Funciona, mas mistura binding do model com nomes manuais; campos novos exigirão cuidado. |
| A14 | `Str::limit` e `json_decode` executados **na view** | `table.blade.php`, `show.blade.php`, `form.blade.php` | Lógica de domínio dentro do Blade, duplicada em três arquivos. |
| A15 | `$request->only([...])` **exclui** `arquivo` | `store()` / `update()` | O campo `arquivo` só é gravado quando há upload novo; em `update()`, se não houver upload, a coluna mantém o valor anterior (comportamento desejado, porém implícito). |
| A16 | Nenhum `cast` para `arquivo` | model | Todo consumidor precisa fazer `json_decode` manual. |
| A17 | Coluna `slug` nullable e sem índice | banco | Um registro com `identificacao` vazia pode ficar com slug vazio; `route('obras-paralisadas.show', '')` gera URL `/obra-paralisada`. |
| A18 | `Str::slug` com idioma `'en'` | helper global `str_slug` | Transliteração ASCII padrão do Laravel; caracteres fora do Latin-1 são removidos pelo `preg_replace` de `Str::ascii`. |
| A19 | `appends(request()->query())` na paginação | `table.blade.php` | O parâmetro `type` é preservado ao paginar e pode disparar download em vez de abrir a página. |
| A20 | O `title` passado para a view não é usado | `Portal\ObrasParalisadasController@index` | O texto "Obras Paralisadas" está hardcoded no Blade; a variável `$title` é código morto. |

## 4. Dependências entre módulos

### 4.1 Dependências de inbound (este módulo depende de)

| Dependência | Uso |
|---|---|
| Recurso `obras` (`resources.id = 13`) | Origem de todas as permissões do módulo. |
| Configuração `layout` (`configurations.key = 'layout'`) | Define qual template do portal é usado. |
| Storage `public` + symlink `public/storage` | Armazenamento e download dos PDFs. |
| `admin.layouts.app` (AdminLTE 1.22) | Layout do CMS. |
| `components/inputs/*` | Componentes de formulário do CMS. |
| `portal/components/obras-paralisadas/table.blade.php` | Componente de listagem do portal. |
| `routes/breadcrumbs.php:237-246` | Breadcrumbs `obras-paralisadas` e `obras-paralisadas.show`. |

### 4.2 Dependências de outbound (outros módulos dependem deste)

| Dependência | Uso |
|---|---|
| Menu "Obras" do AdminLTE | Item "Obras Paralisadas" apontando para `/cms/obras-paralisadas` (`v5/app/Providers/MenuServiceProvider.php:365`). |
| `resolve_permission_aliases()` | Traduz `obra-paralisada*` e `obras-paralisadas*` para `obras*`. Qualquer módulo que use esses prefixos herda a mesma permissão. |

**Este módulo não depende de nenhum outro módulo de negócio.** Ele não lê
`works`, `archives`, `resources` nem `configurations` além do layout. É o
módulo mais desacoplado do conjunto v5.

## 5. Código duplicado ou copiado

| Duplicação | Onde |
|---|---|
| As três views do portal repetidas 6 vezes (`modelo1` a `modelo6`) | `v5/resources/views/portal/layouts/modelo{1..6}/obras/paralisadas/` — conteúdo idêntico, diferindo apenas no `@extends` e no `@include` do filtro. |
| Normalização do JSON de `arquivo` em três lugares | `form.blade.php` (CMS), `show.blade.php` (portal) e `ObraParalisadaController@update` repetem o mesmo `array_map` de normalização. |
| Normalização do JSON de `arquivo` no portal | `ObrasParalisadasController@destroyArquivo` e o controller do portal usam a mesma estratégia defensiva `?: [$obra->arquivo]`. |
| `desabilitaBotao()` | Repetido em `create.blade.php` e `edit.blade.php`. |
| Exportadores csv/xls/txt | `Portal\ObrasParalisadasController` implementa os três do zero, enquanto outros módulos do portal (Concursos PSS, Estagiários, Fila de Espera SUS) repetem a mesma estrutura. |
| `table.js` (`runDatatables`, `createBTN`, `addDelete`) | Infraestrutura JS compartilhada do CMS, fora do diretório do módulo. |

## 6. Onde a v6 divergiu da v5

A v6 **removeu** o módulo por completo. Não é uma divergência de comportamento:
é ausência.

| Artefato | v5 | v6 |
|---|---|---|
| Model `ObraParalisada` | `v5/app/Http/Model/Prefecture/ObraParalisada.php` | **ausente** (`grep -rn "ObraParalisada" v6/` sem resultado) |
| Controller CMS | `v5/app/Http/Controllers/Prefecture/ObraParalisadaController.php` | **ausente** |
| Controller portal | `v5/app/Http/Controllers/Portal/ObrasParalisadasController.php` | **ausente** |
| Form Request | `v5/app/Http/Requests/ObraParalisadaRequest.php` | **ausente** |
| Migration | `v5/database/migrations/2026_05_26_000003_create_obras_paralisadas_table.php` | **ausente** |
| Rotas CMS | `v5/routes/basepro.php:516-523` (7 rotas) | **ausentes** |
| Rota do DataTable | `v5/routes/basepro.php:474` | **ausente** |
| Rotas do portal | `v5/routes/web.php:210-212` (2 rotas) | **ausentes** |
| Views CMS | `v5/resources/views/prefecture/obras-paralisadas/` (4 arquivos) | **ausentes** |
| Views do portal | 18 arquivos em `modelo1..modelo6` + componente | **ausentes** |
| JS do DataTable | `v5/public/assets/js/custom/config/obras-paralisadas/datatable.js` | **ausente** |
| JS do PDF | `v5/public/assets/js/modulos/jspdf/gerar-pdf-obras-paralisadas.js` | **ausente** |
| Breadcrumbs | `v5/routes/breadcrumbs.php:237-246` | **ausentes** |
| Menu do CMS | `v5/app/Providers/MenuServiceProvider.php:365` | **ausente** |
| Tabela `obras_paralisadas` | presente, 0 linhas | **ausente** |
| Recurso/permissão | usa `resources.id = 13` (`obras`) | o recurso `obras` também **não existe** na v6 |

Detalhamento da regressão em `08-GAPS-E-DIVIDAS.md`.

## 7. Riscos de portabilidade para a v7

| Risco | Mitigação sugerida |
|---|---|
| Coluna `text` com JSON de anexos | Migrar para tabela `obra_paralisada_archives` ou coluna `json` com accessor `arquivos`. |
| Exclusão por índice posicional | Trocar por identificador estável do anexo (`id` do arquivo). |
| Slug mutável | Gerar o slug uma vez e não alterá-lo; ou manter `slug` estável + `identificacao` livre. |
| Autorização por derivação de rota/segmento | `Policy` + `Gate::can('obra-paralisada.update', $obra)`. |
| `check.logged` ausente nas escritas | `Route::middleware(['auth'])` explícito em todo o grupo de rotas. |
| `raw` de busca com `translate` | Índice trigram (`pg_trgm`) ou coluna de busca gerada; em Laravel 11/12, `whereRaw` equivalente ou Scout. |
| Carregamento integral em memória | Endpoint JSON paginado para o PDF, ou exportação por streaming. |
| Ausência de trilha de auditoria | `created_by`/`updated_by` + evento de auditoria. |
| Coluna `previsao_reinicio` como `varchar` | `date` com accessor; na migração, converter o texto existente. |
