# Relatório Estatístico da Ouvidoria — Migração para Laravel Moderno

> Plano de reconstrução (PHP 8.x + Laravel 11/12) da funcionalidade que **existe apenas na v5**.
> O objetivo é recuperar **as duas faces**: o cadastro de relatórios PDF no CMS e o painel
> público de estatísticas da Ouvidoria.

---

## 1. Esquema de migração Laravel

```php
// database/migrations/2026_XX_XX_create_relatorios_estatisticos_ouvidoria_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('relatorios_estatisticos_ouvidoria', function (Blueprint $table) {
            $table->id();                        // bigint, auto-increment
            $table->unsignedSmallInteger('ano')->nullable();  // ano real (2..4 dígitos)
            $table->string('descricao')->nullable();
            $table->string('arquivo')->nullable();             // caminho relativo em storage
            $table->timestamps();
            $table->softDeletes();

            $table->index('ano');
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('relatorios_estatisticos_ouvidoria');
    }
};
```

Decisões de equivalência:

| Tema | v5 | v7 (proposto) | Por quê |
|---|---|---|---|
| Tipo de `ano` | `varchar(191)` | `unsignedSmallInteger` | ordenação cronológica correta; a coluna varchar produz ordem lexicográfica |
| `descricao` | `text` | `string` (255) | o Request já limita a 255 caracteres |
| `arquivo` | `docs/<nome>` | `disk: 'public'` + `path` relativo, mesma convenção `docs/` | mantém os links `/storage/docs/...` |
| Migração de dados | — | converter `varchar` para inteiro **somente** para valores de 4 dígitos; valores fora desse padrão são normalizados e auditados | a v5 aceita 10 caracteres; há risco de dado inválido |

---

## 2. Modelos

```php
// app/Models/RelatorioEstatisticoOuvidoria.php
namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Facades\Storage;

class RelatorioEstatisticoOuvidoria extends Model
{
    use SoftDeletes, HasFactory;

    protected $fillable = ['ano', 'descricao', 'arquivo'];

    protected function casts(): array
    {
        return [
            'ano' => 'integer',
            'arquivo' => 'string',
        ];
    }

    public function getArquivoUrlAttribute(): ?string
    {
        return $this->arquivo ? Storage::disk('public')->url($this->arquivo) : null;
    }
}
```

Enum sugerido (Laravel 11/12, `enum` nativo):

```php
enum StatusManifestacao: int
{
    case EmTramitacao = 1;
    case Atendido = 2;
    case Indeferido = 3;
    case Prorrogado = 4;
}
```

> O model `OmbudsmanRequest` da v7 deve **passar a usar `SoftDeletes`**, para eliminar a
> inconsistência descrita em `05-REGRAS-E-PORTABILIDADE.md` (seção 3.4). A coluna
> `deleted_at` já existe na tabela da v5, portanto não há perda de informação ao ativar o trait.

---

## 3. Services (novo)

Na v5, o controller do portal mistura carregamento, agregação e apresentação. Extrair:

```php
// app/Services/Ouvidoria/RelatorioEstatisticoService.php
final class RelatorioEstatisticoService
{
    public function __construct(private readonly RelatorioEstatisticoRepository $repository) {}

    /** @return array<int, int> contadores por status e situação de prazo */
    public function contadoresPorPrazo(?int $ano): array;

    /** @return Collection<int, array{label: string, total: int}> */
    public function porTipo(?int $ano): Collection;

    public function porAssunto(?int $ano): Collection;

    public function porGenero(?int $ano): Collection;

    /** @return array<int, int> anos disponíveis */
    public function anosDisponiveis(): array;
}
```

Regras que o service deve preservar exatamente:

- prazo de **20 dias**;
- status 2 e 3 contados apenas com **resposta registrada**;
- normalização de gênero: `['Masculino', 'Feminino', 'Prefiro não informar']`, o resto
  vira "Não informado" **antes** de agrupar;
- `full_list_requests_disabled` desliga a listagem completa.

Substituir o `whereRaw` por consultas parametrizadas:

```php
DB::table('ombudsman_answers')
    ->join('ombudsman_requests', 'ombudsman_answers.ombudsman_request_id', '=', 'ombudsman_requests.id')
    ->where('ombudsman_requests.status_id', $statusId)
    ->whereRaw('EXTRACT(DAY FROM ombudsman_answers.created_at - ombudsman_requests.created_at) <= ?', [20])
    ->when($ano, fn ($q) => $q->whereBetween('ombudsman_requests.created_at', [
        Carbon::create($ano)->startOfYear(),
        Carbon::create($ano)->endOfYear(),
    ]))
    ->count();
```

Alternativa portável (recomendada): calcular o prazo em PHP ou usar uma coluna
`prazo_dias` materializada, evitando `EXTRACT` (função PostgreSQL-only).

---

## 4. Form Requests

```php
// app/Http/Requests/StoreRelatorioEstatisticoOuvidoriaRequest.php
class StoreRelatorioEstatisticoOuvidoriaRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('create', RelatorioEstatisticoOuvidoria::class);
    }

    public function rules(): array
    {
        return [
            'ano'       => ['required', 'integer', 'between:1990', 'max:2100'],
            'descricao' => ['required', 'string', 'max:255'],
            'arquivo'   => ['nullable', 'file', 'mimes:pdf', 'max:20480'],
        ];
    }
}
```

Melhorias sobre a v5: `authorize()` deixa de ser `true`; `ano` passa a ser inteiro com
faixa; um `UpdateRelatorioEstatisticoOuvidoriaRequest` separado pode preservar o arquivo
anterior explicitamente (a v5 mantém o anterior por omissão).

---

## 5. Policies

```php
class RelatorioEstatisticoOuvidoriaPolicy
{
    public function viewAny(User $user): bool   { return $user->can('relatorio-estatistico-ouvidoria.index'); }
    public function create(User $user): bool    { return $user->can('relatorio-estatistico-ouvidoria.create'); }
    public function update(User $user, RelatorioEstatisticoOuvidoria $r): bool { return $user->can('relatorio-estatistico-ouvidoria.edit'); }
    public function delete(User $user, RelatorioEstatisticoOuvidoria $r): bool { return $user->can('relatorio-estatistico-ouvidoria.delete'); }
}
```

Registrar a policy no `AuthServiceProvider` e usá-la em `@can` nas views (substituindo
`has_access()`), com middleware `can:` nas rotas de escrita — o que a v5 **não** faz.

---

## 6. Endpoints REST/JSON sugeridos

### 6.1 CMS (autenticado)

| Método | URI | Nome | Descrição |
|---|---|---|---|
| GET | `/cms/relatorios-estatisticos-ouvidoria` | `relatorio-estatistico-ouvidoria-cms.index` | listagem (DataTable server-side) |
| GET | `/cms/relatorios-estatisticos-ouvidoria/criar` | `...create` | formulário |
| POST | `/cms/relatorios-estatisticos-ouvidoria` | `...store` | criação |
| GET | `/cms/relatorios-estatisticos-ouvidoria/{relatorio}/editar` | `...edit` | formulário de edição |
| PUT | `/cms/relatorios-estatisticos-ouvidoria/{relatorio}` | `...update` | atualização |
| DELETE | `/cms/relatorios-estatisticos-ouvidoria/{relatorio}` | `...destroy` | exclusão (soft) |
| GET | `/cms/datatable/relatorios-estatisticos-ouvidoria` | `datatable.relatorios` | dados do DataTable (Yajra) |

### 6.2 Portal (público)

| Método | URI | Nome | Descrição |
|---|---|---|---|
| GET | `/relatorio-estatistico-ouvidoria` | `relatorio-estatistico-ouvidoria` | página (Blade) |
| GET | `/relatorio-estatistico-ouvidoria/exportacao` | `relatorio-estatistico-ouvidoria.exportacao` | download (xlsx/csv/pdf/txt) |
| GET | `/api/relatorio-estatistico-ouvidoria/resumo` | `api.relatorio-estatistico-ouvidoria.resumo` | JSON dos cartões e séries dos gráficos |
| GET | `/api/relatorio-estatistico-ouvidoria/series` | `api.relatorio-estatistico-ouvidoria.series` | JSON com `porTipo`, `porAssunto`, `porGenero` |

A separação em endpoints JSON elimina a serialização `@json(...)` dentro do Blade e
permite cachear (HTTP cache / `Cache::remember`) os agregados por ano.

---

## 7. API Resources

```php
class RelatorioEstatisticoResumoResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'ano'         => $this->ano,
            'recebidos'   => $this->recebidos,
            'atendidos'   => $this->atendidos,
            'indeferidos' => $this->indeferidos,
            'por_status'  => $this->porStatus,
            'por_tipo'    => $this->porTipo,
            'por_assunto' => $this->porAssunto,
            'por_genero'  => $this->porGenero,
        ];
    }
}
```

A resposta do Ajax do portal deve devolver **sempre** o protocolo **mascarado**
(`masked_protocol`), coerente com a listagem pública.

---

## 8. Views Blade modernas

| Legado (v5) | Proposto (v7) |
|---|---|
| 6 cópias de `index.blade.php` (modelo1..modelo6) | **1 view** (`resources/views/portal/relatorio-estatistico-ouvidoria/index.blade.php`) que estende o layout ativo, resolvido por `View::composer` |
| 6 cópias de `js.blade.php` | **1 partial** `resources/views/portal/relatorio-estatistico-ouvidoria/_scripts.blade.php` |
| `portal/components/relatorio-estatistico-ouvidoria/stats.blade.php` | `<x-portal.report-cards />` + `<x-portal.pie-chart id="chart_tipo" :items="$porTipo" />` |
| `portal/components/relatorio-estatistico-ouvidoria/charts-js.blade.php` | componente Blade que consome o endpoint JSON |
| `portal/components/relatorio-estatistico/table.blade.php` (compartilhado com LAI) | `<x-portal.report-table :reports="$relatorios" />` (genérico, compartilhado) |
| `portal/components/relatorio-estatistico-export-button.blade.php` | `<x-portal.export-buttons :route="route('relatorio-estatistico-ouvidoria.exportacao')" />` |
| Modal com HTML montado por string em JS | `<x-portal.ombudsman-request-modal />` alimentado por fetch ao endpoint JSON |
| Google Charts via CDN | **Chart.js** (bundle local) ou Google Charts com fallback; em qualquer caso, os dados passam a vir do endpoint JSON |

Componente de view sugerido para o CMS:

```blade
<x-admin.table id="tabela-relatorios" :url="route('datatable.relatorios')" :columns="[...]" />
```

---

## 9. Fila, cache e storage

| Aspecto | Proposição |
|---|---|
| Storage de PDF | disco `public` (ou S3/CDN), `Storage::putFileAs('docs', $file, $name)`, mantendo o prefixo `docs/` |
| Link público | `Storage::disk('public')->url($arquivo)`; em produção, CDN |
| Cache | `Cache::remember("ouvidoria:resumo:{$ano}", 300, fn () => $service->resumo($ano))` — os agregados mudam pouco e a página é de alto volume |
| Fila | enfileirar a **geração de estatísticas pesadas** (relatórios anuais em PDF) — não há fila na v5 |
| Jobs | `GenerateAnnualOmbudsmanReport` (agrega o ano e gera PDF) para o relatório oficial |
| Logs | adicionar `LogsActivity` ao model, para não perder a auditoria que a v5 não tem |

---

## 10. Riscos e decisões de equivalência funcional

Decisões que **não podem mudar** (risco jurídico de transparência):

1. **Prazo de 20 dias** e a divisão dentro/fora do prazo.
2. **Máscara de protocolo e nome** em toda listagem e exportação pública.
3. **Anonimato** preservado ("Anônimo" na listagem).
4. A listagem completa **continua escondível** por configuração (`full_list_requests_disabled`).
5. O texto legal (Art. 30, III, da LAI) exibido na página.
6. O download público do PDF do relatório anual cadastrado no CMS.
7. Formatos de exportação: XLSX, CSV, PDF e TXT.

Riscos:

| Risco | Mitigação |
|---|---|
| Migração de `ano` texto -> inteiro com dados inconsistentes | relatório de auditoria antes da migração; manter valor original em coluna de backup |
| Replicação do SQL `EXTRACT(DAY FROM ...)` com semântica de data diferente | preferir cálculo em PHP ou coluna materializada; testes de fronteira (20 e 21 dias) |
| Regressão de volume: `OmbudsmanRequest::get()` sem filtro | paginar a listagem e carregar apenas os anos necessários |
| Dependência do Google Charts CDN | Charts locais |
| Quebra de URLs antigas (`/relatorio-estatistico-ouvidoria/exportacao`) | manter a rota; a v6 usa `?type=` na própria rota — padronizar e redirecionar |
| Roteamento por `configurations.layout` quebrando com templates novos | resolver o layout por view factory/composer, com fallback explícito |

---

## 11. Frentes de trabalho

| Frente | Itens |
|---|---|
| Esquema + model + repository | 1 migration, 1 model, 1 repository, 1 factory |
| CMS | 1 controller, 2 form requests, 1 policy, 4 views, 1 componente de tabela, 1 JS |
| Portal | 1 service, 2 controllers (página + exportação), 2 API resources, 3 views/componentes, 1 JS |
| Testes | 8 a 12 testes de feature + 4 unitários do service (ver `07-TESTES.md`) |
| Migração de dados | script de auditoria/conversão de `ano` |
