# Solicitações LAI (e-SIC) — Plano de Migração para Laravel Moderno

> Alvo: PHP 8.x + Laravel 11/12. Referência de comportamento: a v6
> (`v6/`). Este documento é um plano de reconstrução, **não** uma implementação.

---

## 1. Schema de migração Laravel

### 1.1 `informations_requests`

```php
// database/migrations/2026_01_01_000000_create_informations_requests_table.php
Schema::create('informations_requests', function (Blueprint $table) {
    $table->id();
    $table->text('request');                 // texto original do pedido
    $table->text('mask_request')->nullable();// versão mascarada (LGPD)
    $table->string('protocol', 32)->unique(); // protocolo único
    $table->string('ip', 45)->nullable();
    $table->string('browser', 191)->nullable();
    $table->foreignId('persons_id')->nullable()->constrained('persons')->nullOnDelete();
    $table->foreignId('status_id')->nullable()->constrained('statuses')->nullOnDelete();
    $table->timestampTz('due_at')->nullable();       // NOVO: prazo legal calculado
    $table->timestampTz('answered_at')->nullable();  // NOVO: data da resposta
    $table->timestampTz('extended_at')->nullable();  // NOVO: data da prorrogação
    $table->text('extension_reason')->nullable();    // NOVO: justificativa da prorrogação
    $table->timestampsTz();
    $table->softDeletesTz();
});
```

Mudanças em relação ao legado:

| Mudança | Justificativa |
|---|---|
| `protocol` com índice `unique` | O legado só valida no PHP e gera o valor no Blade. |
| `due_at`, `answered_at`, `extended_at`, `extension_reason` | O legado não guarda prazo nem justificativa; o "fora do prazo" é recalculado a cada consulta. |
| `timestampTz` | O legado usa `timestamp(0) without time zone`, o que gera ambiguidade de fuso. |
| FK com `nullOnDelete` | Evita impedir exclusão de titular. |

### 1.2 `answers` e `answer_archives`

```php
Schema::create('answers', function (Blueprint $table) {
    $table->id();
    $table->foreignId('informations_request_id')->constrained('informations_requests')->cascadeOnDelete();
    $table->text('answer');
    $table->foreignId('answered_by')->nullable()->constrained('users')->nullOnDelete(); // NOVO
    $table->timestampsTz();
    $table->softDeletesTz();
});

Schema::create('answer_archives', function (Blueprint $table) {
    $table->id();
    $table->foreignId('answer_id')->constrained('answers')->cascadeOnDelete();
    $table->foreignId('archive_id')->constrained('archives')->cascadeOnDelete();
    $table->timestampsTz();
    $table->unique(['answer_id', 'archive_id']);
});
```

O legado mantém `answers.archives_id` (coluna quase sempre nula) ao lado do
pivot `answer_archives`; na v7 a coluna redundante deve ser removida.

### 1.3 `statuses` — uso exclusivo pela LAI

Manter a tabela `statuses` compartilhada, mas garantir que os ids 1..4
pertençam à categoria "Solicitação de Informação" e expor um enum no código:

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

Alternativa mais limpa: tabela própria `lai_statuses` para não acoplar a LAI
ao vocabulário de Licitações (que usa os ids 5..7 na mesma tabela).

### 1.4 Views SQL

As 4 vistas (`em_tramitacao`, `atendidos`, `indeferidos`, `prorrogados`) devem
ser **removidas** na v7 e substituídas por queries Eloquent com `paginate()`.
Se alguma ferramenta externa depender delas, recriá-las como views geradas
por migration, com `masked_protocol`/`masked_name` calculados em SQL
(`regexp_replace`) ou expostos via view do Eloquent.

## 2. Modelos

### 2.1 `InformationsRequest`

```php
class InformationsRequest extends Model
{
    use HasFactory, SoftDeletes;

    protected $fillable = ['request', 'mask_request', 'protocol', 'ip', 'browser',
                           'persons_id', 'status_id', 'due_at'];

    protected function casts(): array
    {
        return [
            'status_id' => StatusLai::class,
            'due_at'    => 'datetime',
            'answered_at' => 'datetime',
            'extended_at' => 'datetime',
        ];
    }

    public function person(): BelongsTo     { return $this->belongsTo(Person::class); }
    public function status(): BelongsTo     { return $this->belongsTo(Status::class); }
    public function answers(): HasMany      { return $this->hasMany(Answer::class); }   // corrigido
    public function archives(): HasManyThrough {
        return $this->hasManyThrough(Archive::class, Answer::class);
    }
}
```

Correções obrigatórias em relação ao legado:

| Legado | v7 |
|---|---|
| `answers()` = `hasMany(Answer::class, 'id', 'answers_id')` (coluna inexistente) | `hasMany(Answer::class)` com FK `informations_request_id` |
| `status()`/`person()` como `hasOne` com argumentos trocados | `belongsTo` explícito |
| `getMaskedProtocolAttribute()` / `getMaskedNameAttribute()` como accessor legado | `Attribute`/`casts` ou um `MaskingService` injetado |
| `$presenter = ContentPresenter::class` (sem `getPresenter()`) | removido |
| `scopeGetByBetweenDate` com `groupBy` inválido | `scope` com `whereDate`/`whereBetween` reais |
| `checkParameters` | um `enum`/`Policy` de prazo |

### 2.2 Services

```php
interface LAIProtocolGenerator { public function generate(): string; }   // ULID/UUID ou YmdHis + sufixo
interface TextMasker { public function mask(string $text): string; }      // determinístico
interface TextMaskerAI { public function refine(string $text): string; }  // opcional, idempotente
```

O `TextMasker` determinístico (regex de CPF/CNPJ/RG/telefone/CEP/e-mail/
data/CID-10) deve ser extraído de `v6/app/Http/Traits/DataMaskTrait.php` e
testado isoladamente. O refinamento por IA vira um **job** (ver item 6).

## 3. Form Requests

```php
class StoreInformationRequest extends FormRequest
{
    public function rules(): array
    {
        $rules = [
            'request'       => ['required', 'string', 'min:20', 'max:4000'],
            'email_address' => ['required', 'email', 'max:191'],
            'phone_number'  => ['nullable', 'string', 'max:45'],
            'protocol'      => ['nullable', 'integer', Rule::unique('informations_requests')],
            'street_name'   => ['nullable', 'string', 'max:191'],
            'street_number' => ['nullable', 'string', 'max:10'],
            'zip_code'      => ['nullable', 'string', 'max:45'],
            'neighborhood'  => ['nullable', 'string', 'max:191'],
            'cities_id'     => ['required', 'exists:cities,id'],
            'state'         => ['required', 'exists:states,id'],
            'person_type'   => ['required', Rule::in(['pf', 'pj'])],
            'g-recaptcha-response' => ['required', 'captcha'],
        ];

        return $rules + match ($this->input('person_type')) {
            'pf' => [
                'cpf' => ['required', 'cpf'],
                'rg'  => ['nullable', 'string', 'max:20'],
                'name'=> ['required', 'string', 'max:191'],
            ],
            'pj' => [
                'cnpj'        => ['required', 'cnpj'],
                'social_name' => ['required', 'string', 'max:191'],
                'fantasy_name'=> ['nullable', 'string', 'max:191'],
            ],
            default => [],
        };
    }
}
```

Mudanças em relação ao legado:

- `person-type` vira `person_type` com valores `pf`/`pj` (hoje `0`/`1` como
  string, comparados com `==`).
- `protocol` deixa de vir do formulário: é gerado no servidor. Se o usuário
  quiser "consultar pelo número", o valor é devolvido na resposta.
- ReCAPTCHA obrigatório **nas duas** vias (no legado só na via não-ajax).
- `cpf`/`cnpj` usam regras nativas (`laravellegends/pt-br-validator` ou
  `Laravel\Validation\Rules\Password` equivalente); a v7 deve preferir
  `cpf`/`cnpj` de pacote auditado ou expressão regular própria, testada.
- A via ajax deixa de existir como duplicata: um único endpoint com
  `Accept: application/json` (ou um controller `StoreInformationRequestController`
  respondendo JSON).

```php
class StoreAnswerRequest extends FormRequest
{
    public function rules(): array
    {
        return [
            'information_request_id' => ['required', 'exists:informations_requests,id'],
            'type'   => ['required', 'integer', Rule::in([0, 1])],
            'answer' => ['required_if:type,1', 'nullable', 'string'],
            'rejected' => ['required_if:type,0', 'nullable', 'string'],
            'archive' => ['nullable', 'array', 'max:5'],
            'archive.*' => ['file', 'mimes:pdf,doc,docx,xls,xlsx,odt,ods,csv,png,jpg', 'max:20480'],
        ];
    }
}
```

O legado não valida **tipo nem tamanho** dos anexos enviados; a v7 deve.

## 4. Policies e autorização

```php
class InformationRequestPolicy
{
    public function viewAny(User $u): bool   { return $u->can('lai.index'); }
    public function view(User $u, InformationRequest $i): bool { return $u->can('lai.show'); }
    public function answer(User $u): bool     { return $u->can('lai.edit'); }
    public function defer(User $u): bool      { return $u->can('lai.edit'); }
    public function extend(User $u, InformationRequest $i): bool
    {
        return $u->can('lai.edit') && $i->status === StatusLai::EmTramitacao;
    }
}
```

Regras de equivalência funcional (não podem mudar):

- Quem pode **prorrogar** só pode hacerlo a partir de "Em Tramitação".
- Responder muda o status para 2 ou 3 **e** grava a resposta — é uma operação
  atômica.
- O anonimato é preservado: o nome mascarado (`masked_name`) é o que o portal
  mostra.

A v6 não tem policy: o `check.route.permission` só verifica a **rota**, e a
rota `info-request-status-update` está na lista `openRoutes` (qualquer
logado). A v7 deve fechar isso.

## 5. Endpoints REST/JSON sugeridos

### 5.1 Portal (público)

| Método | URI | Descrição |
|---|---|---|
| POST | `/api/v1/lai/solicitations` | Registra a solicitação; devolve `201` com `{protocol, due_at, status}` |
| GET | `/api/v1/lai/solicitations/{protocol}` | Consulta pública por protocolo; devolve **apenas** `mask_request`, protocolo mascarado, status e datas |
| GET | `/api/v1/lai/solicitations/{protocol}/answers` | Respostas (texto e links de arquivo) |
| GET | `/api/v1/lai/statistics?year=YYYY` | Indicadores de prazo (replacements de `getByBetweenDate`) |

Regras de segurança da API pública:

- **Nunca** devolver `request` (texto original), `ip`, `browser` ou os dados
  do titular. No legado `getInformationByProtocol` devolve o model inteiro.
- Devolver `mask_request` como o texto exibido, e o nome mascarado.
- Rate limit e reCAPTCHA obrigatório.

### 5.2 CMS

| Método | URI | Descrição |
|---|---|---|
| GET | `/api/v1/lai/solicitations` | Listagem paginada com filtros `status`, `year`, `from`, `to` |
| GET | `/api/v1/lai/solicitations/{id}` | Detalhe completo (somente operadores) |
| POST | `/api/v1/lai/solicitations/{id}/answer` | Resposta (`type=1`) + anexos |
| POST | `/api/v1/lai/solicitations/{id}/reject` | Indeferimento (`type=0`) + justificativa |
| POST | `/api/v1/lai/solicitations/{id}/extend` | Prorrogação (motivo obrigatório) |
| GET | `/api/v1/lai/statistics?year=YYYY` | Relatório estatístico |
| GET | `/api/v1/lai/solicitations/export?format=xls\|csv\|pdf\|txt` | Exportação |

Todas exigem `auth:sanctum` + `can:lai.*`.

## 6. API Resources

```php
class InformationRequestResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id'            => $this->id,
            'protocol'      => $this->masked_protocol,
            'status'        => $this->status->name,
            'status_id'     => $this->status_id->value,
            'request'       => $this->mask_request,   // NUNCA $this->request
            'created_at'    => $this->created_at?->toIso8601String(),
            'due_at'        => $this->due_at?->toIso8601String(),
            'answered_at'   => $this->answered_at?->toIso8601String(),
            'requester'     => ['name' => $this->masked_name],
            'answers'       => AnswerResource::collection($this->whenLoaded('answers')),
        ];
    }
}
```

`AnswerResource` deve expor `answer` (texto do operador — conteúdo público da
resposta), `created_at` e os arquivos com **URL assinada** ou rota autenticada
de download (o legado grava `path = url("storage/answer/{id}/{nome}")`, ou
seja, um caminho público e potencialmente enumerável).

## 7. Blade e componentes

Componentes reutilizáveis (a v6 repete o mesmo HTML em 4 a 7 templates):

```blade
<x-lai.solicitation-form :person-types="['pf','pj']" />
<x-lai.protocol-result :request="$request" />
<x-lai.answer-list :answers="$answers" />
<x-lai.status-badge :status="$status" />
<x-lai.statistics-chart :indicators="$indicators" />
```

Regras de equivalência:

- Manter os textos "Em até 20 (vinte) dias" **derivados de uma configuração**
  (`lai.deadline_days`), com o mesmo valor padrão 20 — o texto atual é fixo em
  3 lugares.
- Manter as 4 faixas de cor do status (#FDC02F, #30A64A, #DA3849, #25A2B7) e as
  cores do gráfico (#fab12e, #ed7104, #13dd17, #1c8419, #f72239, #bc3e4b,
  #1b75aa, #04598c).
- Manter os botões e destinos atuais da listagem (Responder, Prorrogar,
  Visualizar) e as exportações xls/pdf/csv/txt.
- Remover a dependência do `$status` interpolado no nome do asset: o JS deve
  receber a URL por `data-attribute` ou por `x-data`.

## 8. Fila, cache e storage

| Aspecto | Plano v7 |
|---|---|
| Mascaramento por IA | Job `MaskRequestText` na fila (`ShouldQueue`), com retry e backoff; o registro é gravado **antes** do mascaramento, e `mask_request` é preenchido pelo worker. Falha da IA não desfaz a solicitação. |
| E-mail | Job `SendLaiNotification` com `ShouldQueue`; as views de e-mail em Blade viram classes `Mailable` (`LaiRequestReceived`, `LaiAnswerSent`, `LaiRequestDeferred`, `LaiRequestRejected`). |
| Cache | `Cache::remember` para os agregados do relatório estatístico (chave `lai:stats:{year}`), invalidado quando um status muda. |
| Storage | Anexos em disco `local` (privado) com `Storage::disk('local')->put("lai/{id}/{nome}")`; download via rota autenticada com `Storage::download()`. O legado usa o disco `public` com URL aberta. |
| Sessão | As rotas de ajax do portal sob o prefixo `cms/ajax` passam a usar Sanctum com token público (visitor token) em vez de depender de cookie de sessão do CMS. |
| Eventos | `InformationRequestReceived`, `InformationRequestAnswered`, `InformationRequestDeferred`, `InformationRequestRejected` — desacoplando o módulo de e-mail e dashboard. |

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

| # | Risco | Decisão |
|---|---|---|
| 1 | **Protocolo**: mudar o formato quebra o cidadão que já tem o número anotado | Manter `YmdHis` (14 dígitos) no curto prazo; documentar a mudança e oferecer busca por protocolo antigo. Alternativa: `YmdHis` + sufixo aleatório de 4 dígitos, aceitando os dois formatos na consulta. |
| 2 | **Prazo de 20 dias** está fixo no SQL, nos textos e nos e-mails | Extrair para configuração com o mesmo valor padrão; comportamento observável idêntico. |
| 3 | **Estados 1..4** são referenciados por número em SQL, Blade, JS e logs | Preservar os valores no enum; a coluna `status_id` vira `status_lai` tipada. |
| 4 | **E-mail de prolongação** só é enviado quando `status == 4` | Preservar exatamente esse gatilho. |
| 5 | **Relatório estatístico** (8 fatias, dentro/fora do prazo) é informação públicalegal | Recalcular com `due_at`/`answered_at`; validar os números contra a v6 antes de desligar a rota antiga. |
| 6 | **Exports** xls/csv/pdf/txt com colunas Status, Protocolo, Solicitante, Data | Preservar o layout e a ordem das colunas. |
| 7 | **Mascaramento**: o texto público hoje vem de `mask_request` em alguns lugares e de `request` em outros | Definir uma única fonte pública (`mask_request`) e aplicá-la em **todos** os pontos públicos. |
| 8 | **Protocolo como fator de autenticação frouxa** | Introduzir token de consulta ou exigir e-mail + protocolo; risco de segurança que precisa de decisão do negócio. |
| 9 | **Duplicação de titulares** (nova `persons` por pedido) | Preservar o comportamento (o histórico depende dele) e, se necessário, deduplicar por CPF/CNPJ apenas para consultas. |
| 10 | **`resource` `respostas-lai`** expõe `create`/`edit`/`update`/`destroy` quebrados | Remover; a v7 só precisa de listagem, detalhe, resposta, indeferimento e prorrogação. |
