# 06 — Plano de Migração para a v7 (PHP 8.x + Laravel 11/12)

---

## 1. Princípios de equivalência funcional

O que **não pode mudar** na reconstrução:

1. A edição é salva **sem** gerar PDF; a geração é explícita.
2. A assinatura digital só aparece em edições publicadas.
3. A **área** é a única unidade de seção; a subárea é apenas classificatória.
4. `file_in` decide entre rasterização inline e importação página a página.
5. O certificado do diário sobrepõe o certificado padrão.
6. A publicação exige um segundo usuário (4 olhos).
7. O PDF é atômico, não é servido do cache e o layout não perde páginas.
8. O QR Code aponta para a URL pública da edição.
9. A trilha de auditoria editorial e a trilha pública mantêm os mesmos eventos.

## 2. Schema de migração Laravel

### 2.1 Tabela `diarios`

```php
Schema::create('diarios', function (Blueprint $table) {
    $table->id();
    $table->foreignId('id_certificado')->constrained('diario_certificados');
    $table->unsignedBigInteger('id_complemento')->nullable();
    $table->date('data_publicacao');
    $table->string('lei_validadora')->nullable();
    $table->string('autor');
    $table->string('email');
    $table->unsignedInteger('edicao');
    $table->boolean('status')->nullable();
    $table->boolean('permission_edit')->default(true);
    $table->unsignedTinyInteger('columns_number')->default(1);
    $table->boolean('has_line')->default(false);
    $table->unsignedTinyInteger('status_workflow')->default(1);
    $table->foreignId('created_by_user_id')->nullable()->constrained('users')->nullOnDelete();
    $table->foreignId('updated_by_user_id')->nullable()->constrained('users')->nullOnDelete();
    $table->foreignId('published_by_user_id')->nullable()->constrained('users')->nullOnDelete();
    $table->timestamp('published_at')->nullable();
    $table->string('line_style')->nullable();
    $table->string('line_color')->nullable();
    $table->foreignId('cover_image_id')->nullable()->constrained('archives')->nullOnDelete();
    $table->string('generation_status', 20)->nullable();
    $table->text('generation_error')->nullable();
    $table->json('generation_progress')->nullable();
    $table->timestamp('generation_started_at')->nullable();
    $table->timestamp('generation_completed_at')->nullable();
    $table->softDeletes();
    $table->timestamps();

    $table->index(['status', 'edicao']);
    $table->index('status_workflow');
});
```

### 2.2 Demais tabelas

| Tabela | Colunas | Observação |
|--------|---------|------------|
| `diario_areas` | `id`, `name`, `softDeletes`, `timestamps` | — |
| `diario_sub_areas` | `id`, `name`, `softDeletes`, `timestamps` | — |
| `diario_has_areas` | `id`, `titulo`, `foreignId id_diario`, `foreignId id_area`, `foreignId id_subarea`, `text conteudo` nullable, `unsignedBigInteger columns_number` default 1, `unsignedInteger position` default 0, `string type` default `text`, `boolean file_in` default true, `string path` nullable, `softDeletes`, `timestamps` | índices em `id_diario` e `position` |
| `diario_certificados` | `id`, `name`, `path`, `text cert`, `text pkey`, `softDeletes`, `timestamps` | **guardar a chave privada cifrada** (ver seção 8) |
| `diario_configs` | `id`, `text lei_padrao` nullable, `foreignId certificado_padrao` nullable, `boolean ativos` nullable, `string logo_position` nullable, `foreignId header_img` nullable, `softDeletes`, `timestamps` | registro único com `id = 1` |

### 2.3 Enums sugeridos

```php
enum WorkflowStatus: int {
    case Rascunho = 1;
    case EmRevisao = 2;
    case Publicado = 3;
}

enum MateriaType: string {
    case Texto = 'text';
    case Arquivo = 'file';
}

enum GenerationStatus: string {
    case Pending = 'pending';
    case Processing = 'processing';
    case Completed = 'completed';
    case Failed = 'failed';
}

enum LogoPosition: string {
    case Center = 'center';
    case Left = 'left';
    case Right = 'right';
}

enum LineStyle: string {
    case Solid = 'solid';
    case Dashed = 'dashed';
    case Dotted = 'dotted';
}
```

## 3. Models

| Model | Atributos e casts | Relations |
|-------|-------------------|-----------|
| `App\Models\Diario` | `$fillable`: `autor`, `status`, `edicao`, `email`, `data_publicacao`, `id_certificado`, `id_complemento`, `lei_validadora`, `permission_edit`, `columns_number`, `has_line`, `status_workflow`, `created_by_user_id`, `updated_by_user_id`, `published_by_user_id`, `published_at`, `line_style`, `line_color`, `cover_image_id`. Casts: `data_publicacao` → `date`, `status` → `boolean`, `permission_edit` → `boolean`, `has_line` → `boolean`, `columns_number` → `integer`, `status_workflow` → `WorkflowStatus::class`, `published_at` → `datetime` | `certificado()` belongsTo, `complemento()` belongsTo (self), `diario_areas()` hasMany, `areas()` belongsToMany (pivot `diario_has_areas`), `subAreas()` belongsToMany, `cover_image()` belongsTo |
| `App\Models\DiarioArea` | `name` fillable | `materias()` hasMany |
| `App\Models\DiarioSubArea` | `name` fillable | `materias()` hasMany |
| `App\Models\DiarioHasAreas` | fillable: `titulo`, `id_diario`, `id_area`, `id_subarea`, `conteudo`, `columns_number`, `position`, `type`, `file_in`, `path`. Casts: `type` → `MateriaType::class`, `file_in` → `boolean`, `columns_number` → `integer`, `position` → `integer` | `diario()` belongsTo, `area()` belongsTo, `subarea()` belongsTo |
| `App\Models\DiarioCertificado` | `name`, `path`, `cert`, `pkey` | `diarios()` hasMany |
| `App\Models\DiarioConfig` | `lei_padrao`, `certificado_padrao`, `ativos`, `logo_position`, `header_img`. Casts: `ativos` → `boolean`, `logo_position` → `LogoPosition::class` | `certificado()` belongsTo, `archive()` belongsTo |

> O nome `DiarioHasAreas` deve ser **renomeado** para `DiarioMateria` na v7
> (o nome sugere pivot, mas a tabela tem atributos próprios). Se a renomeação
> for custosa, manter o nome e documentar.

## 4. Form Requests

| Request | Regras |
|---------|--------|
| `StoreDiarioRequest` / `UpdateDiarioRequest` | `edicao` required integer; `autor` required string max 191; `email` required email max 191; `data_publicacao` required date; `lei_validadora` nullable string; `id_certificado` required exists `diario_certificados,id`; `columns_number` nullable integer in 1,2,3; `has_line` nullable boolean; `line_style` nullable in solid,dashed,dotted; `line_color` nullable regex `^#[0-9A-Fa-f]{6}$`; `archive_highlight` nullable image max 5120 |
| `StoreMateriaRequest` | `titulo` nullable string; `conteudo` nullable string; `id_area` required exists `diario_areas,id`; `id_subarea` required exists `diario_sub_areas,id`; `id_diario` required exists `diarios,id`; `type` required in text,file; `file` required_if type,file file mimes:pdf max 5120 |
| `StoreAreaRequest` / `StoreSubAreaRequest` | `name` required string max 191 unique (ignoring soft deletes) |
| `SortMateriasRequest` | `materias` required array; `materias.*.id` required integer exists `diario_has_areas,id`; `materias.*.position` required integer |
| `SaveConfigRequest` | `ativos` required boolean; `lei_padrao` required string; `certificado_padrao` required exists `diario_certificados,id`; `logo_position` required in center,left,right; `header_img` nullable file mimes jpeg,jpg,png max 5120 |
| `StoreCertificadoRequest` | `certificado` required file mimes:pfx,p12,crt max 10240; `password` required string |

## 5. Policies e autorização

```php
class DiarioPolicy
{
    public function viewAny(User $user): bool;      // diario-oficial.index
    public function create(User $user): bool;        // diario-oficial.create
    public function update(User $user, Diario $d): bool;
    public function delete(User $user, Diario $d): bool;
    public function publish(User $user, Diario $d): bool;
}
```

Regras específicas a preservar:

- `publish` exige `status_workflow === EmRevisao`.
- `publish` exige `created_by_user_id !== $user->id` **e**
  `updated_by_user_id !== $user->id` (4 olhos).
- `update` de edição publicada exige `permission_edit` verdadeiro.

> A v7 deve migrar do Sentinel para o `users` do Laravel com Policies +
> `Gate::define`. As permissões nomeadas (`diario-oficial.index`, etc.) podem
> ser mantidas como slugs na tabela de permissões para não quebrar a
> configuração de menu já implantada.

## 6. Endpoints REST/JSON sugeridos

### 6.1 CMS (mantendo o contrato atual)

| Método | URI | Nome | Observação |
|--------|-----|------|------------|
| GET | `/cms/diario-oficial` | `diario-oficial.index` | DataTable |
| GET | `/cms/diario-oficial/create` | `diario-oficial.create` | — |
| POST | `/cms/diario-oficial` | `diario-oficial.store` | não gera PDF |
| GET | `/cms/diario-oficial/{id}` | `diario-oficial.show` | stream do PDF ou JSON de status |
| GET | `/cms/diario-oficial/{id}/edit` | `diario-oficial.edit` | — |
| PUT | `/cms/diario-oficial/{id}` | `diario-oficial.update` | não gera PDF |
| DELETE | `/cms/diario-oficial/{id}` | `diario-oficial.destroy` | soft delete |
| PUT | `/cms/diario-oficial/{id}/publish` | `diario-oficial.publish` | 4 olhos |
| DELETE | `/cms/diario-oficial/{id}/cover-image` | `diario-oficial.delete.cover-image` | — |
| GET | `/cms/ajax/dados-diario/{id}` | `dados_diario` | resource JSON |
| POST | `/cms/ajax/diario-oficial` | `diario.destroy` | — |
| PUT | `/cms/ajax/diario-oficial/{id}/sort` | `diario.sort` | — |
| GET | `/cms/ajax/diario-generation-status/{id}` | `diario.generation-status` | polling |
| POST | `/cms/diario/destroyAll` | `diario.destroyAll` | — |
| POST | `/cms/diario/status` | `diario-status-toggle` | — |
| GET/PUT | `/cms/diario/configuracoes` | `diario-config.*` | — |
| GET | `/cms/datatable/diario_oficial` | `datatable.diario_oficial` | — |

### 6.2 Portal

| Método | URI | Nome |
|--------|-----|------|
| GET | `/diario-oficial` | `portal-diario.index` |
| GET | `/diario-oficial/{id}` | `portal-diario.show` |
| POST | `/diario-oficial/filtro` | `portal-diario.fill` |
| GET | `/diario-oficial/viewer/search` | `portal-diario.search` |
| GET | `/diario-oficial/viewer/{id}/file-info` | `portal-diario.file-info` |
| GET | `/diario-oficial/viewer/{id}/search-pdf` | `portal-diario.search-pdf` |
| GET | `/diario-oficial/{id}/auditoria` | `portal-diario.auditoria` |

### 6.3 API JSON adicional (novo)

| Método | URI | Descrição |
|--------|-----|-----------|
| GET | `/api/v1/diarios` | lista paginada, filtro por período/edição/termo |
| GET | `/api/v1/diarios/{id}` | metadados da edição + URL do PDF |
| GET | `/api/v1/diarios/{id}/materias` | matérias da edição |
| GET | `/api/v1/diarios/{id}/pdf` | download do PDF |
| GET | `/api/v1/diarios/{id}/auditoria` | trilha pública |

## 7. Resources de API

```php
class DiarioResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id' => $this->id,
            'edicao' => $this->edicao,
            'autor' => $this->autor,
            'email' => $this->email,
            'data_publicacao' => $this->data_publicacao?->toDateString(),
            'lei_validadora' => $this->lei_validadora,
            'status' => (bool) $this->status,
            'workflow' => $this->status_workflow->value,
            'workflow_label' => $this->status_workflow->name,
            'columns_number' => $this->columns_number,
            'has_line' => $this->has_line,
            'line_style' => $this->line_style,
            'line_color' => $this->line_color,
            'capa' => $this->cover_image?->url,
            'pdf_url' => route('portal-diario.show', $this->id),
            'areas' => AreaResource::collection($this->areas),
            'materias' => MateriaResource::collection($this->diario_areas),
        ];
    }
}
```

## 8. Fila, cache e storage

### 8.1 Fila (correção do maior risco do legado)

O `pending` sem consumidor é resolvido com um Job:

```php
class GenerateDiarioPdf implements ShouldQueue
{
    public function __construct(public int $diarioId, public bool $force = false) {}

    public function handle(DiarioPdfGenerator $generator): void
    {
        $generator->generate($this->diarioId);
    }
}
```

- O `store`/`update` despacha `GenerateDiarioPdf::dispatch($id)` e grava
  `generation_status = pending`.
- O worker executa a geração, grava `completed`/`failed` e o
  `generation_progress`.
- O polling (`generationStatus`) continua existindo, mas o estado `pending`
  **tem consumidor**.
- Fila sugerida: `redis` (já disponível na stack).

### 8.2 Cache

| Uso | Chave sugerida | TTL |
|-----|----------------|-----|
| Texto extraído do PDF | `diario:{id}:pdf-text` (hoje em JSON no disco) | invalidar pelo `mtime` |
| QR Code | `diario:{id}:qrcode` (PNG) | permanente, por edição |
| Listagem do portal | `diario:listas` | 60 s |

> Eliminar o arquivo global `public/img/qrcode.png` (armadilha A2): gravar por
> edição ou em cache.

### 8.3 Storage

| Caminho | Uso |
|---------|-----|
| `storage/app/public/diario-oficial/content/{id}/pdf/diario-{edicao}.pdf` | PDF gerado (renomear `diário-` para ASCII e evitar acento no nome do arquivo) |
| `storage/app/public/diario-oficial/covers/{id}/` | Capa |
| `storage/app/public/diario-oficial/headers/{id}/` | Imagem de cabeçalho |
| `storage/app/public/diario-oficial/adendos/` | Anexos das matérias |
| `storage/app/private/diario-oficial/certificados/` | **Certificados e chaves privadas** (fora do disco público) |

> ⚠️ Na v7 o `.pfx` e a chave privada **não podem** ficar sob `public/`. Hoje
> `diario_certificados.path` aponta para um caminho sob `storage/app/public/`.

## 9. Views Blade modernas

| Legado | v7 |
|--------|-----|
| `prefecture/contents/diario-oficial/index.blade.php` + `datatable.js` | Componente `<x-diario.index>` + DataTable via Livewire ou Inertia |
| `prefecture/contents/diario-oficial/create.blade.php` + `form.blade.php` | `<x-diario.form>` com `<x-form.field>` |
| `prefecture/contents/diario-oficial/modal/areas/*` | Componente `<x-diario.materia-modal>` |
| `prefecture/contents/diario-oficial/components/cover-image.blade.php` | `<x-diario.cover-image>` com preview e remoção |
| `portal/layouts/diario-oficial/index.blade.php` | Componente de layout `<x-portal.diario.index>` |
| `portal/layouts/diario-oficial/show-pdf.blade.php` | `<x-portal.diario.viewer>` (manter HTML autônomo, sem o layout do portal) |
| `prefecture/contents/diario-oficial/pdf/*` | **Remover** (template morto) |
| `prefecture/contents/components/preview/diario-oficial/summernote.blade.php` e `style.blade.php` | **Remover** (órfãos) |

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

| # | Risco | Decisão |
|:-:|-------|---------|
| 1 | TCPDF 6.3 não implementa `max-width`/`max-height`; o legado emite `width`/`height` em mm | Manter a mesma matemática de dimensionamento, ou migrar para uma biblioteca que suporte CSS moderno — **validando o resultado visual** |
| 2 | O FPDI gratuito não lê PDFs com CrossReference Streams | Manter o fallback para Imagick; na v7, preferir `pdfimages`/`pdftoppm` (Poppler) ou uma biblioteca que suporte PDF 1.5+ |
| 3 | Ghostscript é dependência externa do SO | Isolar atrás de uma interface `PdfTextExtractor` e `PdfRasterizer` com implementação Ghostscript e alternativa (Poppler) |
| 4 | `mergePdfs` existe e nunca é chamado | Decidir: ativar o merge (com o agrupamento que preenche `_merged_path`) ou remover o código morto |
| 5 | A chave privada do certificado fica em texto claro no banco | Cifrar com a chave da aplicação (`Crypt::encryptString`) |
| 6 | A senha do certificado passa por `shell_exec` sem escape | Trocar por `proc_open` com array de argumentos, ou por uma biblioteca PHP de PKCS#12; nunca registrar a senha |
| 7 | A geração síncrona pode estourar `max_execution_time` | Job de fila (seção 8.1) |
| 8 | A auditoria usa `properties::json->>'id_diario'` (dialeto PostgreSQL) | Usar uma coluna indexada própria (`diario_id`) na tabela de atividades |
| 9 | A configuração global é um registro com `id = 1` fixo | Migrar para chave única (`key = 'diario'`) ou singleton de configuração |
| 10 | A tabela `modulos` tem duas entradas para o diário (`diario-ofifical` id 16 e `diario-oficial` id 37) | Consolidar em uma entrada e realinhar `resources.modulo_id` e `menu_orders.modulo_id` (ver `08-GAPS-E-DIVIDAS.md`) |
| 11 | A porta HTTP do preview é montada no JS | Usar `route()`/`url()` do Laravel e eliminar a concatenação manual |
| 12 | As views SQL `diario_oficial` e `diario_oficial_pdf` são contrato de API do JS | Manter as colunas ou migrar o JS para JSON |
