# Migração Moderna

## 0. Premissa de destino

O V7 já possui o módulo `src/modules/Portal/DadosAbertos` com:

- tabela `dados_abertos.datasets` (UUIDv7, `jsonb columns/rows`, DCAT, soft delete);
- export CSV/JSON/XML com `ETag`/SHA-256 (`src/modules/Portal/DadosAbertos/Logic/Actions/DadosAbertosExport.php`);
- API pública paginada (`src/modules/Portal/DadosAbertos/Logic/Controllers/DadosAbertosApiIndexController.php`);
- CMS com Create/Edit/Store/Update/Status/Destroy (`src/modules/Portal/DadosAbertos/Routes/cms.php`).

A reconstrução **não** cria um CRUD paralelo de "itens de transparência". Ela
adiciona ao módulo de dados abertos:

1. uma **árvore de categorias** (porte legítimo da hierarquia + `order`);
2. a **competência** como coluna da matriz de dados (porte legítimo de
   `reference_date`);
3. um **dataset por categoria fiscal**, cujas linhas são os itens.

Justificativa: ADR-018 (dataset como entidade de domínio; export legível por
máquina), ADR-017 (`jsonb`, migrations idempotentes), ADR-033 (RBAC de quatro
ações), ADR-022 (`ETag`/304), ADR-035 (auditoria nativa).

## 1. Schema (migration)

### 1.1 Categorias hierárquicas

```php
<?php
// exemplo ilustrativo — módulo Portal/DadosAbertos
declare(strict_types=1);

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Query\Expression;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    private const string SCHEMA = 'dados_abertos';

    public function up(): void
    {
        DB::statement("SET lock_timeout = '3s';");
        DB::statement('CREATE SCHEMA IF NOT EXISTS '.self::SCHEMA.';');
        DB::statement('SET search_path TO '.self::SCHEMA.', public;');

        if (Schema::hasTable('dataset_categories')) {
            DB::statement('RESET search_path;');
            return;
        }

        Schema::create('dataset_categories', function (Blueprint $table): void {
            $id = $table->uuid('id')->primary();
            $id->default(new Expression('uuidv7()'));

            $table->uuid('parent_id')->nullable();
            $table->text('name');
            $table->text('slug')->unique();
            $table->text('description')->nullable();
            $table->text('status')->default('publicado'); // rascunho | publicado
            $table->unsignedInteger('position')->default(0);
            $table->jsonb('metadata')->default(new Expression("'{}'::jsonb"));
            $table->timestampsTz();
            $table->softDeletesTz();

            $table->foreign('parent_id')->references('id')
                ->on('dataset_categories')->nullOnDelete();
            $table->index(['parent_id', 'position']);
        });

        DB::statement('RESET search_path;');
    }

    public function down(): void
    {
        DB::statement('SET search_path TO '.self::SCHEMA.', public;');
        $backup = 'backup_dataset_categories_'.date('Y_m_d_His');
        if (Schema::hasTable('dataset_categories')) {
            Schema::rename('dataset_categories', $backup);
        }
        DB::statement('RESET search_path;');
    }
};
```

Diferenças deliberadas frente ao legado:

| Legado | V7 | Motivo |
|---|---|---|
| `parent_id` sem FK | FK `nullOnDelete` + índice composto | integridade; órfão é bug de dados |
| `is_active` boolean | `status` `rascunho|publicado` | ADR-018 usa o mesmo vocabulário de dataset |
| `order` | `position` (unsigned) | nome sem palavra reservada; índice composto |
| `slug` unique global | `slug` unique global (mantido) | portável; colisão entre ramos resolvida por prefixo |
| `metadata` livre | `jsonb` com default `{}` | ADR-017 |

### 1.2 Vínculo categoria ↔ dataset

```php
// exemplo ilustrativo
Schema::table('datasets', function (Blueprint $table): void {
    $table->uuid('category_id')->nullable()->after('theme');
    $table->foreign('category_id')->references('id')
        ->on('dataset_categories')->nullOnDelete();
    $table->index(['category_id', 'published_at']);
});
```

`datasets.columns` / `datasets.rows` permanecem intactos. A matriz de um dataset
fiscal usa o cabeçalho fixo:

| Índice | Coluna | Tipo no `jsonb` |
|---|---|---|
| 0 | `item` | string (título do lançamento) |
| 1 | `competencia` | string `YYYY-MM` ou `YYYY-MM-DD` |
| 2 | `valor` | string decimal (`"2850000.00"`) |
| 3 | `observacao` | string ou `null` |

Regra de equivalência: **valor nunca é `float` PHP**. O export já preserva a
string; a exibição no portal formata com `NumberFormatter`.

## 2. Models

```php
// exemplo ilustrativo
declare(strict_types=1);

namespace Modules\Portal\DadosAbertos\Database\Models;

use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Concerns\HasUuids;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;

final class DadosAbertosCategory extends Model
{
    use HasUuids;
    use SoftDeletes;

    protected $table = 'dados_abertos.dataset_categories';

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

    protected function casts(): array
    {
        return ['metadata' => 'array', '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 datasets(): HasMany
    {
        return $this->hasMany(DadosAbertosDataset::class, 'category_id');
    }

    public function scopePublicadas(Builder $query): Builder
    {
        return $query->where('status', 'publicado')->whereNull('parent_id');
    }

    public function scopeOrdenadas(Builder $query): Builder
    {
        return $query->orderBy('position')->orderBy('name');
    }
}
```

No `DadosAbertosDataset` existente, adicionar:

```php
// exemplo ilustrativo — patch em Database/Models/DadosAbertosDataset.php
public function category(): BelongsTo
{
    return $this->belongsTo(DadosAbertosCategory::class, 'category_id');
}
```

- `slug` de categoria: gerado **na Action de save**, não em hook de model
  (DDD-lite: domínio fora dos hooks do Eloquent — ADR-013).
- `slug` de dataset: já é gerado em `booted()` (`src/modules/Portal/DadosAbertos/Database/Models/DadosAbertosDataset.php:84-95`);
  manter, mas garantir unicidade e imutabilidade após publicação.

## 3. Form Request

`DadosAbertosSaveRequest` (`src/modules/Portal/DadosAbertos/Logic/Requests/DadosAbertosSaveRequest.php`) recebe
dois campos novos:

```php
// exemplo ilustrativo
'category_id' => [
    'nullable', 'uuid',
    Rule::exists('dados_abertos.dataset_categories', 'id')
        ->where('status', 'publicado'),
],
'columns' => ['required', 'array', 'min:2', 'max:60'],
'rows.*' => ['array', 'size:'.$columnCount],
```

Nada de `$id` morto: a unicidade de slug já é validada por closure
(`src/modules/Portal/DadosAbertos/Logic/Requests/DadosAbertosSaveRequest.php:57-75`), reaproveitável.

Regra de competência (validada na Action, não no Request, porque depende do
índice da coluna):

```php
// exemplo ilustrativo
$index = array_search('competencia', $dataset->columns, true);
foreach ($dataset->rows as $row) {
    if ($index === false || preg_match('/^\d{4}-\d{2}(-\d{2})?$/', $row[$index] ?? '') !== 1) {
        throw new InvalidArgumentException('Competência inválida na linha.');
    }
}
```

## 4. Autorização (RBAC ADR-033)

O V7 substitui `none|read|write` por quatro ações por módulo:
`ver|editar|aprovar|arquivar`. Mapa:

| Legado | V7 |
|---|---|
| `v2_can('transparencia','read')` | `ver` |
| `v2_can('transparencia','write')` | `editar` |
| publicar/despublicar (`is_active`) | `aprovar` |
| soft delete | `arquivar` |

Chave canônica do módulo no V7: `dados-abertos` (declarada em
`ModuleProvider::config()['permissions'] = Permission::ACTIONS`,
`src/modules/Portal/DadosAbertos/ModuleProvider.php:29-37`).

```php
// exemplo ilustrativo — dentro das Actions/Controllers
Permission::authorize('dados-abertos', Permission::EDIT);
```

Policies por model são desnecessárias: a autorização é do módulo, como no
módulo V7 de referência (ADR-033).

## 5. Endpoints

Reaproveitar os existentes e acrescentar apenas o necessário:

| Método | URI | Ação | Origem |
|---|---|---|---|
| GET | `/dados-abertos` | catálogo público | já existe |
| GET | `/dados-abertos/{slug}` | detalhe | já existe |
| GET | `/dados-abertos/{slug}/{format}` | export CSV/JSON/XML com `ETag` | já existe |
| GET | `/api/v1/datasets` | API paginada | já existe |
| GET | `/cms/dados-abertos` | lista | já existe |
| GET | `/cms/dados-abertos/categorias` | árvore de categorias | **novo** |
| POST | `/cms/dados-abertos/categorias` | criar categoria | **novo** |
| PUT | `/cms/dados-abertos/categorias/{category}` | editar | **novo** |
| POST | `/cms/dados-abertos/categorias/{category}/status/{state}` | publicar/despublicar | **novo** |
| DELETE | `/cms/dados-abertos/categorias/{category}` | arquivar | **novo** |

Rotas sem closure (ADR-008), nomes `dados-abertos.cms.categories.*`.

## 6. API Resource

Reutilizar o formato do `DadosAbertosApiIndexController::represent()`
(`src/modules/Portal/DadosAbertos/Logic/Controllers/DadosAbertosApiIndexController.php:68-86`), acrescentando o
nó de categoria:

```php
// exemplo ilustrativo
'category' => $dataset->category === null ? null : [
    'slug' => $dataset->category->slug,
    'name' => $dataset->category->name,
    'path' => $dataset->category->ancestryPath(), // raiz › filha
],
```

Não criar Resource class: o módulo V7 serializa arrays no controller, mantendo
determinismo de ordem (ADR-001).

## 7. Views / componentes

| Legado | V7 |
|---|---|
| `V2/Modules/DadosAbertos/TransparenciaFiscal/Views/portal/index.blade.php` (cards) | `src/modules/Portal/DadosAbertos/Layout/Views/index.blade.php` com grade de datasets + filtro por categoria |
| `V2/Modules/DadosAbertos/TransparenciaFiscal/Views/portal/show.blade.php` | `src/modules/Portal/DadosAbertos/Layout/Views/show.blade.php` com metadados DCAT + downloads |
| `V2/Modules/DadosAbertos/TransparenciaFiscal/Views/cms/index.blade.php` (tabela) | `src/modules/Portal/DadosAbertos/Layout/Views/cms/index.blade.php` |
| `cms/create|edit` | `src/modules/Portal/DadosAbertos/Layout/Views/cms/form.blade.php` (já unificado) |
| filtro por `category` | `theme`/`category` no catálogo |

Regras de UI no V7:

- Tailwind/daisyUI, zero CDN (ADR-009);
- sem inline script/styles — CSP (ADR-022);
- tabela de dados com `aria-label`, `<caption>`, `scope="col"` (ADR-025);
- download sempre com URL estável e `ETag` (ADR-018/022).

## 8. Fila, cache e storage

| Necessidade | Solução |
|---|---|
| Export de datasets grandes | job em fila + cache do corpo por `slug+version` (`Cache::remember`) |
| `ETag` | SHA-256 do corpo (já implementado) |
| `If-None-Match` | 304 (já implementado em `src/modules/Portal/DadosAbertos/Logic/Controllers/DadosAbertosExportController.php:39-41`) |
| Importação periódica de dados fiscais | comando agendado que grava `rows` via Action, com `lock_version` (versionamento otimista já no schema) |
| Anexos/planilhas originais | `Media`/S3 (ADR-026/030), referenciando `metadata` do dataset |

Não há storage de arquivo no legado — o importador copia para a galeria; no V7
isso vira distribuição (`distributions`) do dataset.

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

| # | Risco | Decisão |
|---|---|---|
| M1 | URLs antigas `/transparencia/{slug}` deixam de existir | sem camada de compatibilidade: atualizar links internos e sitemap (ADR-042) |
| M2 | Precisão monetária | `numeric` no `jsonb` como string; nunca `float` (o legado usa `float`) |
| M3 | Hierarquia de 2 níveis | V7 suporta N níveis; renderizar no máximo 2 para equivalência visual, sem limitar o dado |
| M4 | Itens sem categoria | dataset especial "Sem categoria" ou `category_id NULL` visível no catálogo |
| M5 | Soft delete | `arquivar` (RBAC) + `deleted_at`; item arquivado some do portal |
| M6 | Competência | coluna obrigatória em datasets fiscais; validação `YYYY-MM` |
| M7 | Conteúdo V1 | **não** portar importador: reentrada manual pelo CMS (ADR-042). O `TransparenciaFiscalLegacyImporter` é artefato da transição V1→V2, não do V7 |
| M8 | `maintenance`/flags | ativação por dado (ADR-034) e não por arquivo de config |
| M9 | Links da home para `/transparencia` | atualizar `LandingSectionsEditor`/home no mesmo commit |
| M10 | Sem PII | nenhum dado pessoal em transparência; servidores/diárias ficam fora deste porte (ADR-019) |
