# Relatório Estatístico da Ouvidoria — Regras e Portabilidade

> Regras de negócio observáveis e armadilhas reais da implementação na `v5`.

---

## 1. Regras de negócio

| # | Regra | Onde está implementada |
|---|---|---|
| R1 | O filtro **Ano de Gestão** é opcional. Ausente/vazio → todos os anos. | `v5/app/Http/Controllers/Portal/OmbudsmanController.php:300-311` |
| R2 | O ano filtra **duas** populations diferentes: `ombudsman_requests.created_at` (listagem e cartões) e `relatorios_estatisticos_ouvidoria.ano` (tabela de PDFs) | idem |
| R3 | O **prazo legal** de resposta é de **20 dias** (`EXTRACT(DAY FROM ...) > 20` ou `<= 20`) | `v5/app/Http/Model/Prefecture/OmbudsmanRequest.php:100-120` e `143-168` |
| R4 | Os status usados nos agregados são **1 Em Tramitação, 2 Atendido, 3 Indeferido, 4 Prorrogado** | `v5/app/Http/Controllers/Portal/OmbudsmanController.php:300-320` |
| R5 | Para status **1 (Em Tramitação)** e **4 (Prorrogado)** o prazo é medido entre `created_at` e `updated_at`; para **2 (Atendido)** e **3 (Indeferido)** o prazo é medido entre a data da **resposta** (`ombudsman_answers.created_at`) e `created_at` | `OmbudsmanRequest::checkParameters()` (linhas 171-192) |
| R6 | Contagens de "Atendido" e "Indeferido" exigem **resposta registrada** (`ombudsman_answers`); solicitações sem resposta **não** entram nesses cartões | `scopeGetByBetweenDateWithAnswer` |
| R7 | O card "Pedidos Recebidos" é o total da listagem do ano; "Atendidos" e "Indeferidos" vêm dos contadores de prazo — **universos diferentes** | `v5/resources/views/portal/components/relatorio-estatistico-ouvidoria/stats.blade.php` |
| R8 | A **listagem completa** das solicitações só é renderizada se `configurations.full_list_requests_disabled` não for `1` | `v5/app/Http/Controllers/Portal/OmbudsmanController.php:337` e a view do portal |
| R9 | Protocolo e nome do solicitante são **mascarados** (`masked_protocol`, `masked_name`); solicitações anônimas exibem "Anônimo" | `v5/app/Http/Model/Prefecture/OmbudsmanRequest.php:50-61` |
| R10 | No CMS o upload é **opcional**; ao editar sem enviar novo arquivo, o PDF anterior é mantido | `v5/app/Http/Controllers/Prefecture/RelatorioEstatisticoOuvidoriaController.php:52-60` |
| R11 | O PDF é limitado a **20 MB** e deve ser PDF (`mimes:pdf`) | `v5/app/Http/Requests/RelatorioEstatisticoOuvidoriaRequest.php` |
| R12 | Exclusões são **lógicas** (`SoftDeletes`); o endpoint do DataTable filtra `whereNull('deleted_at')` | model + `DatatableController@get_relatorio_estatistico_ouvidoria` |
| R13 | Cada visita à página pública incrementa `static_pages.clicks` | `OmbudsmanController@relStatitics` |
| R14 | O **nome do arquivo** no storage é aleatório (`uniqid` + 5 chars + extensão); o valor gravado no banco é `docs/<arquivo>` | `v5/app/Http/Traits/FileTrait.php:78-82` |

---

## 2. Onde a agregação é feita

| Agregado | Camada | Consulta |
|---|---|---|
| 8 contadores de prazo | **SQL**, via scopes no model | `OmbudsmanRequest::getByBetweenDate()` / `getByBetweenDateWithAnswer()` |
| `$porTipo` (tipo de manifestação) | **SQL** | `DB::table('ombudsman_requests as r')->leftJoin('ombudsman_manifestation_types as t', ...)->select('t.title', DB::raw('count(*) as total'))->groupBy('t.title')` |
| `$porAssunto` (assunto) | **SQL** | idem, `leftJoin('ombudsman_subjects as s', ...)` — **resultado não exibido** |
| `$porGenero` | **SQL** (contagem) + **PHP** (normalização e agrupamento) | `->map(...)` com a lista `['Masculino','Feminino','Prefiro não informar']`; o que não estiver na lista vira "Não informado" e é re-agrupado com `->groupBy('label')->map(...)` |
| Cartões "Recebidos/Atendidos/Indeferidos" | **PHP** | soma dos contadores no Blade |
| Lista de anos disponíveis (`$datas`) | **PHP** | `OmbudsmanRequest::orderBy('created_at','desc')->get()->pluck('created_at')` + `array_unique` |
| Tabela de PDFs do CMS | **PHP** (query builder) | `DB::table('relatorios_estatisticos_ouvidoria')` + `orderBy('ano','desc')` |
| Número da linha (`value`) do DataTable | **PHP** | `foreach` no controller |

---

## 3. Armadilhas reais

### 3.1 SQL montado por concatenação (`whereRaw`)

`v5/app/Http/Model/Prefecture/OmbudsmanRequest.php:100-120` e `143-168`:

```php
->whereRaw('EXTRACT(DAY FROM now() - '.$values['fieldExtract'].') '.$values['comparation'].' 20')
```

```php
->whereRaw('EXTRACT(DAY FROM ombudsman_answers.'.$values['fieldExtract'].' - ombudsman_requests.'.$values['fieldExtract'].') '.$values['comparation'].' 20')
```

Os fragmentos interpolados vêm de `checkParameters()` (valores fixos: `created_at`,
`updated_at`, `>`, `<=`) — **não há interpolação de entrada do usuário**, mas a montagem é
fragil e dependente de PostgreSQL (`EXTRACT`).

Armadilha real: **`now()` é usado em vez da data de referência do pedido**, logo
"dentro do prazo" é reavaliado a cada acesso à página — o resultado muda com o tempo para
solicitações antigas em tramitação.

### 3.2 Carga total em memória

```php
foreach (OmbudsmanRequest::orderBy('created_at', 'desc')->get()->pluck('created_at') as $data) {
    $datas[] = date("Y", strtotime($data));
}
```

Carrega **todas** as solicitações da Ouvidoria (sem filtro de ano e sem paginação) só para
montar a lista de anos do seletor. Em prefeituras com histórico grande, é o principal custo
da página. A mesma collection (`$ombudsmanRequests`) é renderizada linha a linha na
listagem completa.

### 3.3 `ano` é texto

- Coluna `varchar(191)`; `orderBy('ano','desc')` faz ordem lexicográfica
  (`'998'` > `'2025'`), o que quebra a ordenação cronológica em séries com 3 dígitos.
- A validação aceita `required|string|max:10` — não há validação de que seja um ano
  válido; o JS limita a 4 dígitos, mas o servidor não.
- O filtro do portal compara `ano` (texto) com `$request->ano` (texto) — coerente, mas
  frágil se um dia o tipo mudar.

### 3.4 Soft delete inconsistente

`OmbudsmanRequest` **não** usa o trait `SoftDeletes`, embora a tabela tenha `deleted_at`.
Resultado:

| Consulta | Filtra `deleted_at`? |
|---|---|
| Listagem completa (`with('status')->byDateBetweenMain(...)`) | **Não** |
| Contadores de prazo (scopes) | **Não** |
| `$porTipo`, `$porAssunto`, `$porGenero` (`DB::table` + `whereNull('deleted_at')`) | **Sim** |

Ou seja: um pedido removido logicamente **aparece na listagem e nos cartões**, mas **some dos
gráficos**. Inconsistência observável entre os blocos da mesma página.

### 3.5 Aggregate com `leftJoin` e `groupBy` em coluna possivelmente nula

`$porTipo` e `$porAssunto` usam `leftJoin` + `groupBy('t.title')`. Solicitações cujo tipo
ou assunto foi removido (`deleted_at` nas tabelas de dimensão, mas sem `whereNull` no join)
entram com `title = null` e viram uma fatia "null" no gráfico. O chart JS só substitui o
rótulo por "Não informado" quando `row.title` é falsy — mas o valor **entra no grupo antes**,
criando uma fatia separada das demais "Não informado".

### 3.6 "Limpar filtro" aponta para a página errada

Na view do portal (todos os modelos):

```blade
<a href="{{ route('relatorio-estatistico') }}" class="btn btn-sm bg-color-3 txt-color-dinamic-3">
    Limpar filtro
```

`relatorio-estatistico` é a rota do relatório de **LAI** (Transparência Fiscal), não da
Ouvidoria. O botão leva o cidadão para outra página do portal. A v6 repete o mesmo link.

### 3.7 `porAssunto` calculado e nunca exibido

A query é executada no controller e o resultado é serializado no Blade
(`@json($porAssunto)`), mas não existe `#chart_assunto` em nenhuma das 12 views do portal.
Custo de query sem retorno visual.

### 3.8 `flash()` e `redirect()->back()` em `catch (\Exception)`

O controller do CMS captura `Exception` genérica e mostra `$e->getMessage()` ao operador.
Mensagens de banco (constraint, conexão) podem vazar detalhes internos. `redirect()->back()`
sem `withInput()` perde os campos digitados.

### 3.9 Sem trilha de auditoria

O model `RelatorioEstatisticoOuvidoria` **não** usa `LogsActivity`
(`spatie/laravel-activitylog`), ao contrário de outros models do projeto
(`v5/app/Http/Model/Prefecture/Status.php`). Quem cadastrou, alterou ou excluiu um
relatório não fica registrado.

### 3.10 `authorize()` retorna `true`

`RelatorioEstatisticoOuvidoriaRequest::authorize()` não verifica papel/permissão. A
autorização real depende de:
- `check.logged` — presente **apenas** nas 3 rotas GET do CMS;
- ausência de `check.route.permission` nas rotas de escrita;
- botões condicionais por `has_access()` na view.

### 3.11 Substituição de arquivo no upload

`saveUpload($file, 'docs', null, null, false)` é chamado com `$fileOld = null`, então o
**PDF anterior não é removido do storage** quando um novo arquivo é enviado na edição. O
registro passa a apontar para o novo arquivo e o anterior fica órfão no disco.

### 3.12 Dependência do template ativo

`getTemplateActived()` (`v5/app/Http/Traits/GetInformationsPrefectureTrait.php:92-102`)
lê `configurations.layout` e exige que exista `portal.layouts.{layout}.home.index`. A view
do relatório existe apenas para `modelo1..modelo6` — com `layout = default` a rota do
portal quebra com erro de view não encontrada. O mesmo vale para a v6.

### 3.13 Importação morta no controller do portal

`v5/app/Http/Controllers/Portal/OmbudsmanController.php:29-32` importa
`PhpOffice\PhpSpreadsheet\Calculation\Category`, `PharIo\Manifest\Manifest` e
`App\Http\Model\Prefecture\RelatorioEstatisticoOuvidoria` — as duas primeiras não são
usadas no módulo.

---

## 4. Acoplamentos

| Módulo | Relação |
|---|---|
| **Ouvidoria** (chamados) | Fornece **todos** os dados do relatório público (`ombudsman_requests`, `ombudsman_answers`, tipos, assuntos, status). O relatório depende integralmente dele. |
| **Transparência Fiscal / LAI** | Compartilha componentes de view: `portal/components/relatorio-estatistico/table.blade.php` e `portal/components/relatorio-estatistico-export-button.blade.php` (o segundo é genérico e parametrizado por `exportRoute`). Alterações na página de LAI quebram a de Ouvidoria. |
| **Páginas estáticas / Archives** | Fornece o título, o texto, o contador de cliques e os anexos da página pública. |
| **Configurações** | `configurations.layout` (template) e `configurations.full_list_requests_disabled` (listagem completa). |
| **CMS/AdminLTE + Sentinel** | Layout do CMS, `has_access()`, `check.logged`. |
| **Módulos irmãos de PDF (obras, concursos, terceirizados, estagiários, licitantes sancionados)** | Compartilham o mesmo padrão de código (controller + `FileTrait::saveUpload(..., 'docs', ...)` + `datatable.js` em `assets/js/custom/config/`), o que implica **código duplicado** a ser replicado na reconstrução. |

---

## 5. Código duplicado / copiado

| Trecho | Onde se repete |
|---|---|
| `saveUpload($file, 'docs', null, null, false)` + `arquivo = "docs/{$path}"` | `RelatorioEstatisticoOuvidoriaController`, `ConcursoPssController`, `EstagiarioController`, `LicitanteSancionadoController`, `ObraParalisadaController` |
| `datatable.js` com `createBTN(id, url, edit, delete, del_url)` | `assets/js/custom/config/{relatorio-estatistico-ouvidoria,obras-paralisadas,concursos-pss,estagiarios,licitantes-sancionados,terceirizados}/datatable.js` |
| Página do portal (6 modelos) | `portal/layouts/modelo1..modelo6/transparencia-fiscal/relatorio-estatistico-ouvidoria/{index,js}.blade.php` — cópias com diferenças de indentação e de markup do DataTable |
| Exportação TXT com colunas Status/Protocolo/Solicitante/Data | `OmbudsmanController::exportOmbudsmanRequestsTxt` (Ouvidoria) e `TransparencyController` (LAI) |

---

## 6. Onde a v6 divergiu (detalhamento)

A v6 **mantém** a rota pública `/relatorio-estatistico-ouvidoria`
(`v6/routes/web.php:23` → `Portal\OmbudsmanController@relStatitics`) e as 12 views de
portal (`modelo1..modelo6`, `index.blade.php` + `js.blade.php`), mas:

| Aspecto | v5 | v6 |
|---|---|---|
| Exportação | rota dedicada `/relatorio-estatistico-ouvidoria/exportacao` (`v5/routes/web.php:33`) | parâmetro `?type=` na própria rota (`v6/app/Http/Controllers/Portal/OmbudsmanController.php:275-277` e `356-370`) |
| Cartões de resumo e gráficos por tipo/gênero | presentes (`stats.blade.php`, `charts-js.blade.php`) | **ausentes** — os componentes não existem na v6 |
| Tabela de PDFs por ano (`relatorio-estatistico/table.blade.php`) | presente | **ausente** |
| Componente de botões de exportação (`relatorio-estatistico-export-button.blade.php`) | presente | **ausente** (substituído por `exportarRelatorio()` dentro do `js.blade.php`) |
| Resposta do modal | `data.request.protocol` (protocolo em claro) | `data.request.protocol` + `mask_answer` no lugar de `answer` (`v6/app/Http/Controllers/Portal/OmbudsmanController.php:344-352`) |
| Retorno de "protocolo não encontrado" | HTTP **404** (`v5/app/Http/Controllers/Portal/OmbudsmanController.php:279`) | HTTP **200** (`v6/app/Http/Controllers/Portal/OmbudsmanController.php:250`) |

O detalhe completo da perda está em `08-GAPS-E-DIVIDAS.md`.
