# Obras — Regras de Negócio (complemento)

> Complemento de `05-REGRAS-E-PORTABILIDADE.md` (v5). A seção "Regras de
> negócio não óbvias" foi movida para cá para manter todos os arquivos dentro
> do limite de 400 linhas.

---

## 1. Regras de negócio não óbvias

### 1.1 Justificativa condicional (regra do negócio central)

A justificativa da obra (`works.justification`) só é exigida quando a situação
é **Suspensa** ou **Cancelada**. A regra é implementada em **duas camadas
independentes**, e nenhuma delas é no servidor:

1. **Blade** (`prefecture/works/form.blade.php`) calcula
   `justificationStatusIds` a partir do **nome** da situação
   (`whereIn('name', ['Suspensa', 'Cancelada'])`) e decide se o textarea é
   renderizado com `display:none`.
2. **JavaScript** (`script.js::toggleJustification`) esconde/mostra o wrapper
   quando o select de situação muda, e **limpa** o textarea quando a situação
   deixa de ser Suspensa/Cancelada.

**Consequência:** um `POST`/`PUT` direto (ou com o JS desabilitado) grava obra
"Suspensa" **sem justificativa** — o `SaveRequest` tem
`'justification' => ['nullable', 'string']`. Ao reconstruir, esta regra **deve**
vir para o servidor (`Rule::requiredIf`).

### 1.2 Ativar a galeria redireciona para a aba

Ao **criar** uma obra com `gallery = 1`, o `store()` redireciona para
`obras.edit` com `['goto' => 'galeria']`. Ao **editar** uma obra que não tinha
galeria e passar a ter, o `update()` faz o mesmo. Sem isso, o operador ficaria
sem chance de enviar as fotos logo após o cadastro.

### 1.3 A galeria só aparece na edição

Em `form.blade.php` a aba "Galeria" só é renderizada quando
`isset($data) && $data->gallery == 1`. Na tela de **cadastro** (`create`) não
existe `$data`, portanto a galeria é enviada em uma **segunda etapa**.

### 1.4 Categorias com obras vinculadas não podem ser excluídas

`WorkCategoryController@destroy` conta `$store->works()->count()` e devolve
**HTTP 400** com a mensagem "Não é possível excluir esta categoria de obra,
pois existem obras vinculadas a ela.".

Como `Work` usa `SoftDeletes`, obras **soft-deleted** não bloqueiam a exclusão
da categoria. E como a exclusão da categoria também é **lógica**
(`deleted_at`), a FK `ON DELETE SET NULL` **não é disparada**: as obras
continuam apontando para a categoria, mas a view `listagem_obras` continua
listando-as (o `INNER JOIN` não filtra `wc.deleted_at`) — a categoria
desaparece do seletor do formulário, mas as obras seguem visíveis.

### 1.5 Normalização de datas no request

`prepareForValidation()` converte `start_date`, `end_date` e
`estimated_completion_date` de `d/m/Y` para `Y-m-d` **antes** da validação.
`end_date` tem a regra `after_or_equal:start_date` — mas a comparação é feita
com o valor já normalizado (a mesma chave foi mesclada), o que funciona.

### 1.6 O toggle "Ativar exibição na página" define `archives.view_pdf`

O radio `archive[][view]` (gerado por `script.js::setArchive`) marca o
atributo `view_pdf` do registro em `archives`. Na ficha pública, o componente
`v5/resources/views/portal/components/galerias/index.blade.php` **exclui** da galeria os arquivos
com `highlight` ou `view_pdf` preenchidos.

### 1.7 Coluna "ID" do CMS é a posição, não o identificador

A view `listagem_obras` expõe `row_number() OVER (ORDER BY w.created_at DESC)
AS value`, e `runDatatables` faz `unshift` de uma coluna `data: 'value'`. O
"ID" exibido é a **posição na ordenação**. O `id` real é usado nas ações (link
público, editar, excluir).

### 1.8 Valor monetário é texto

`works.value` e `works.value_paid` são `varchar(191)`. O formulário usa
`<input type="number" step="0.01">`, mas o valor persistido é a string
enviada. O portal formata com
`number_format((float) str_replace(['.', ','], ['', '.'], $work->value), 2, ',', '.')`,
o que **só** funciona para o formato `1234.56`. Para `1.234,56` (com
separador de milhar), o `str_replace` produz `1234,56` → `(float)` = `1234` →
exibe `R$ 1.234,00` — **valor errado em tela**.

### 1.9 `gallery` sempre presente no payload

`prepareForValidation()` faz `merge(['gallery' => ... ? 1 : 0])`, o que
**sempre** sobrescreve o campo. Um `PUT` que não envie `gallery` (ou que
envie um valor diferente de `on`) **desativa** a galeria da obra.

### 1.10 O checkbox da galeria usa `value="on"` e a normalização depende disso

O componente `v5/resources/views/components/inputs/checkbox.blade.php` renderiza
o input **sem atributo `value`** quando marcado — o navegador então envia o
literal `on`. É esse literal que a normalização
`strtolower($this->get('gallery')) === 'on' ? 1 : 0` espera. Se o componente
passar a emitir `value="1"` (ou `value="true"`), a regra do `SaveRequest`
(`'gallery' => ['nullable', 'integer', 'in:0,1']`) **passará a reprovar** o
formulário, porque o `merge()` produziria `0` (não `"1"`) e a galeria jamais
poderia ser ativada. **Contrato implícito entre o componente de input e o
FormRequest** — vale registrar na reconstrução (usar `boolean` no request e
eliminar a dependência do literal `on`).

### 1.11 A ordenação do portal põe obras sem data de início no fim

```php
$result = $result->orderBy(DB::raw('start_date IS NULL, start_date'), 'DESC');
```

Em PostgreSQL, `start_date IS NULL` produz `false`/`true` e, com ordenação
descendente, `true` (obra **sem** data de início) vem **depois** das demais —
ou seja, as obras sem data aparecem no **fim** da lista. A expressão é
específica do PostgreSQL (em MySQL o booleano é numérico e o resultado muda) e
não é portável para SQL Server.

### 1.12 O percentual de conclusão é exibido como número cru no portal

A tabela pública e a ficha pública imprimem `completion_percentage` (0–100)
sem normalização: quando a coluna é `NULL`, a tabela mostra `0%` (via
`?? 0`) e a ficha **omite** o bloco (via `isset()`). O valor não é validado
como consistente com a situação — uma obra "Finalizada" pode exibir 30%.

### 1.13 A galeria do portal filtra por extensão, não por `work_archives.type`

`v5/resources/views/portal/components/galerias/index.blade.php` seleciona as
imagens pela **extensão do arquivo**
(`jpg, jpeg, png, gif, bmp, tiff, svg, webp, ico, heic, raw`) e ignora o
`type` do pivot. Um PDF anexado como arquivo não entra na galeria (por
extensão), mas um PNG anexado como "arquivo para download" **entra** como
imagem de galeria — o critério de exibição é o tipo de mídia, não o papel
atribuído pelo operador.


---
