# Obras — Regras de Negócio e Portabilidade

> Versão analisada: `v5`.

---

---

> **Continua em `11-REGRAS-DE-NEGOCIO.md`:** as **regras de negócio não óbvias**
> (justificativa condicional, redirecionamento para a aba Galeria, bloqueio de
> exclusão de categoria com obras vinculadas, normalização de datas, semântica do
> toggle de exibição, coluna "ID" da listagem, valor monetário como texto e o
> comportamento de `gallery` no payload).

## 1. Acoplamentos

### 2.1 Tabela `archives` (acoplamento crítico)

O módulo **não tem tabela própria de arquivos**: usa a tabela global
`archives` e o pivot `work_archives`. Consequências:

| Ponto | Detalhe |
|---|---|
| Exclusão em cascata | `work_archives_archive_id_foreign` é `ON DELETE CASCADE`: excluir fisicamente um `archives` remove o vínculo da obra silenciosamente. |
| Soft delete inconsistente | `Archive` usa `SoftDeletes`, mas `work_archives` **também**. Ao fazer soft delete de um `archives`, o pivot continua ativo — a listagem de arquivos da obra continua mostrando o arquivo. |
| Coluna `type` com dupla semântica | Em `archives`, `type` = **MIME type** (`image/jpeg`); em `work_archives`, `type` = **papel** (`file`/`gallery`). Confusão fácil. |
| Exclusão de arquivo por rota alheia | O `deleteFile()` do JS de obras chama `POST /cms/galeria/delete/{id}`, que pertence ao `ArchiveController` (módulo de galeria genérica), **não** a uma rota de obras. |

### 2.2 Módulo de Obras Paralisadas

Compartilham o **mesmo** recurso de permissão (`resources.id = 13`, slug
`obras`) e o **mesmo** item de `menu_orders` (slug `obras`, que abre o
submenu "Obras" com as duas entradas). Não compartilham nenhuma tabela.

Consequência prática: **uma permissão `obras.delete` libera a exclusão de
obras paralisadas**, e vice-versa.

### 2.3 `DatatableController` compartilhado

`get_works_list()` e `get_works_categories_list()` usam o
`struct_views($data, 'obras')` — o mesmo helper de todos os módulos. Ele
injeta `edit`, `delete`, `active` e `inactivate` **no JSON**, e o JS decide o
que renderizar. Qualquer mudança nesse helper afeta o módulo.

### 2.4 `Configuration` (três chaves)

| Chave | Uso |
|---|---|
| `layout` | Escolhe o template do portal (atualmente `modelo1`). |
| `estado` | UF usada para centralizar o mapa e montar a URL do IBGE (atualmente `PB`). Fallback `'PB'` no controller. |
| `geocoding_api_key` | Chave da API do Google Geocoding. **Está vazia na base** (`SELECT value FROM configurations WHERE key='geocoding_api_key'` → string vazia) → o geocoding **sempre falha** com `REQUEST_DENIED`/`InvalidRequest`. |

### 2.5 `MenuServiceProvider` e `menuClicks()`

O menu do CMS é montado dinamicamente a partir de `menu_orders`. O
`WorkController@index` chama `menuClicks('obras')`, que incrementa
`menu_orders.clicks` do slug `obras` — e, com `Configuration('menu_interativo')
= 1`, **a ordem do menu é por número de cliques**. Ou seja, o uso do módulo
muda a ordem dos itens no menu lateral.

### 2.6 `spatie/laravel-activitylog`

Toda ação do CMS grava um registro em `activity_log`
(`log_name`, `description`, `subject_type`, `subject_id`, `causer_id`,
`properties`, `ip_address`, `browser`). As descrições são **texto livre em
português** ("Listou obras", "Cadastrou uma obra", ...). Não há
`log_name` específico do módulo — todas as ações caem no log_name padrão.

---

## 2. Armadilhas técnicas

### 3.1 SQL montado por interpolação de string

`Portal\WorksController::getNonAccentedILikeSearch()`:

```php
public function getNonAccentedILikeSearch($search, $column)
{
    return "lower(TRANSLATE($column::varchar,'áéíóúàèìòùãõâêîôôäëïöüçÁÉÍÓÚÀÈÌÒÙÃÕÂÊÎÔÛÄËÏÖÜÇ','aeiouaeiouaoaeiooaeioucAEIOUAEIOUAOAEIOOAEIOUC')) like '%$search%'";
}
```

A variável `$search` é **interpolada direto no SQL**, sem binding e sem escape.
A única sanitização é `prepareStringToSearch()`, que converte entidades HTML
de volta para caracteres acentuados e aplica `mb_strtolower` — **não remove
aspas simples**. Um valor de busca com `'` quebra (ou injeta) a query.

O `$column` vem do código (`'contract'`, `'edital'`), então não é entrada do
usuário — o risco está em `$search`.

**Portabilidade:** `TRANSLATE` e o cast `::varchar` são **específicos do
PostgreSQL**. Em MySQL a busca quebraria.

### 3.2 `DB::raw()` em três lugares

| Onde | Uso |
|---|---|
| `WorkController@create` / `@edit` | `WorkCategory::query()->select(DB::raw("id, name"))->get()` |
| `Portal\WorksController@index` | idem para categorias e situações |
| `Portal\WorksController@index` | `orderBy(DB::raw('start_date IS NULL, start_date'), 'DESC')` |

O `orderBy` com `DB::raw` implementa "NULLs last" de forma **específica do
PostgreSQL** (`start_date IS NULL` como booleano ordena `false < true`).
Em MySQL a expressão é booleana numérica (`IS NULL` → 0/1) e o resultado
difere.

### 3.3 Upload de arquivo **fora** da transação

Em `store()` e `update()`:

```php
DB::beginTransaction();
try {
    $store = Work::query()->updateOrCreate(['id' => $id], $this->attributes($request));
    ...
    if ($request->has('archive')) {
        $this->storeArchives($request->archive, $store->id);
    }
    DB::commit();
```

`storeArchives()` faz `$file->storeAs(...)` — a escrita em disco **não** é
transacional. Se o `DB::commit()` falhar (ou se uma exceção ocorrer depois do
upload), os **arquivos ficam no storage** sem registro no banco (órfãos). O
mesmo vale para `saveArchivesFromGallery()`, que grava o arquivo antes de
criar o `archives`.

### 3.4 Caminhos de arquivo montados à mão

`storeArchives()`:

```php
$insertArchive->path = "storage/obras/$workId/arquivos/$name";
$file->storeAs("obras/$workId/arquivos/", $name);
```

O `path` salvo é **relativo ao `public/`** (`storage/...`), enquanto o
`storeAs` grava no disk `public` (raiz `storage/app/public`). O `url($path)`
usado nas views resolve para `/storage/obras/...` — funciona porque o Laravel
cria o symlink `public/storage`. É uma convenção frágil: qualquer mudança no
disk padrão quebra todos os links de download.

Já a galeria usa `FileTrait::saveUpload($request->file, '/works', ...)` — o
diretório é `/works` (raiz do disk public), **não** `obras/{id}/...`. As duas
sequências de arquivos ficam em pastas diferentes.

### 3.5 `FileTrait::saveUpload()` e compressão

`saveUpload()` chama `compress()` quando `$compress = true` (default). O
`compress()` usa `getimagesize()` e **ignora** arquivos não-imagem
(`if (!$imgInfo || !in_array($imgInfo[2], [1, 2, 3])) return;`). Para imagens,
gera as variantes `_min` (200px), `_med` (380px) e `_larg` (1480px) ao lado do
original, e as views do portal usam `getMinFile()`/`getLargFile()` para
apontar para elas.

Risco: o `compress()` reescreve o arquivo original com `Image::make(...)->save(...)`
— em caso de erro, o original pode ficar corrompido, sem backup.

### 3.6 `Route::currentRouteName()` dentro do controller

```php
$result = $result->orderBy(DB::raw('start_date IS NULL, start_date'), 'DESC')->paginate(10);
if (Route::currentRouteName() == 'portal-obras.exportacao') {
    return $this->export($result, $request->type);
}
```

O controller do portal **depende do nome da rota** para decidir o que
responder. Duas rotas (`obras` e `exportacao-obras`) apontam para o mesmo
método. Renomear a rota quebra a exportação silenciosamente.

### 3.7 Helpers globais fora de classe

| Helper | Onde | Origem |
|---|---|---|
| `flash()` | todos os controllers | `laracasts/flash` |
| `activity()` | todos os controllers | `spatie/laravel-activitylog` (helper global) |
| `menuClicks('obras')` | `WorkController@index` | `v5/app/Helpers/functions.php:822` |
| `has_access([...])` | views e `DatatableController` | `v5/app/Library/Helper/appHelpers.php:22` |
| `getMinFile()` / `getLargFile()` | views do portal | `v5/app/Helpers/functions.php:909`/`:930` |

`menuClicks()` faz `$menuOrder->clicks++` e `$menuOrder->update()` — **uma
query a mais por visita** ao index, e uma escrita no banco a cada abertura da
tela.

### 3.8 N+1 no portal e na exportação

| Trecho | Problema |
|---|---|
| `indexShow()` | `Work::with(['workCategory','status','archives'])` — eager loading correto. |
| `tabela.blade.php` | `data_get($res, 'status.name')` e `data_get($res, 'workCategory.name')` — **lazy loading** (N+1) por linha, porque o `index()` **não** usa `with()`. |
| `index()` → `$forMap` | `data_get($item, 'status.name')` e `'workCategory.name'` em cada item — N+1 sobre **todas** as obras com coordenadas. |
| `exports/works.blade.php` | `$obra->workCategory->name` e `$obra->status->name` por linha exportada — N+1. |
| `gerarTxt()` | `$model->workCategory->name` e `$model->status->name` — N+1. |

### 3.9 Carregamento integral em memória no portal

```php
$forMap = (clone $result)->whereNotNull('latitude')
    ->whereNotNull('longitude')
    ->get()
    ->map(...);
```

O `clone` acontece **antes** do `paginate(10)`, então **todas** as obras com
coordenadas são carregadas em memória e convertidas em array a cada requisição
do portal — inclusive na listagem normal, onde o mapa só mostra as 10 obras
exibidas. Com dezenas de milhares de obras, isso é um problema de memória e de
tempo de resposta.

O mesmo array `$forMap` alimenta o mapa **e** o PDF — por isso o PDF só
contém obras georreferenciadas.

### 3.10 `Route::` e helpers dentro de views/controllers

- `Route::currentRouteName()` no controller do portal (3.6).
- `route('obras.edit', ['id' => ..., 'goto' => 'galeria'])` no `store()`.
- `has_access()` dentro do Blade (`prefecture/works/index.blade.php:10`).
- `Breadcrumbs::view('portal.layouts.modeloN.layout.breadcrumb', 'obras', 'Obras')`
  dentro das views do portal — as views conhecem o próprio template.

### 3.11 `DB::raw("id, name")` como `select()`

`select(DB::raw("id, name"))` funciona, mas é SQL cru dentro do Eloquent.
Em SQL Server exigiria `[]`; em MySQL funciona.

### 3.12 `updateOrCreate` com `id` no array de valores

`WorkController@update()` usa `Work::query()->updateOrCreate(['id' => $id],
$this->attributes($request))`. O `id` está no **primeiro** argumento (chave de
busca) — correto. Mas `attributes()` **não** inclui `id`, então o
`updateOrCreate` nunca corrige o `id` — o que é o comportamento desejado.

Já `WorkCategoryController@update()` faz
`updateOrCreate(['id' => $id], ['name' => ...])` — se o `id` não existir,
**cria** um registro novo com outro `id` (o `id` do array de busca não é
aplicado ao `create`). Comportamento silencioso e surpreendente.

### 3.13 `dd($e)` em produção

`WorkCategoryController@store()` tem `dd($e);` dentro do `catch` — em caso de
erro, a aplicação **dumpa a exceção** em vez de exibir a mensagem de erro. É
um resíduo de depuração.

### 3.14 `str_contains()` / `str_starts_with()` em PHP 7.2

`app/Library/Helper/appHelpers.php` e
`app/Http/Middleware/CheckRoutePermission.php` usam `str_contains()` e
`str_starts_with()`, funções do **PHP 8**. O runtime é **PHP 7.2.34**
(compatibilidade garantida pelo `symfony/polyfill-php80`, presente no
`composer.lock`), mas `str_contains` também é definida pelo próprio Laravel
5.5 (`Illuminate\Support\helpers.php:846`) e `str_starts_with` vem do polyfill
— a combinação funciona, mas é frágil: sem o polyfill, o módulo de obras
(inclusive o `resolve_permission_aliases`) quebraria em runtime.

### 3.15 `Route::group(['before' => 'csrf'])`

`v5/routes/basepro.php:515` (e em vários outros grupos) usa
`'before' => 'csrf'`. O Laravel 5.5 **não** interpreta `before` como alias de
middleware — é uma chave de grupo sem efeito sobre o middleware. A proteção
CSRF real vem do grupo `web` (`VerifyCsrfToken`) aplicado em
`RouteServiceProvider::mapBaseproRoutes()`. O atributo é enganoso.

---

## 3. Código duplicado / copiado

| Bloco | Onde | Observação |
|---|---|---|
| `showButton()` / `setArchive()` | `public/assets/js/custom/contents/obras/script.js` **e** `public/assets/js/custom/utilities/helpers/add_archive.js` | Cópias divergentes: o de obras usa `name="archive[N][file]"`, o helper genérico usa `name="archive[][]"`. O helper **não** é carregado pelas views de obras. |
| Handler do `#btn-adicionar` | idem | idem |
| `exportar()` / `toCleanFields()` | `v5/resources/views/portal/components/obras/filtro.blade.php` **e** `v5/resources/views/portal/layouts/modelo3/obras/filtro.blade.php` (e modelo4, modelo5, modelo6) | Seis cópias da mesma função, com pequenas diferenças de CSS. |
| Bloco de tabela | `v5/resources/views/portal/components/obras/tabela.blade.php` | Compartilhado por todos os modelos — **não** duplicado. |
| Estrutura de `show.blade.php` | `v5/resources/views/portal/layouts/modelo1/obras/show.blade.php` (e modelo2..modelo6) | Seis cópias **quase idênticas** (9833 bytes em modelo1/modelo2/modelo5/modelo6; 9886 em modelo3; 9703 em modelo4). |
| `getNonAccentedILikeSearch()` | `Portal\WorksController` | Copiada de controllers de outros módulos (licitações etc.) — mesma implementação e mesmos defeitos. |
| `struct_views()` | `DatatableController` | Compartilhado com todos os módulos. |

---

## 4. Onde a v6 divergiu da v5

A v6 **removeu completamente** o módulo. Não há divergência de comportamento
a documentar — há **ausência**:

| Item | v5 | v6 |
|---|---|---|
| Models `Work`, `WorkCategory`, `WorkStatus`, `WorkArchive` | presentes | **ausentes** |
| Controllers `WorkController`, `WorkCategoryController`, `Portal\WorksController` | presentes | **ausentes** |
| Requests `Works\SaveRequest`, `Works\DeleteRequest`, `WorkCategories\*` | presentes | **ausentes** |
| Enum `ArchiveTypesEnum` | presente | **ausente** |
| Service `GeocodingService` | presente | **ausente** |
| Export `WorksExport` + `resources/views/exports/works.blade.php` | presentes | **ausentes** |
| Views CMS `prefecture/works/**` | 7 arquivos | **ausentes** |
| Views portal `layouts/modelo1/obras/` … `layouts/modelo6/obras/` | 17 arquivos | **ausentes** |
| Views SQL `listagem_obras`, `listagem_categorias_obras` | presentes | **ausentes** |
| Rotas `obras.*`, `galeria-obras.*`, `categorias-obras.*` (CMS) e `portal-obras.*` (portal) | presentes | **ausentes** |
| JS `datatable.js`, `categorias-datatable.js`, `script.js`, `gerar-pdf-obras.js` | presentes | **ausentes** |
| Resource `resources.slug = 'obras'` (id 13) | presente | **ausente** |
| `menu_orders.slug = 'obras'` | presente | **ausente** |
| Tabelas `works`, `work_categories`, `work_statuses`, `work_archives` | presentes | **ausentes** |

Detalhamento completo em `08-GAPS-E-DIVIDAS.md`.

---

## 5. Checklist de equivalência funcional (o que NÃO pode mudar na reconstrução)

1. A **justificativa** deve passar a ser obrigatória **no servidor** quando a
   situação for Suspensa ou Cancelada.
2. O redirecionamento para a aba "Galeria" após ativar a galeria deve ser
   preservado.
3. A **categoria com obras vinculadas** deve continuar bloqueando a exclusão.
4. A listagem do CMS deve manter o **filtro por soft delete** e a
   visualização de "Valor Pago" e "Percentual Concluído" na ficha pública.
5. A exportação deve passar a exportar **o conjunto filtrado inteiro**, não
   apenas a página corrente.
6. A **ficha pública** deve continuar exibindo a galeria e os arquivos para
   download da obra, e o download deve continuar funcionando (o `path`
   relativo ao `public/` é a convenção existente).
7. A exclusão da obra deve continuar sendo **lógica**, e os arquivos
   compartilhados **não** devem ser apagados.
8. O mapa deve continuar exibindo **apenas** as obras com coordenadas, e o PDF
   deve passar a incluir **todas** as obras filtradas (com a coluna "Valor
   Pago" preenchida e o nome de arquivo corrigido).
9. A busca por contrato/edital deve continuar **insensível a acentos**, mas
   implementada com binding (e sem depender de `TRANSLATE` do PostgreSQL).
