# Revisões — Migração para Laravel Moderno (PHP 8.x / Laravel 11-12)

> Princípio: **equivalência funcional**. O módulo não é público; o que não pode mudar é a
> semântica da avaliação (uma revisão ativa, pontuação por "Sim", protocolo, PDF e
> destinatários).

---

## 1. Schema de migração

### 1.1 Tabelas (equivalente Laravel 11/12)

```php
// database/migrations/2024_01_01_000100_create_revisoes_tables.php
Schema::create('ratings', function (Blueprint $table) {
    $table->id();
    $table->string('title');
    $table->string('subtitle');
    $table->text('description');
    $table->string('slug')->unique();
    $table->boolean('status')->default(false);
    $table->timestamps();
    $table->softDeletes();
});

Schema::create('question_types', function (Blueprint $table) {
    $table->id();
    $table->string('title', 80);
    $table->timestamps();
    $table->softDeletes();
});

Schema::create('question_categories', function (Blueprint $table) {
    $table->id();
    $table->foreignId('question_categories_id')->nullable()
          ->constrained('question_categories')->nullOnDelete();
    $table->foreignId('ratings_id')->nullable()
          ->constrained('ratings')->nullOnDelete();
    $table->string('title');
    $table->unsignedInteger('order')->nullable();
    $table->timestamps();
    $table->softDeletes();
});

Schema::create('option_categories', function (Blueprint $table) {
    $table->id();
    $table->text('title');
    $table->timestamps();
    $table->softDeletes();
});

Schema::create('options', function (Blueprint $table) {
    $table->id();
    $table->text('description');
    $table->text('value')->nullable();
    $table->timestamps();
    $table->softDeletes();
});

Schema::create('option_category_has_options', function (Blueprint $table) {
    $table->id();
    $table->foreignId('option_categories_id')->constrained()->cascadeOnDelete();
    $table->foreignId('options_id')->constrained()->cascadeOnDelete();
    $table->unsignedInteger('order')->nullable();
    $table->timestamps();
});

Schema::create('questions', function (Blueprint $table) {
    $table->id();
    $table->text('description');
    $table->double('value')->nullable();          // peso
    $table->unsignedInteger('order')->nullable();
    $table->foreignId('ratings_id')->constrained()->cascadeOnDelete();
    $table->foreignId('question_types_id')->constrained()->restrictOnDelete();
    $table->foreignId('question_categories_id')->constrained()->cascadeOnDelete();
    $table->foreignId('options_categories_id')->constrained()->restrictOnDelete();
    $table->timestamps();
    $table->softDeletes();
});

Schema::create('send_ratings', function (Blueprint $table) {
    $table->id();
    $table->foreignId('ratings_id')->constrained()->cascadeOnDelete();
    $table->text('revisor');
    $table->dateTime('data');
    $table->boolean('status')->default(false);
    $table->unsignedBigInteger('protocol');
    $table->foreignId('send_id')->nullable()
          ->constrained('send_ratings')->nullOnDelete();   // retificação
    $table->double('pts_totais')->nullable();
    $table->double('pts_finais')->nullable();
    $table->text('path')->nullable();
    $table->timestamps();
    $table->softDeletes();
});

Schema::create('answer_forms', function (Blueprint $table) {
    $table->id();
    $table->foreignId('ratings_id')->constrained()->cascadeOnDelete();
    $table->foreignId('questions_id')->constrained()->cascadeOnDelete();
    $table->foreignId('send_id')->constrained()->cascadeOnDelete();
    $table->foreignId('option_id')->nullable()->constrained()->nullOnDelete();
    $table->text('observation')->nullable();
    $table->timestamps();
    $table->softDeletes();
});

Schema::create('email_revisoes', function (Blueprint $table) {
    $table->id();
    $table->text('title');
    $table->foreignId('ratings_id')->constrained()->cascadeOnDelete();
    $table->timestamps();
    $table->softDeletes();
});
```

### 1.2 Decisões de schema

| Decisão | Motivo |
|---|---|
| **Remover `ratings.version`** | Coluna morta (nunca gravada, fora do `$fillable`). Se a área de negócio quiser versionar o questionário, criar `rating_versions` com data e autor — não um `varchar` órfão |
| `ratings.slug` único | Evita duas avaliações com o mesmo slug (o `slug` é gerado por `slug_fix($title)`) |
| `question_categories.ratings_id` com FK real | Na v6 a coluna existe sem constraint |
| `protocol` como `unsignedBigInteger` | Na v6 é `integer`; o formato "AAAAMM" + rand cabe, mas o número pode passar de 2^31 |
| `send_ratings.data` **não** deve ser sobrescrito no envio do e-mail | Addicionar `sent_at` para o instante do envio, preservando `data` = data da avaliação |
| `options.value` Decimal ou `decimal:2` | today é `text`; o valor é ignorado pelo cálculo, mas é útil para relatórios futuros |
| `option_category_has_options` **sem** `SoftDeletes` | Igual à v6 |
| **Views SQL → queries Eloquent** | As views `formulario`, `envios`, `respostas` e `listagem_clientes` existem para a paginação server-side do Yajra; em Laravel moderno use `spatie/laravel-query-builder` + paginação simples, ou queries Eloquent com `withCount`/`selectRaw` equivalents |

### 1.3 Enums

```php
enum QuestionType: string {
    case SingleChoice = 'Resposta única';
    case MultipleChoice = 'Múltipla escolha';
}

enum ResponseOption: string {          // descrição canônica da alternativa
    case Yes = 'Sim';
    case No = 'Não';
    case Partial = 'Parcial';
}

enum SendStatus: bool {               // substitui o booleano solto
    case Pending = false;
    case Sent = true;
}
```

> O `ResponseOption` deve ser a **única** fonte de verdade da pontuação: o cálculo passa a
> comparar com o enum, e não com a string `'Sim'` literais (ver risco A2 em `05-REGRAS-E-PORTABILIDADE.md`).

## 2. Models

```php
final class Rating extends Model
{
    use HasFactory, SoftDeletes;

    protected $fillable = ['title', 'subtitle', 'description', 'slug', 'status'];
    protected function casts(): array {
        return ['status' => 'boolean'];
    }

    public function questions(): HasMany { return $this->hasMany(Question::class); }
    public function sends(): HasMany { return $this->hasMany(SendRating::class); }
    public function categories(): HasMany { return $this->hasMany(QuestionCategory::class); }
    public function emails(): HasMany { return $this->hasMany(EmailRevisao::class); }
}
```

| Model | Casts sugeridos | Relações |
|---|---|---|
| `Rating` | `status` → bool | `questions`, `sends`, `categories`, `emails` |
| `Question` | `value` → float, `order` → int | `rating`, `type`, `category`, `optionCategory` |
| `QuestionCategory` | `order` → int | `parent`, `children`, `rating` |
| `OptionCategory` | — | `options` (belongsToMany, com pivot `order`) |
| `Option` | — | `optionCategory` (belongsToMany) |
| `SendRating` | `status` → bool, `data` → datetime, `pts_finais`/`pts_totais` → float | `rating`, `parent` (send), `children` (retificações), `answers` |
| `AnswerForm` | — | `send`, `question`, `option` |
| `EmailRevisao` | — | `rating` |

Correção obrigatória: **namespace único** (`App\Models`), eliminando a divergência
`App\Http\Model\Prefecture` × `App\Prefecture` (risco A2).

## 3. Services

Extrair a lógica hoje duplicada:

```php
final class ScoreService
{
    public function __construct(private readonly ScorePolicy $policy = new ResponseOptionPolicy()) {}

    public function forSend(SendRating $send): Score
    {
        $questions = Question::where('ratings_id', $send->ratings_id)->get(['id', 'value']);
        $answers  = AnswerForm::with('option')->where('send_id', $send->id)->get();
        // mesma semântica: só "Sim" soma
    }
}
```

| Service | Responsabilidade |
|---|---|
| `ScoreService` | Calcula `pts_finais` / `pts_totais` e o percentual (com guarda de divisão por zero) |
| `ActiveRatingService` | Garante a unicidade da avaliação ativa (uma transação, um UPDATE) |
| `RatingTemplateService` | Export/import do questionário em JSON (versionar o formato: chave `format` = `revisoes/v1`) |
| `ReviewReportService` | Monta os dados dos gráficos (por categoria e evolução mensal) |
| `ReviewPdfService` | Gera o PDF (Dompdf ou PDFKHR) e grava em `storage/app/public/content/revisoes/{id}/pdf/revisoes_{data}.pdf` |

## 4. Form Requests

| Request | Regras |
|---|---|
| `SaveRatingRequest` | `title` required max:191, `subtitle` required max:191, `description` required, `status` boolean |
| `SaveQuestionRequest` | `description` required, `value` numeric min:0, `question_types_id` exists, `question_categories_id` exists, `options_categories_id` exists, `ratings_id` exists |
| `SaveQuestionCategoryRequest` | `title` required, `question_categories_id` nullable exists, `ratings_id` nullable exists |
| `SaveOptionRequest` | `description` required, `value` nullable, `option_categories_id` exists |
| `SubmitReviewRequest` | `dataDeEnvio` required date_format:d/m/Y H:i, `revisor` required, `ratings_id` exists, `resposta` required array, `resposta.*` required |
| `SendReviewEmailRequest` | `emails` required array, `emails.*` required email, `send_id` exists |

Hoje apenas `RatingRequest` e `RevisoesRepostas` validam algo; as mensagens de
`RevisoesRepostas` estão trocadas e devem ser reescritas.

## 5. Autorização

```php
final class RatingPolicy
{
    public function viewAny(User $user): bool { return $user->can('revisoes.index'); }
    public function create(User $user): bool { return $user->can('revisoes.create'); }
    public function update(User $user, Rating $rating): bool { return $user->can('revisoes.edit'); }
    public function delete(User $user, Rating $rating): bool { return $user->can('revisoes.delete'); }
    public function restore(User $user, Rating $rating): bool { return $user->can('revisoes.restore'); }
    public function apply(User $user): bool { return $user->can('revisoes.edit'); }
}
```

Substitui o `check.route.permission` (comparação por nome de rota) e o `has_access()` nas
views. O super-admin continua com acesso total (regra global, fora do módulo).

## 6. Endpoints REST/JSON sugeridos

Prefixo: `/api/v1/revisoes` (interno ao CMS; opcionalmente com `auth:sanctum`).

| Método | URI | Descrição |
|---|---|---|
| GET | `/ratings` | lista paginada (filtros: `status`, `q`) |
| POST | `/ratings` | cria avaliação |
| GET | `/ratings/{rating}` | detalhe com categorias e perguntas |
| PUT | `/ratings/{rating}` | atualiza |
| DELETE | `/ratings/{rating}` | soft delete |
| POST | `/ratings/{rating}/activate` | ativa (desativando as demais) — idempotente |
| GET | `/ratings/{rating}/template` | exporta o JSON do questionário |
| POST | `/ratings/import` | importa o JSON |
| POST | `/ratings/{rating}/copy` | copia a avaliação |
| GET | `/ratings/{rating}/questions` | perguntas da avaliação |
| POST | `/questions` | cria pergunta |
| PUT | `/questions/{question}` | atualiza |
| DELETE | `/questions/{question}` | remove |
| POST | `/questions/reorder` | reordena (payload com a lista de ids) |
| GET | `/ratings/{rating}/sends` | envios da avaliação |
| POST | `/sends` | registra a avaliação (cálculo de score) |
| GET | `/sends/{send}` | detalhe do envio + respostas |
| GET | `/sends/{send}/pdf` | PDF |
| GET | `/sends/{send}/report` | dados dos gráficos |
| POST | `/sends/{send}/email` | envia o PDF aos destinatários |

## 7. API Resources

```php
final class RatingResource extends JsonResource {
    public function toArray(Request $request): array {
        return [
            'id' => $this->id,
            'title' => $this->title,
            'subtitle' => $this->subtitle,
            'description' => $this->description,
            'slug' => $this->slug,
            'status' => (bool) $this->status,
            'questions_count' => $this->whenCounted('questions'),
            'sends_count' => $this->whenCounted('sends'),
        ];
    }
}
```

`QuestionResource`, `SendRatingResource` (com `answers` aninhadas), `AnswerFormResource` e
`ReportResource` completam o conjunto. Hoje os endpoints devolvem o model cru — mudar isso
exige adaptations no JS (`revisoesv2.js`, `revisoes_envio.js`) ou uma camada de compatibilidade.

## 8. Views Blade modernas

| Tela | Legado | Moderno |
|---|---|---|
| Listagem de avaliações | `formularios/index.blade.php` + `formularios/datatable.js` | `<x-data-table>` com Livewire/Alpine ou tabela server-side |
| Editor de questionário | `revisoesv2.js` (791 linhas) + 5 modais | Componente `<x-question-editor>` com Alpine.js, drag & drop (`SortableJS`) e endpoints JSON |
| Formulário de resposta | `respostas/create.blade.php` + `respostas/form.blade.php` | `<x-review-form>` com validação via Livewire |
| Relatórios | `envios/send_estatistica.blade.php` (Chart.js) | Componente `<x-review-charts>` mantendo Chart.js (ou ApexCharts) |
| PDF | `pdf/index.blade.php` | Manter Blade + Dompdf/PDFKHR; **preservar o layout** (é documento de controle) |

Remover `Form::open()` (helper depreciado) por `<form method="POST" @csrf>`.

## 9. Fila, cache e storage

| Aspecto | Legado | Moderno |
|---|---|---|
| Geração de PDF | síncrona, dentro do POST da resposta | **Job na fila** (`GenerateReviewPdf`) + evento `ReviewSubmitted` |
| Envio de e-mail | síncrono, loop por destinatário | **Job** `SendReviewMail` por destinatário, com `ShouldQueue` |
| Cache | nenhum | cache da avaliação ativa (chave `revisoes.active`) e das perguntas por avaliação |
| Storage | `Storage::put("content/revisoes/{id}/pdf/revisoes_{data}.pdf")` | manter o mesmo prefixo para não quebrar links já distribuídos; `Storage::disk('public')` |
| Export JSON | arquivo temporário em `public/cache/` | `Storage::disk('local')->temporaryUrl()` ou download direto via `response()->streamDownload()` |
| Registro de atividade | `activity()->performedOn($this->ratingsRepository->getModel())->causedBy(Sentinel::getUser())->withProperty('Detalhe', 'As revisões com ids: ' . implode(",", $request->toDelete) . ' Foram Excluidas por: ' . Sentinel::getUser()->name)->log('Exclusão de revisões')` (`v6/app/Http/Controllers/Prefecture/RatingsController.php:51-54`) | `spatie/laravel-activitylog` (mantido) |

## 10. Riscos e decisões de equivalência (o que NÃO pode mudar)

| # | Não pode mudar | Por quê |
|---|---|---|
| R1 | Só **uma** avaliação ativa | O relatório "cliente" e a view `listagem_clientes` dependem disso |
| R2 | A pontuação reconhece **apenas "Sim"** | O PDF e os gráficos comparam com os totais já publicados; mudar a régua altera números históricos |
| R3 | `pts_totais` = soma dos pesos de **todas** as perguntas | Mesma razão |
| R4 | O **protocolo** é exibido e usado para retificação | É o identificador de controle do relatório |
| R5 | A retificação é um **novo envio** apontando para o anterior (`send_id`) | Mantém o histórico e a coluna "Retificado de" |
| R6 | O PDF tem o **mesmo layout** (cabeçalho comDados do município, gráficos por categoria, tabela de respostas) | É o documento distribuído ao avaliador |
| R7 | A `data` do envio representa a **data da avaliação** | Corrigir o bug de sobrescrita (migrar `sent_at` sem quebrar `data`) |
| R8 | A listagem do "cliente" mostra apenas a avaliação **ativa** | É o recorte institucional |
| R9 | O JSON de exportação mantém o formato atual (array com as perguntas na chave `"0"`) **ou** ganha versão com conversor | Arquivos já circulam entre municípios |
| R10 | Nenhuma rota pública | O módulo é administrativo por decisão de projeto |

## 11. Esforço x benefício

| Item | Esforço | Benefício |
|---|---|---|
| Recriar tabelas + models | Baixo | Base |
| Extrair `ScoreService` e remover duplicação | Baixo | Elimina a classe de bug mais séria (divisão por zero) |
| Matar as rotas mortas (`show`, `update`, `respostas/relatorios` sem métodos) | Baixo | Remove 500 em rotas alcançáveis |
| Form Requests completos | Médio | Elimina entrada inválida |
| Policy | Médio | Autorização explícita |
| Fila para PDF e e-mail | Médio | Timeout em avaliações grandes |
| Trocar as views SQL por queries | Médio | Remove dependência de DDL no deploy |
| Reescrever `revisoesv2.js` | Alto | Gain pequeno — o editor funciona; migrar por último |
