# Transparência Fiscal — Regras e Portabilidade

---

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

| # | Regra | Onde está | Efeito observável |
|---|---|---|---|
| R1 | A raiz da transparência é a `category_contents` com `slug = 'transparencia-fiscal'` | `v6/app/Http/Controllers/Portal/TransparencyController.php:28`, `:71`, `:96` | Se o slug não existir, usa o id literal **25** (que no banco inspecionado **é** a raiz real) |
| R2 | Só conteúdos com `status = true` aparecem no portal | `TransparencyController@index`/`subcategoria`/`show` | Itens desativados somem da listagem, mas continuam na view SQL do CMS |
| R3 | A ordem é `contents.order` (1-based) e o CMS a renumera ao inserir | `v6/app/Http/Controllers/Prefecture/ContentController.php:682-690` | Ao criar um item com `order = 3`, todos os itens da mesma categoria com `order >= 3` são incrementados |
| R4 | A **raiz** não pode ser excluída | `v6/app/Http/Controllers/Prefecture/CategoryContentController.php:735-737` | `DELETE` na raiz redireciona para `cadastrar-categorias` |
| R5 | A `description` só é salva para categorias cujo pai é a raiz da transparência | `CategoryContentController.php:461-464` e `:595-598` | `description` de categorias de outros módulos é ignorada |
| R6 | `isBlank` decide entre link externo (nova aba) e página interna (`iframe`) | `portal/components/transparencia-fiscal/item-box.blade.php` | `isBlank = 1` → `<a target="_blank">`; caso contrário → `route('transparencia.show')` |
| R7 | O prazo legal do e-SIC é de **20 dias** | `v6/app/Http/Model/Prefecture/InformationsRequest.php` (`checkParameters` + `whereRaw(... 20)`) | `> 20` = fora do prazo; `<= 20` = dentro do prazo |
| R8 | O prazo é medido em campos diferentes conforme o status | `checkParameters()` | Status 1/2/3 → `created_at`; status 4 (prorrogado) → `updated_at`; para respostas, `answers.created_at - informations_requests.created_at` |
| R9 | `full_list_requests_disabled = 1` esconde a listagem completa de solicitações | `v6/app/Http/Controllers/Portal/TransparencyController.php:relStatitics` + Blade | O gráfico e os contadores continuam visíveis |
| R10 | Todo acesso ao relatório incrementa `clicks` da `static_pages` | `relStatitics` | Métrica de acesso por página, não por item |
| R11 | O Ajax de respostas **não** valida `id` | `getAnswers` | `where('id', $request->id)` com valor arbitrário; id inexistente devolve 204 com corpo texto |
| R12 | A busca no portal externo só é habilitada com `portal_transparencia_ativo = 1` **e** `portal_transparencia_codigo_ente` não vazio | `v6/app/Services/PortalTransparenciaService.php:isActive` | Sem o código do ente, a integração fica silenciosamente desligada |
| R13 | A configuração do portal externo é cacheada por 600 s | `PortalTransparenciaService::getConfig` | Alterar a chave no CMS leva até 10 min para valer; o `ConfigurationController` faz `Cache::pull` para forçar a limpeza |

---

## 2. Acoplamentos

### 2.1 Dependências de outros módulos

| Módulo | Ponto de acoplamento |
|---|---|
| **Conteúdos / Categorias (genérico)** | O módulo **é** o módulo genérico de conteúdos: usa `ContentController`, `CategoryContentController`, `ContentRequest`, `CategoryContentRequest`, as views `prefecture/contents/*` e `prefecture/category-contents/*` |
| **Layouts / Templates do portal** | Todas as views são duplicadas por `modelo1..modelo6`; o template vem de `configurations.key = 'layout'` |
| **e-SIC / Informação solicitada** | Compartilha o controller de transparência, as tabelas `informations_requests`/`answers` e as views `transparencia-fiscal.informacao-solicitada` |
| **Ouvidoria** | Compartilha o diretório de views `transparencia-fiscal.relatorio-estatistico-ouvidoria` e a tabela `statuses` |
| **Busca global do portal** | `Portal\PortalController@searchContents` injeta os resultados do `PortalTransparenciaService` |
| **Home do portal** | `Portal\PortalController@index` refaz a mesma busca de categoria raiz para montar a seção de transparência da home |
| **Configurações (CMS)** | `Prefecture\ConfigurationController` invalida o cache do `PortalTransparenciaService` quando a chave começa com `portal_transparencia_` |
| **Módulos (`modulos`)** | `modulos.slug = 'transparencia-fiscal'` (id 23) — usado por `Prefecture\MenuServiceProvider` para montar o item de menu |

### 2.2 O que este módulo NÃO depende

- Não há repository, service ou policy próprios.
- Não há dados fiscais internos: **nenhuma** tabela de despesas, credores, empenhos ou
  orçamento foi encontrada no banco `prefeituras_v5` (verificado com `\dt`).
- O `PortalTransparenciaService` **não** é usado por `TransparencyController`; ele atende
  apenas a busca global do portal.

---

## 3. Armadilhas

### 3.1 SQL e consultas

| Armadilha | Local | Risco |
|---|---|---|
| `whereRaw` com SQL montado por concatenação | `v6/app/Http/Model/Prefecture/InformationsRequest.php` (`scopeGetByBetweenDate`, `scopeGetByBetweenDateWithAnswer`) | O fragmento é interno (`EXTRACT(DAY FROM now() - created_at) > 20`), **não** vem de input do usuário — mas o padrão é frágil |
| `DB::table('answers')->join('informations_requests', ...)` dentro de uma "scope" | `scopeGetByBetweenDateWithAnswer` | A scope retorna um **Query Builder**, não um model; `->count()` funciona, mas encadear `with()` não |
| `groupBy('status_id')` no meio de uma contagem | `scopeGetByBetweenDate` | `->count()` sobre query agrupada devolve a contagem de grupos, não de linhas — o valor exibido pode divergir do esperado |
| Leitura **integral** da view SQL | `v6/app/Http/Controllers/Prefecture/DatatableController.php:266` (`DB::table("transparencia_fiscal")->get()`) | Sem paginação no servidor; o payload inteiro vai ao navegador. Em municípios com centenas de itens, é desperdício de memória e banda |
| `row_number()` na view | migrations de 2023 | A coluna `value` é recalculada a cada leitura; indexá-la não ajuda |
| `WHERE` com `AND`/`OR` sem parênteses na view | `2023_05_04_160917_add_order_by_view_transparencia_fiscal.php` | Funciona por precedência, mas é fácil de quebrar em edição |
| `order` como palavra reservada | `c."order"` na view | Necessita de aspas; quebra em migração para outro SGBD sem quoting |
| Comparação `isBlank == 1` sobre campo booleano | `portal/components/transparencia-fiscal/item-box.blade.php` | Funciona em PostgreSQL, mas é o tipo errado em MySQL |

### 3.2 PHP e Laravel

| Armadilha | Local | Risco |
|---|---|---|
| Id literal `25` replicado | `TransparencyController` (3×), `CategoryContentController` (3×), `ContentController` (3×) | Se a raiz mudar de id, o sistema passa a listar a categoria errada **sem erro** |
| Acesso a propriedade de `null` | `TransparencyController@show`: `$transparencia->title ?? 'Transparência Fiscal'` | O `??` **não** protege o acesso a `$transparencia->title` quando `$transparencia` é `null` — resulta em "Trying to get property of non-object" (erro 500) |
| `Configuration::where('key','layout')->first()->value` | `v6/app/Http/Controllers/Admin/Controller.php:41` | `null->value` se a chave `layout` não existir — 500 em toda a navegação do portal |
| Eager loading correto só em parte | `TransparencyController@show` usa `with('linksUnique:link,isBlank,contents_id')` (com colunas selecionadas) em um trecho e `with('linksUnique')` em outro | Inconsistência; e `linksUnique` é `hasOne`, então a chave `contents_id` na seleção é redundante |
| `InformationsRequest::answers()` com chave estrangeira errada | `v6/app/Http/Model/Prefecture/InformationsRequest.php` (`hasMany(Answer::class,'id','answers_id')`) | A tabela `answers` **não tem** a coluna `answers_id` (tem `informations_request_id`). O `with('answers')` em `getAnswers` não retorna o esperado — por isso o controller também consulta `Answer::where('informations_request_id', ...)` separadamente |
| `getTemplateActived()` com efeito colateral | `v6/app/Http/Controllers/Admin/Controller.php:39-47` | Se a view do template não existir, grava `pagina_em_manutencao = 1` no banco — **efeito colateral em GET** |
| Nomes de rota duplicados | `v6/routes/web.php:165-176` | `route('transparencia.index')` sempre resolve para a última URI registrada |
| Export como efeito colateral do GET | `relStatitics` | `?type=` na mesma rota altera o tipo de resposta; sem cache nem `Content-Disposition` padronizado |
| Sanitização de link específica | `ContentController::sanitizeLink` / `normalizeVideoLink` | Regras pensadas para YouTube aplicadas a URLs genéricas |
| Slug duplicado | `ContentController@store` | Colisões de slug são resolvidas com sufixo `-{maxid}`; a URL pública muda silenciosamente |

### 3.3 Front-end

| Armadilha | Local | Risco |
|---|---|---|
| CDN externa para gráficos | `relatorio-estatistico/js.blade.php` (`gstatic.com/charts/loader.js`) | Sem internet o relatório perde o gráfico; sem SRI a CDN pode servir script alterado |
| JS monta HTML por template string | `relatorio-estatistico/js.blade.php` (`abrirModal`) | `${value.answer}` insere conteúdo do banco **sem escape** — risco de XSS armazenado se o texto da resposta contiver HTML |
| Ausência de `subcategorias.blade.php` fora do `modelo5` | `v6/resources/views/portal/layouts/` | Rota `transparencia.subcategoria` quebra conforme o template ativo |
| `iframe` sem `sandbox` | `transparencia-fiscal/show.blade.php` | Sem restrição de recursos do site externo |
| Colunas do DataTable acopladas ao payload | `datatable.js` × `struct_views` | Renomear as flags no controller quebra o JS silenciosamente |

---

## 4. Código duplicado

| Trecho | Onde aparece |
|---|---|
| Busca da categoria raiz + fallback 25 | `v6/app/Http/Controllers/Portal/TransparencyController.php`, `v6/app/Http/Controllers/Portal/PortalController.php:166-167`, `v6/app/Http/Controllers/Prefecture/CategoryContentController.php:238-239` e `:421`, `v6/app/Http/Controllers/Prefecture/ContentController.php:634`, `:830`, `:958` |
| Montagem do texto do TXT | `v6/app/Http/Controllers/Portal/TransparencyController.php:exportTxt` vs. view `v6/resources/views/exports/informations-requests.blade.php` (Excel/PDF) |
| Blades do portal | 6-7 cópias quase idênticas por template (`modelo1..modelo6`) |
| 4 migrations da view reescrevendo o mesmo `CREATE OR REPLACE VIEW` | 2023-04-11, 2023-04-18, 2023-04-28, 2023-05-04 |

---

## 5. Onde a v6 divergiu da v5

| Aspecto | v5 | v6 | Motivo provável |
|---|---|---|---|
| Exportação do relatório | Rota separada `ANY /relatorio-estatistico/exportacao` → `exportRelStatitics` (`v5/routes/web.php:29`) | Export folded into `relStatitics` via `?type=` (`v6/routes/web.php:20`) | Simplificação: uma rota em vez de duas |
| Nome dos arquivos exportados | `lai-relatorio-estatistico.{xlsx,csv,pdf,txt}` | `relatorio-estatistico.{xlsx,csv,pdf,txt}` | Padronização de nomenclatura |
| View do PDF | `exports.information-requests` (singular) | `exports.informations-requests` (plural) | Correção de nome |
| Formato do TXT | `sprintf` com colunas de largura fixa, cabeçalho com `config('app.name')` e ano | String concatenada, cabeçalho fixo "LAI - Relatório Estatístico" | Simplificação; **perde-se o ano no cabeçalho do TXT** |
| Exportação TXT | Método `exportInformationRequestsTxt` (protected) | Método `exportTxt` (private) | — |
| `datas` (anos disponíveis) | `array_unique(...)` sobre `->get()->pluck(...)` | `->orderBy('created_at','desc')->pluck('created_at')->map(...)->unique()->toArray()` | Evita carregar todas as linhas em memória |
| `selectTransparency` / `$id` | Variável `$id` | Variável `$idTransparencia` | Clareza (o nome do blade foi atualizado junto) |
| Integração com portal fiscal externo | **Inexistente** | `v6/app/Services/PortalTransparenciaService.php` + seeder `v6/database/seeds/AddPortalTransparenciaConfigurationsSeeder.php` + chaves `portal_transparencia_*` | Novo: busca no portal de transparência do fornecedor |
| View SQL | Mesmas 4 migrations | Mesmas 4 migrations | Sem mudança |
| Rotas de transparência | 4 de índice + 1 de categoria + 5 de detalhe | Idênticas | Sem mudança |
| Views do portal | `modelo1..modelo5` | `modelo1..modelo6` + `newsbit` | Novo template modelo6 |

> O nome do seeder `AddTerceirizadosInTransparenciaFiscalSeeder` existe nas duas versões
> (`v5/database/seeds/` e `v6/database/seeds/`) com a mesma lógica condicionada ao estado `PB`.

---

## 6. Riscos de migração de schema

| Risco | Detalhe |
|---|---|
| `contents.order` pode ter lacunas ou duplicatas | A reordenação do CMS só garante unicidade **dentro de uma categoria** e apenas para inserções via CMS; seeds e importações podem violar |
| `order` NULL | `ORDER BY c."order"` no PostgreSQL põe os NULLs **por último** (default `NULLS LAST` em `ASC`), quebrando a ordenação esperada de itens sem ordem definida |
| `category_contents_id` NULL em `contents` | A coluna é `NOT NULL`, ok — mas `CategoryContent::where('slug','transparencia-fiscal')->whereNull('category_contents_id')` sugere que historicamente existiram registros com pai nulo |
| `links` opcional | Itens sem `links` quebram a semântica do `item-box` (caem no `route('transparencia.show')`) |
| `statuses` fixo | Os ids 1-7 são usados literalmente nos scopes e no JS; um novo status exige alterar código, não só dados |
| View SQL como contrato | Renomear colunas da view quebra `datatable.js` e o controller do CMS ao mesmo tempo |
| Dependência de `static_pages.slug = 'relatorio-estatistico'` | O relatório depende de um registro de página estática; sem ele, `$content` é `null` e a view acessa `$content->text` (erro 500) |
