# Transparência Fiscal — Migração para Laravel Moderno (v7)

> Plano de reconstrução em PHP 8.x + Laravel 11/12, tomando como base a `v6`.

---

## 1. Decisões de equivalência funcional (o que NÃO pode mudar)

| # | Regra a preservar | Motivo |
|---|---|---|
| E1 | A raiz da transparência continua sendo uma `category_contents` identificada por `slug = 'transparencia-fiscal'` | Migrações, seeds e URLs do CMS dependem do slug |
| E2 | A ordem dos itens é `contents.order`, 1-based, reordenada automaticamente na inserção | O arrasto no CMS e a ordem da view SQL dependem disso |
| E3 | Itens inativos (`status = false`) somem do portal mas permanecem no CMS | Separação entre "publicado" e "em edição" |
| E4 | `isBlank` mantém o mesmo significado (nova aba vs. `iframe` interno) | Contrato visual com o contenido editor |
| E5 | O relatório estatístico é público, com **máscara** de protocolo e nome do solicitante (LGPD) | Obrigação legal; não pode ser "simplificado" |
| E6 | O prazo legal continua sendo 20 dias, com os mesmos status (1 Em Tramitação, 2 Atendido, 3 Indeferido, 4 Prorrogado) | Compatibilidade com os dados já publicados |
| E7 | As exportações continuam sendo xls, csv, pdf e txt com o mesmo conteúdo | Órgãos de controle já arquivam esses arquivos |
| E8 | A listagem do CMS mostra `titulo`, `category_name` e `status_name` | Formato de dado já consumido por relatórios internos |

---

## 2. Schema de migração Laravel

### 2.1 Tabela de categorias — generalizar `category_contents`

```php
// database/migrations/2026_xx_xx_create_transparency_categories.php
Schema::create('transparency_categories', function (Blueprint $table) {
    $table->id();
    $table->foreignId('parent_id')->nullable()
        ->constrained('transparency_categories')->nullOnDelete();
    $table->string('name');
    $table->string('slug')->unique();
    $table->text('description')->nullable();
    $table->string('icon')->nullable();
    $table->unsignedInteger('position')->default(0);
    $table->boolean('status')->default(true);
    $table->timestamps();
    $table->softDeletes();
});
```

> Decisão: **não** reaproveitar `category_contents` (tabela compartilhada por ~25 módulos).
> Criar tabelas próprias isola a reconstrução e elimina o fallback literal `25`.

### 2.2 Tabela de itens

```php
Schema::create('transparency_items', function (Blueprint $table) {
    $table->id();
    $table->foreignId('category_id')->nullable()
        ->constrained('transparency_categories')->nullOnDelete();
    $table->string('title');
    $table->string('slug')->unique();
    $table->text('description')->nullable();
    $table->string('icon')->nullable();
    $table->string('url', 3000)->nullable();   // substitui a tabela `links`
    $table->boolean('open_in_new_tab')->default(false);
    $table->string('legal_reference', 50)->nullable(); // ex.: "2.1 a 2.5"
    $table->unsignedInteger('position')->default(0);
    $table->boolean('status')->default(false);
    $table->timestamps();
    $table->softDeletes();

    $table->index(['category_id', 'position']);
});
```

### 2.3 Dados do e-SIC (relatório)

```php
Schema::create('information_requests', function (Blueprint $table) {
    $table->id();
    $table->string('protocol')->index();
    $table->text('request');
    $table->text('masked_request')->nullable();
    $table->foreignId('person_id')->nullable()->constrained('people')->nullOnDelete();
    $table->foreignId('status_id')->nullable()->constrained('statuses')->nullOnDelete();
    $table->string('ip')->nullable();
    $table->string('browser')->nullable();
    $table->timestamps();
    $table->softDeletes();
});

Schema::create('information_request_answers', function (Blueprint $table) {
    $table->id();
    $table->foreignId('information_request_id')->constrained('information_requests')->cascadeOnDelete();
    $table->text('answer');
    $table->timestamps();
    $table->softDeletes();
});
```

### 2.4 Mapeamento a partir da v6

| Origem (v6) | Destino (v7) |
|---|---|
| `contents` com `category_contents_id` = raiz ou subcategoria | `transparency_items` |
| `category_contents` com `slug = 'transparencia-fiscal'` e filhos | `transparency_categories` (raiz + subcategorias) |
| `links` (1:1 com `contents`) | `transparency_items.url` + `open_in_new_tab` |
| `informations_requests` | `information_requests` |
| `answers` + `answer_archives` | `information_request_answers` + `media` (pivot) |
| View `transparencia_fiscal` | **Eliminada** — vira uma query Eloquent paginada |

---

## 3. Modelos

```php
// app/Models/Transparency/Category.php
class Category extends Model
{
    use HasFactory, SoftDeletes;

    protected $fillable = ['parent_id', 'name', 'slug', 'description', 'icon', 'position', 'status'];

    protected $casts = ['status' => 'boolean', 'position' => 'integer'];

    public function parent(): BelongsTo     { return $this->belongsTo(self::class, 'parent_id'); }
    public function children(): HasMany     { return $this->hasMany(self::class, 'parent_id')->orderBy('position'); }
    public function items(): HasMany        { return $this->hasMany(Item::class, 'category_id')->orderBy('position'); }
}
```

```php
// app/Models/Transparency/Item.php
class Item extends Model
{
    use HasFactory, SoftDeletes;

    protected $fillable = ['category_id', 'title', 'slug', 'description', 'icon',
                            'url', 'open_in_new_tab', 'legal_reference', 'position', 'status'];

    protected $casts = ['status' => 'boolean', 'open_in_new_tab' => 'boolean', 'position' => 'integer'];

    public function category(): BelongsTo { return $this->belongsTo(Category::class, 'category_id'); }
}
```

### 3.1 Enums sugeridos

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

```php
enum ExportFormat: string
{
    case Xls = 'xls';
    case Csv = 'csv';
    case Pdf = 'pdf';
    case Txt = 'txt';
}
```

### 3.2 Escopos que eliminam o SQL cru

```php
// app/Models/InformationRequest.php
public function scopeWithinLegalDeadline(Builder $q, RequestStatus $status, bool $late): Builder
{
    $field = $status === RequestStatus::Prorrogado ? 'updated_at' : 'created_at';
    $operator = $late ? '>' : '<=';

    return $q->where('status_id', $status->value)
             ->whereRaw("EXTRACT(DAY FROM now() - {$field}) {$operator} ?", [self::LEGAL_DEADLINE_DAYS]);
}
```

> O `whereRaw` continua, mas com **binding** (`?`) e o limite vindo de uma constante — o
> padrão `->whereRaw('... '.$values['comparation'].' 20')` da v6 concatenava strings.

Alternativa preferível (indexável): guardar `due_at = created_at + 20 days` e comparar
datas no PHP/SQL padrão.

---

## 4. Form Requests

```php
// app/Http/Requests/Portal/TransparencyReportRequest.php
class TransparencyReportRequest extends FormRequest
{
    public function authorize(): bool { return true; }

    public function rules(): array
    {
        return [
            'ano' => ['nullable', 'integer', 'min:2000', 'max:'.(int) date('Y')],
            'type' => ['nullable', Rule::enum(ExportFormat::class)],
        ];
    }
}
```

```php
// app/Http/Requests/Admin/TransparencyItemRequest.php
public function rules(): array
{
    return [
        'title' => ['required', 'string', 'max:120'],
        'category_id' => ['nullable', 'exists:transparency_categories,id'],
        'icon' => ['required', 'string', 'max:120'],
        'url' => ['required', 'string', 'max:2999', 'url'],
        'position' => ['required', 'integer', 'min:1'],
        'status' => ['boolean'],
        'open_in_new_tab' => ['boolean'],
        'legal_reference' => ['nullable', 'string', 'max:50'],
    ];
}
```

> Diferença relevante: na v6 a URL **não** é validada como URL (apenas `max:2999`); a
> sanitização é feita por `sanitizeLink()`/`normalizeVideoLink()`. Na v7, validação estrita
> de URL é desejável, mas exige confirmar com o negócio se há URLs internas relativas
> (hoje o controller aceita `https://` sem ponto virando `/caminho`).

---

## 5. Policies

```php
// app/Policies/TransparencyItemPolicy.php
class TransparencyItemPolicy
{
    public function viewAny(User $user): bool   { return $user->can('transparencia-fiscal.view'); }
    public function create(User $user): bool    { return $user->can('transparencia-fiscal.create'); }
    public function update(User $user, Item $i): bool { return $user->can('transparencia-fiscal.edit'); }
    public function delete(User $user, Item $i): bool { return $user->can('transparencia-fiscal.delete'); }
    public function restore(User $user, Item $i): bool { return $user->can('transparencia-fiscal.restore'); }
    public function inactivate(User $user, Item $i): bool { return $user->can('transparencia-fiscal.inactivate'); }
}
```

Substitui o `has_access(['transparencia-fiscal.create'])` distribuído pelas views e o
`struct_views()` que injeta flags booleanas no payload do DataTable.

---

## 6. Endpoints REST/JSON sugeridos

### 6.1 Portal (público)

| Método | URI | Descrição |
|---|---|---|
| GET | `/api/v1/transparency/items` | Lista de itens ativos agrupados por categoria (`?category=slug`) |
| GET | `/api/v1/transparency/categories` | Árvore de categorias |
| GET | `/api/v1/transparency/items/{slug}` | Detalhe de um item |
| GET | `/api/v1/transparency/reports/e-sic` | Contadores do relatório (`?ano=YYYY`) |
| GET | `/api/v1/transparency/reports/e-sic/export` | Download (`?ano=YYYY&format=xls|csv|pdf|txt`) |
| GET | `/api/v1/information-requests/{id}/answers` | Respostas mascaradas (substitui `/AjaxGetanswers`) |

### 6.2 CMS (autenticado)

| Método | URI | Descrição |
|---|---|---|
| GET | `/api/v1/admin/transparency/items` | DataTable paginado (server-side) |
| POST | `/api/v1/admin/transparency/items` | Criar item |
| PUT | `/api/v1/admin/transparency/items/{id}` | Atualizar item |
| DELETE | `/api/v1/admin/transparency/items/{id}` | Excluir |
| POST | `/api/v1/admin/transparency/items/reorder` | Reordenar (recebe lista de ids na ordem) |
| POST | `/api/v1/admin/transparency/items/bulk-delete` | Exclusão em lote |
| GET/POST/PUT/DELETE | `/api/v1/admin/transparency/categories[...]` | CRUD de categorias |

**Compatibilidade de URL:** as 9 URIs legadas devem responder **301** para a URI canônica
`/transparencia-fiscal` (e `/transparencia-fiscal/{slug}` para o detalhe), em vez de
manter 4+5 registros com o mesmo nome de rota.

---

## 7. API Resources

```php
class TransparencyItemResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id'          => $this->id,
            'title'       => $this->title,
            'slug'        => $this->slug,
            'icon'        => $this->icon,
            'legal_reference' => $this->legal_reference,
            'position'    => $this->position,
            'category'    => new TransparencyCategoryResource($this->whenLoaded('category')),
            'url'         => $this->url,
            'open_in_new_tab' => $this->open_in_new_tab,
        ];
    }
}
```

```php
class InformationRequestResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id'            => $this->id,
            'protocol'      => $this->masked_protocol,   // já mascarado
            'request'       => $this->masked_request,
            'status_id'     => $this->status_id,
            'created_at'    => $this->created_at->toDateString(),
            'answers'       => InformationRequestAnswerResource::collection($this->whenLoaded('answers')),
        ];
    }
}
```

> O `getAnswers` da v6 serializa o model Eloquent cru (expõe `archives_id`,
> `informations_request_id`, `deleted_at`); o Resource resolve isso e mantém o contrato
> observável (`request.protocol`, `request.mask_request`, `request.status_id`, `answers[]`).

---

## 8. Views Blade modernas

| Legado (v6) | Proposta (v7) |
|---|---|
| 6-7 cópias de `transparencia-fiscal/index.blade.php` | Um componente Blade único `<x-transparency.grid :items="$items" />` incluído por cada layout |
| `portal/components/transparencia-fiscal/item-box.blade.php` | `<x-transparency.item-card :item="$item" />` |
| `referencia-badge.blade.php` + `referencia-badge-styles.blade.php` | `<x-transparency.reference-badge :value="$item->legal_reference" />` com CSS único |
| `relatorio-estatistico/js.blade.php` (Google Charts CDN + jQuery Ajax) | Alpine.js + `Chart.js` empacotado (npm) ou `<x-chart>`; AJAX via `fetch` com token CSRF no header |
| `prefecture/contents/transparencia-fiscal/form.blade.php` | `<x-admin.transparency-item-form>` reutilizado por create/edit |

Recomendação: eliminar a multiplicação por template **mantendo** apenas a variação de
layout no `master` de cada tema, com o conteúdo do módulo vindo de um componente único.

---

## 9. Fila, cache e storage

| Item | Proposta |
|---|---|
| Cache | `PortalTransparenciaService`: manter `Cache::remember` mas com chave por tenant/município e invalidação por evento (o `ConfigurationController` já faz `Cache::pull`) |
| Fila | O relatório gera PDF e XLS **sincronamente** hoje; para alto volume, mover para um job `GenerateTransparencyReport` com `ShouldQueue` e notificação por e-mail |
| Storage | Nenhum arquivo novo; os anexos do e-SIC seguem no `Storage` atual |
| Cache de consulta | Paginação server-side do DataTable (hoje é `->get()` completo) |
| Agenda | Job diário que consolida os contadores anuais do relatório (evita 8 agregações por requisição) |

---

## 10. Riscos de equivalência

| Risco | Impacto | Mitigação |
|---|---|---|
| Perder as URIs legadas | Quebra de links publicados, SEO e material impresso | Redirect 301 de todas as variantes para a URI canônica; sitemap apontando para a canônica |
| Perder a view SQL | O CMS muda de contrato de dados | Reproduzir as colunas `titulo`, `category_name`, `status_name` no Resource equivalente |
| Alterar a ordem dos itens | Reorganização visual da home e do portal | Preservar `position` 1-based e a renumeração transacional na inserção |
| Mascaramento diferente | Exposição de dados pessoais (LGPD) | Reaproveitar a trait de mascaramento (`maskProtocol`, `maskName`) e seus testes |
| Contadores do relatório divergentes | Divergência com o relatório oficial de LAI | Fixar os valores esperados em testes de caracterização (mesmo `EXTRACT(DAY ... 20)`) |
| Integração com o portal externo do fornecedor | Busca do portal perde os links fiscais | Isolar em um `ExternalTransparencyProvider` (interface) com implementação Elmar |
