# Regras, acoplamentos e portabilidade — Licitações

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

### 1.1 Identidade e slug

- Toda linha nasce com `id` UUID v4 de 36 caracteres. O `creating` do model só
  preenche se `id` estiver vazio (`V2/Modules/Publicacoes/Licitacoes/Models/Licitacao.php:119-122`), então o
  importador pode gravar um id próprio.
- O slug é `Str::slug($number . '-' . $title)`; se o resultado for vazio, usa
  `licitacao-<8 aleatórios>` (`:143-148`).
- A unicidade é resolvida em laço, com sufixo numérico **a partir de 1**:

```php
$slug = $base;
$counter = 1;
$query = static::where('slug', $slug);
if (isset(static::$oldSlug)) {
    $query->where('id', '!=', static::$oldSlug);
}
while ($query->exists()) {
    $slug = $base . '-' . $counter;
    $query = static::where('slug', $slug);
    if (isset(static::$oldSlug)) {
        $query->where('id', '!=', static::$oldSlug);
    }
    $counter++;
}
```

(`V2/Modules/Publicacoes/Licitacoes/Models/Licitacao.php:150-165`)

`static::$oldSlug` **nunca é atribuída** em nenhum arquivo do repositório
(verificado por busca global). Logo o `if` é sempre falso. Consequências:

- na criação, se já existir o mesmo número+título, o segundo recebe `-1`;
- na edição, quando `number`/`title` mudam para um valor cujo slug gerado é
  igual ao slug **da própria linha**, a consulta encontra a própria linha e
  gera `-1` desnecessariamente; a URL pública muda sem o operador pedir;
- se o slug gerado já existir em outra linha, o sufixo é aplicado — correto —
  mas não há tratamento de corrida entre duas requisições concorrentes; a
  unique parcial do banco pode estourar.

O `slug` enviado explicitamente pelo formulário vence: o `updating` só
regenera quando `slug` não está sujo (`:128-135`).

### 1.2 Objeto em texto puro

```php
public function setObjectAttribute($value)
{
    $this->attributes['object'] = strip_tags(html_entity_decode($value ?? ''));
}
```

(`V2/Modules/Publicacoes/Licitacoes/Models/Licitacao.php:172-175`)

- Remove tags HTML de qualquer entrada.
- Decodifica entidades antes de remover tags: `&lt;b&gt;` vira `<b>` e é
  removido, não preservado como texto.
- A migration 000008 aplica a mesma transformação em dados históricos cujo
  `object` casa com `~* '<[a-z]'`.
- O formulário usa um editor rico que produz AST JSON; o mutator não remove
  JSON (sem tags), mas a coluna é `text` e não há cast de array. O portal
  depende de `json_decode` para renderizar; texto puro resulta em HTML vazio.

### 1.3 Valor

`setValueAttribute` converte `''` e `null` em `0` e o restante em `float`
(`:181-184`). O banco é `decimal(15,2)`. Não há arredondamento explícito antes
do cast; o PostgreSQL arredonda na gravação, mas o objeto Eloquent continua com
o float original até o próximo `fresh()`.

`show_value` é um booleano independente: o valor continua armazenado e
editável; apenas a exibição pública é suprimida. As views usam `? 'R$ ...' : ''`
(portal) ou `—` (CMS).

### 1.4 Datas

Três datas opcionais, sem ordem obrigatória: é possível homologar antes de
publicar o edital. Não há cálculo de “vigente”, alerta de data passada ou
timezone explícito. No PostgreSQL as colunas são `timestamp` sem timezone, e o
Laravel 5.5 as interpreta no timezone da aplicação.

### 1.5 Publicação

`is_active` é o único critério do portal (`scopeActive`). Não depende de
`status_id`, de datas ou de existir anexo. `status` é rótulo editorial e serve
apenas para cor/exibição. O importador deriva `is_active` do status:
`Cancelada` → `false`, qualquer outro → `true` (regra documentada no topo de
`LicitacoesLegacyImporter`).

### 1.6 Ciclo de vida

- **Arquivar** é soft delete da licitação. Não altera `is_active`, não remove
  anexos e não os soft-deleta.
- **Restaurar** desfaz apenas o soft delete da licitação.
- **Excluir em definitivo** apaga os arquivos físicos dos anexos **visíveis** e
  faz hard delete. A FK `onDelete('cascade')` remove as linhas de anexos e o
  pivô, mas não os arquivos de anexos já soft-deleted.
- **Anexo**: `delete()` no serviço apaga o arquivo físico e faz soft delete do
  registro. Não há restauração de anexo na UI.
- **Órgão**: soft delete; `restore()` e `toggle()` existem. `destroy` de um
  órgão não desassocia licitações; a relação continua retornando o órgão
  deletado, porque `belongsToMany` não aplica o `SoftDeletes` global scope na
  tabela relacionada sem `wherePivot`/`whereNull`.

### 1.7 Anexos — allowlist e magic bytes

O serviço tem duas barreiras:

1. **MIME declarado** precisa estar em `ALLOWED_MIME_TYPES`
   (`V2/Modules/Publicacoes/Licitacoes/Services/LicitacaoAttachmentService.php:25-49`):
   ZIP, PDF, DOC/DOCX, XLS/XLSX, JPEG, PNG, GIF, WebP, SVG, MP3, WAV, OGG,
   MP4, WebM.
2. **Magic bytes** por MIME (`:51-77`), com três formatos aceitos:
   - lista de bytes a partir do offset 0 (`application/pdf => ['%PDF']`);
   - descritor com offset (`video/mp4 => ['offset' => 4, 'bytes' => ['f','t','y','p']]`);
   - lista de alternativas (`audio/mpeg` aceita `ID3` ou `FF FB`).

```php
foreach ($bytes as $i => $byte) {
    $pos = $offset + $i;
    if (!isset($header[$pos]) || ord($header[$pos]) !== ord($byte)) {
        $match = false;
        break;
    }
}
```

(`V2/Modules/Publicacoes/Licitacoes/Services/LicitacaoAttachmentService.php:173-186`)

Limitações observadas:

- lê apenas **16 bytes** do arquivo (`:161`); assinaturas maiores que isso não
  são verificadas, mas nenhuma da allowlist passa de 8 bytes;
- `application/zip`, `docx` e `xlsx` compartilham `PK`, então um ZIP comum
  passa como DOCX/XLSX;
- `image/webp` valida apenas `RIFF`, não `WEBP` no offset 8; um WAV (`RIFF`)
  declarado como WebP passaria;
- `image/svg+xml` e `application/vnd.ms-excel` (OLE2) dependem do MIME
  detectado; o SVG não tem assinatura e não está em `MAGIC_BYTES`, então
  `validateMagicBytes` retorna cedo (`:147-149`);
- o MIME usado é `UploadedFile::getMimeType()`, que no Laravel 5.5 usa finfo
  sobre o conteúdo, não o `Content-Type` do cliente. Ainda assim, o nome
  original é preservado sem sanitização além do que o Flysystem faz.

O upload grava em `licitacoes/{id}` no disco `public` e persiste
`original_name`, `mime_type`, `file_size` e `order`. O `order` é passado pelo
controller como `$licitacao->anexos()->count()`, sem `max(order)+1`. Se um
anexo intermediário for removido, o próximo upload pode repetir uma ordem
existente; a listagem fica com empate e o desempate passa a ser indefinido.

### 1.8 Órgãos inline

`createInline` cria o órgão e devolve o model inteiro em JSON. A view insere um
checkbox marcado no formulário sem recarregar. Não há proteção contra
duplicidade além da validação `unique`; duas abas podem criar o mesmo nome em
corrida e o banco não tem unique.

## 2. Acoplamentos

### 2.1 Dentro da V2

| Módulo/serviço | Direção | Contrato |
|---|---|---|
| `V2/Core` (helpers, audit, permission, layouts) | depende | `v2_can`, `v2_uuid`, `v2_storage_url`, `V2AuditLogger`, `CmsActionLogger` |
| `V2/Services/ContentRichEditor` | depende | formato AST/JSON em `object` |
| `V2/Services/SEO` | depende | `setTitle` no detalhe |
| `V2/Services/Storage` | depende | URL de anexo |
| `Infraestrutura/Estatisticas` | é usado por | `LicitacaoDataSource` lê as tabelas |
| `Support/MigracaoLegada` | é usado por | importador grava `v1_id`, status, jsonb |
| `Institucional/BasicPage` | é usado por | atalho “Editais” aponta para `/licitacoes` |
| `Infraestrutura/Analytics` | é usado por | mapeia a rota para rótulo |

### 2.2 Dependências de dados do importador

O importador é o principal acoplamento oculto: ele conhece o schema por dentro
e grava campos que o módulo não edita (`v1_id`, `imported_at`,
`jurisdicionados`, `metadata`, `arquivos`). Também cria status por nome com
`firstOrCreate`, podendo introduzir nomes fora dos três canônicos. Qualquer
mudança de coluna ou de nome de tabela quebra o importador **sem** que os
testes do módulo percebam.

### 2.3 Dependências do layout

As views dependem de partials e componentes do Core que não são versionados
junto com o módulo: `v2-cms::partials.table` espera um contrato específico de
`columns`/`rows`/`paginator`, e `v2-cms::partials.filter-card` espera
`searchPlaceholder`, `searchValue`, `action` e `showDateRange`. Uma mudança no
partial quebra as duas telas.

## 3. Armadilhas e riscos técnicos

### 3.1 Portal carrega tudo e filtra no navegador

```php
$query = Licitacao::active()->with(['status', 'orgaos', 'anexos']);
$licitacoes = $query->ordered()->get();
```

(`V2/Modules/Publicacoes/Licitacoes/Controllers/Portal/LicitacaoPortalController.php:28-31`)

Com N licitações, o custo é: 1 query da licitação + 1 de status + 1 de órgãos
+ 1 de anexos + queries do pivô (o `with` de `belongsToMany` faz duas). O HTML
cresce sem limite e o navegador recebe todos os registros. Não há paginação,
`LIMIT`, cache ou busca no servidor. É o risco de maior impacto para o V7.

### 3.2 Duas verdades para anexos

- `v2_licitacoes.arquivos` (jsonb) guarda `[{url,title}]` do importador.
- `v2_licitacao_anexos` guarda os uploads nativos.

Nenhum código sincroniza os dois. O CMS e o portal leem apenas a tabela; o
jsonb fica como dado morto (mas visível para quem consultar o banco ou a API
futura). No V7, escolher **uma** fonte e migrar o jsonb para linhas da tabela
(ou descartá-lo, conforme ADR-042).

### 3.3 Force delete ignora anexos soft-deleted

```php
foreach ($licitacao->anexos as $anexo) {
    \Storage::disk('public')->delete($anexo->file_path);
}
```

(`V2/Modules/Publicacoes/Licitacoes/Controllers/Admin/LicitacaoCmsController.php:230-232`)

`$licitacao->anexos` aplica o global scope `SoftDeletes` de `LicitacaoAnexo`.
Anexos já removidos pela UI têm `deleted_at` e não entram no laço; seus
arquivos ficam no disco para sempre. Correção no V7: usar
`anexos()->withTrashed()->get()` antes do hard delete, ou delegar ao serviço
Media que conhece o objeto.

### 3.4 Filtros do CMS sem efeito

A view envia `orgao` e `status` (`V2/Modules/Publicacoes/Licitacoes/Views/cms/index.blade.php:113-139`), mas o
controller só lê `search`, `sort` e `dir`. O usuário escolhe um filtro, o form
submete e a lista não muda. O partial ainda mostra “Limpar filtros” com base
nesses parâmetros, reforçando a falsa impressão.

### 3.5 N+1 e contagens repetidas

- `V2/Modules/Publicacoes/Licitacoes/Views/cms/includes/attachments.blade.php:33` chama
  `$licitacao->anexos()->count()` dentro do `@forelse` — uma query por anexo.
- `Licitacao::getHasAnexosAttribute()` executa `anexos()->count()` a cada
  acesso; não é usado hoje, mas é uma armadilha para novas views.
- `V2/Modules/Publicacoes/Licitacoes/Views/cms/index.blade.php:6-7` executa duas queries de catálogo
  (`LicitacaoOrgao`, `LicitacaoStatus`) por render, mesmo sem filtros ativos.
- `V2/Modules/Publicacoes/Licitacoes/Views/portal/index.blade.php` chama `$l->anexos->count()` (coleção já
  carregada, sem query) e `$l->orgaos->count()` (idem), o que é correto.

### 3.6 `appends` ausente em archived

`index` faz `->appends($r->query())`; `archived` não
(`V2/Modules/Publicacoes/Licitacoes/Controllers/Admin/LicitacaoCmsController.php:89`). A paginação da lixeira
perde busca/ordenação ao navegar entre páginas.

### 3.7 Upload e `title` como array

O controller se protege contra `title` array:

```php
$rawTitle = $r->input('title');
$title = is_array($rawTitle) ? '' : (string) $rawTitle;
```

(`V2/Modules/Publicacoes/Licitacoes/Controllers/Admin/LicitacaoCmsController.php:291-292`)

A validação `nullable|string` já rejeitaria array; a guarda é defensiva e
indica tentativa de corrigir um erro de tipo em produção.

### 3.8 Datas e `datetime` sem timezone

As três colunas são `datetime` (timestamp sem tz). O cast `datetime` do
Laravel 5.5 aplica o timezone da aplicação na leitura, mas o banco não guarda
offset. Comparações de “hoje” e relatórios por ano no `LicitacaoDataSource`
usam `to_char` e `extract(year ...)` no fuso do banco.

### 3.9 Ordenação de anexos sem transação

`reorder` faz um `UPDATE` por id, em laço, sem transação
(`V2/Modules/Publicacoes/Licitacoes/Services/LicitacaoAttachmentService.php:248-255`). Falha no meio deixa
ordens parcialmente aplicadas. Também não valida pertencimento no serviço:
apenas o `where('licitacao_id', ...)` filtra, então ids de outra licitação são
silenciosamente ignorados.

### 3.10 `status` e `orgaos` sem escopo

`LicitacaoStatus::all()` no formulário inclui status inativos (não há escopo
`active()`), e `V2/Modules/Publicacoes/Licitacoes/Views/cms/form.blade.php` usa `LicitacaoStatus::all()` diretamente. Já
`LicitacaoOrgao::active()` é usado no `create`/`edit`, mas a lista de filtros do
índice usa `orderBy('name')` sem filtrar ativos nem excluir soft-deleted
(`V2/Modules/Publicacoes/Licitacoes/Views/cms/index.blade.php:6`).

## 4. Duplicação e divergência

- **Dois controladores de órgãos?** Não. O CRUD de órgãos vive em
  `LicitacaoOrgaoController`; o `LicitacaoCmsController` só carrega a lista
  para o formulário.
- **Dois modais de órgão**: `V2/Modules/Publicacoes/Licitacoes/Views/cms/create.blade.php` e `V2/Modules/Publicacoes/Licitacoes/Views/cms/includes/orgao-modal.blade.php`
  duplicam markup e JS (ver `04-VIEWS-E-UX.md`).
- **Duas fontes de anexo**: jsonb legado e tabela nova (ver 3.2).
- **Dois caminhos de autorização**: middleware e `v2_can` no controller; a
  duplicação é intencional para testes unitários, mas aumenta a chance de
  divergência.
- **Versões V1/V2**: o módulo V2 não existe como código na V1; a V1 tem
  `biddings`, `archives`, `statuses` e `bidding_jurisdictionals`, e é a origem
  do importador. Não há uma segunda implementação de UI para comparar. O mapa
  de dados está em `02-MODELAGEM.md` §9.

## 5. O que é portátil e o que não é

| Item | Portabilidade | Motivo |
|---|---|---|
| conceito de licitação + anexos + órgãos N:N | alta | modelo de domínio claro |
| allowlist MIME + magic bytes | alta | regra de segurança reutilizável; corrigir WebP/ZIP |
| ordenação de anexos | média | conceito útil; implementação sem transação e sem validação de posse |
| slug `number-title` | média | intenção boa; remover `static::$oldSlug` e usar unicidade transacional |
| `object` em text com `strip_tags` | baixa | contradiz o editor AST e apaga conteúdo legado |
| portal client-side | baixa | não escala e não é indexável por filtro |
| jsonb `arquivos` | baixa | dado morto e segunda verdade |
| UUID v4 gerado em PHP | média | funciona, mas o V7 usa UUIDv7 nativo |
| auditoria via `activity()` | média | o V7 tem trilha automática por observer (ADR-035) |
| datas sem timezone | baixa | V7 exige `timestamptz` (ADR-017) |
