# Regras e Portabilidade

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

| # | Regra | Evidência | Por que importa |
|---|---|---|---|
| R1 | Só categorias **raiz ativas** abrem a página | `V2/Modules/DadosAbertos/TransparenciaFiscal/Controllers/Portal/TransparencyPortalController.php:17` | categoria filha ativa sob raiz inativa desaparece inteira |
| R2 | Itens da raiz são filtrados por `active()`; itens das filhas **não** | `V2/Modules/DadosAbertos/TransparenciaFiscal/Controllers/Portal/TransparencyPortalController.php:17-19` | item inativo exposto no portal |
| R3 | O portal ignora `deleted_at`? Não: o global scope do `SoftDeletes` remove os soft-deleted | `V2/Modules/DadosAbertos/TransparenciaFiscal/Models/V2TransparencyItem.php:52` | exclusão no CMS some do portal |
| R4 | Um item sem categoria nunca aparece no índice | `V2/Modules/DadosAbertos/TransparenciaFiscal/Views/portal/index.blade.php:34,54` (itera por categoria) | o select permite "Sem categoria", mas o portal não tem bloco "sem categoria" |
| R5 | Detalhe exige slug de item **ativo** | `V2/Modules/DadosAbertos/TransparenciaFiscal/Controllers/Portal/TransparencyPortalController.php:28` | desativar item quebra links antigos com 404 |
| R6 | Slug do item é gerado só na criação | `V2/Modules/DadosAbertos/TransparenciaFiscal/Models/V2TransparencyItem.php:63-72` | renomear não muda a URL |
| R7 | Slug da categoria é gerado/atualizado com o nome | `V2/Modules/DadosAbertos/TransparenciaFiscal/Models/V2TransparencyCategory.php:63-77` | renomear categoria muda a URL de... nada (categoria não tem rota pública) |
| R8 | `order` é o critério primário; `name` desempata | `V2/Modules/DadosAbertos/TransparenciaFiscal/Models/V2TransparencyCategory.php:112-115` | categorias com `order` 0 aparecem em ordem alfabética |
| R9 | Excluir categoria deixa itens órfãos (`SET NULL`) | `Migrations/2026_07_17_000002_...:29` | itens "somem" do portal sem aviso |
| R10 | Categorias não podem ser criadas pela UI | ausência de rota/action | operador precisa de seeder ou SQL |
| R11 | Importação V1 força `value = null` | `V2/Modules/Support/MigracaoLegada/Services/TransparenciaFiscalLegacyImporter.php:141` | dados importados não têm valor monetário |
| R12 | `metadata` é depósito livre, nunca exibido | models + views | dados de arquivo legado ficam invisíveis |
| R13 | O CMS lista categorias inativas no filtro e no formulário | `V2/Modules/DadosAbertos/TransparenciaFiscal/Controllers/Admin/TransparencyCmsController.php:42,60,86` | permite publicar item em categoria que o portal não mostrará (se for raiz inativa) |
| R14 | Desmarcar "Ativo" no formulário de edição não desativa | `V2/Modules/DadosAbertos/TransparenciaFiscal/Requests/TransparencyRequest.php:22` + `update()` `:98` | ver GAP em `08-GAPS-E-DIVIDAS.md` |

## 2. Acoplamentos

### 2.1 De quais módulos este depende

| Dependência | Uso | Acoplamento |
|---|---|---|
| `V2/Core` (RBAC, auditoria, flags) | middleware, `v2_can`, `v2_uuid`, `V2AuditLogger` | forte e esperado |
| `V2/Services/SEO` | `setTitle` no detalhe | fraco (try/catch silencioso) |
| `V2/layouts/cms` e `V2/layouts/portal` | layouts e partials | forte (contrato de blocos) |
| `V2/Modules/Support/MigracaoLegada` | importador grava nas tabelas | forte, direção Support → DadosAbertos |
| PostgreSQL | `jsonb`, `ILIKE` | forte (não roda em MySQL) |

### 2.2 De quais módulos ele é usado

| Consumidor | Como |
|---|---|
| `Support/MigracaoLegada` | importa V1 para as tabelas |
| `Support/MigracaoLegada/Services/LegacyV1FixtureFactory` | limpa/valida as tabelas em testes (`:298`, `:324`) |
| `Infraestrutura/Analytics` | rótulo de URL `/transparencia` (`V2/Modules/Infraestrutura/Analytics/Controllers/AnalyticsCmsController.php:223`) |
| `Infraestrutura/Estatisticas` | mock de acessos a `/transparencia/*` (`V2/Modules/Infraestrutura/Estatisticas/Console/Commands/EstatisticasMockCommand.php:213-215`) |
| `Institucional/BasicPage` | link "Transparência" na home (`V2/Modules/Institucional/BasicPage/Views/portal/homepage/partials/dashboard-financeiro.blade.php`) |
| `LandingSectionsEditor` | CTA para `/transparencia` (`V2/Services/LandingSectionsEditor/Views/render/home-comunicado.blade.php:18`) |

Nenhum desses lê as tabelas: são links/estatísticas. O módulo é **folha** na
leitura e **alvo de escrita** do importador.

## 3. Armadilhas de implementação

### 3.1 `children` sem `active()` — N+1 e vazamento

```php
// V2/Modules/DadosAbertos/TransparenciaFiscal/Controllers/Portal/TransparencyPortalController.php:17-19
$categories = V2TransparencyCategory::active()->root()->ordered()->with(['children','items' => function ($q) {
    $q->active();
}])->get();
```

`'children'` é string simples: carrega todas as filhas, **sem** `active()` e
**sem** eager load de `children.items`. A view então:

```blade
{{-- V2/Modules/DadosAbertos/TransparenciaFiscal/Views/portal/index.blade.php:54 --}}
@forelse($child->items as $item)
```

Resultado: 1 query para raízes + 1 para `children` + 1 para `items` da raiz +
**N queries** para `items` de cada filha. Com 50 filhas, 53 queries por
carregamento da página. Além disso, item inativo de filha é renderizado.

Correção mínima (para o porte): eager load aninhado com escopo.

```php
// exemplo ilustrativo
->with([
    'items' => fn ($q) => $q->active()->orderByDesc('reference_date'),
    'children' => fn ($q) => $q->active()->ordered()
        ->with(['items' => fn ($q2) => $q2->active()->orderByDesc('reference_date')]),
])
```

### 3.2 `TransparencyRequest` calcula `$id` e não usa

```php
// V2/Modules/DadosAbertos/TransparenciaFiscal/Requests/TransparencyRequest.php:19-23
public function rules()
{
    $id = $this->route('id') ?: 'NULL';
    return['title' => 'required|string|max:255', /* ... */];
}
```

Código morto; sugere que já existiu (ou se planejou) uma regra de unicidade.
Não há `slug` nas regras — logo, o cliente não pode enviar slug (ele está no
`$fillable`, mas fora do `validated()`).

### 3.3 Slug com índice não único

`2026_08_31_000001_...:17-18` cria coluna `nullable` + `index('slug')`, **sem**
`unique`. A unicidade prática vem do sufixo de 8 chars do UUID. Comparações de
URL dependem de `where('slug', ...)->firstOrFail()` — colisão resultaria em
item errado servido.

### 3.4 Ordenação vinda da query string

`index` valida `sort`/`dir` contra whitelist (`V2/Modules/DadosAbertos/TransparenciaFiscal/Controllers/Admin/TransparencyCmsController.php:36-38`),
o que evita SQL injection por coluna. Porém `is_active` é boolean e `value` é
`decimal`; a ordenação funciona, mas mistura tipos na mesma tabela. O parâmetro
`category` **não** é validado como UUID: valor inválido gera `where` com string
qualquer e lista vazia (sem erro).

### 3.5 Auditoria e log em leitura

`index` registra `cmsLog->info('transparencia', 'index', [...])`
(`:43-49`) a cada carregamento — ruído no sink de auditoria, sem valor
probatório.

### 3.6 `update` não inclui `slug` e o model não reage

`V2TransparencyItem` só possui `static::creating`. Diferente do model de
categoria, que tem `static::updating`. Inconsistência entre os dois models do
mesmo módulo.

### 3.7 Dupla checagem de permissão

Middleware + `abort(403)` no controller. Se o middleware for desativado
(configuração de rota alterada), o controller ainda protege. Se o controller for
chamado em teste, `authorize()` do Request pode barrar antes. Três pontos de
decisão para a mesma regra.

### 3.8 Importador como único caminho de carga

A carga real vem de `Support/MigracaoLegada`, com `firstOrCreate` de categorias
e cópia de arquivos para a galeria. Não há job, comando ou cron no módulo para
atualização periódica de valores fiscais — o CMS manual é o único caminho
editorial.

### 3.9 Soft delete + `withTrashed()` no seeder

O seeder procura `where('slug', ...)->withTrashed()->first()` e reativa. Isso
significa que rodar o seeder "ressuscita" categoria excluída pelo operador —
comportamento inesperado se alguém apagar uma categoria padrão de propósito.

## 4. Código duplicado / divergência entre versões

### 4.1 V1 (`App\Http\Controllers\Portal\TransparencyController`)

`app/Http/Controllers/Portal/TransparencyController.php:13-40` implementa o
portal lendo a árvore de conteúdo (`CategoryContent` slug `transparencia-fiscal`
+ `Content`), com grupo por categoria e ordenação por `order`. O V2 reimplementa
a mesma ideia em tabelas próprias, sem reaproveitar nada.

| Comportamento | V1 | V2 |
|---|---|---|
| Fonte | `contents` + `category_contents` | `v2_transparency_items` + `v2_transparency_categories` |
| Hierarquia | N níveis (`category_contents` recursivo) | 2 níveis renderizados |
| Valores monetários | não existem (texto/HTML) | `value decimal(15,2)` |
| Competência | `publication_date` | `reference_date` |
| Anexos | `archives` (links) | nenhum |
| Rota | 4 aliases (`/transparencia-fiscal`, `/transparencia`, `/portal-da-transparencia`, `/portal-transparencia`) | 1 (`/transparencia`) |
| Busca | não | não |
| Exportação | não | não |

O importador (`TransparenciaFiscalLegacyImporter`) é a ponte declarada e
documenta as perdas: `value = null`, HTML convertido para texto plano, arquivos
copiados para a galeria mas não exibidos.

### 4.2 Blades create/edit quase idênticos

`V2/Modules/DadosAbertos/TransparenciaFiscal/Views/cms/create.blade.php` e `V2/Modules/DadosAbertos/TransparenciaFiscal/Views/cms/edit.blade.php` repetem o mesmo
formulário (≈70 linhas cada). Único partial compartilhado seria o form; não
existe.

### 4.3 Models com hooks diferentes

Categoria atualiza slug em `updating`; item não. Categoria não tem índice
`order`; item tem índice em `reference_date`. Sem justificativa registrada.

## 5. Portabilidade — o que sobrevive e o que não

| Elemento | Portável? | Observação |
|---|---|---|
| Tabela de categorias hierárquica | **sim, com ajuste** | trocar `parent_id` livre por hierarquia explícita ou achatar em `theme`/`tags` |
| `order` por categoria | **sim** | vira `position` (int) com índice |
| `reference_date` (competência) | **sim** | é o dado mais valioso; vira coluna `competencia`/`periodo` |
| `value decimal(15,2)` | **sim, com ajuste** | nunca `float` em PHP; `numeric`/`string` ou inteiro de centavos |
| `title`/`description` | **sim** | descrição vira `summary`; HTML rico exigiria AST (ADR-028) |
| `is_active` | **não** | substituir por `status` + `published_at` |
| `slug` gerado no model | **não** | slug é dado/editável, com `UNIQUE` e regra explícita |
| `metadata` livre | **parcial** | V7 exige `jsonb` com contrato (ADR-017) |
| Controllers gordos | **não** | V7: DDD-lite, Action/Controller fino |
| `v2_uuid()` (UUID v4) | **não** | V7 usa UUIDv7 no banco (`uuidv7()`) |
| Layout Bootstrap + partials | **não** | V7: Tailwind/daisyUI + componentes próprios |
