# Obras — Plano de Migração para Laravel Moderno

> Alvo: PHP 8.x + Laravel 11/12. Base: comportamento observável da v5
> (descrito em `04-VIEWS-E-UX.md` e `05-REGRAS-E-PORTABILIDADE.md`).

---

## 1. Schema de migração

### 1.1 `work_statuses`

```php
Schema::create('work_statuses', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->timestamps();
    $table->softDeletes();
});
```

### 1.2 `work_categories`

```php
Schema::create('work_categories', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->timestamps();
    $table->softDeletes();
});
```

### 1.3 `works`

```php
Schema::create('works', function (Blueprint $table) {
    $table->id();
    $table->string('title');
    $table->string('edital');
    $table->string('contract');
    $table->foreignId('work_category_id')->nullable()
          ->constrained('work_categories')->nullOnDelete();
    $table->foreignId('status_id')->nullable()
          ->constrained('work_statuses')->nullOnDelete();
    $table->decimal('value', 14, 2);          // decisão D1
    $table->decimal('value_paid', 14, 2)->nullable();  // decisão D1
    $table->date('start_date');
    $table->date('end_date')->nullable();
    $table->string('cep', 10);
    $table->string('state', 2);
    $table->string('city');
    $table->string('neighborhood');
    $table->string('street');
    $table->string('house_number', 10);
    $table->string('complement')->nullable();
    $table->decimal('latitude', 10, 7)->nullable();   // decisão D2
    $table->decimal('longitude', 10, 7)->nullable();  // decisão D2
    $table->string('company_holder')->nullable();
    $table->string('holder');
    $table->boolean('gallery')->default(false);
    $table->text('justification')->nullable();
    $table->text('description')->nullable();
    $table->date('estimated_completion_date')->nullable();
    $table->unsignedTinyInteger('completion_percentage')->nullable();
    $table->timestamps();
    $table->softDeletes();

    // decisão D3 — índices que a v5 não tinha
    $table->index(['status_id', 'deleted_at']);
    $table->index(['work_category_id', 'deleted_at']);
    $table->index('start_date');
    $table->index('contract');
    $table->index('edital');
});
```

### 1.4 `work_archives` (pivot com papel)

```php
Schema::create('work_archives', function (Blueprint $table) {
    $table->id();
    $table->foreignId('work_id')->constrained('works')->cascadeOnDelete();
    $table->foreignId('archive_id')->constrained('archives')->cascadeOnDelete();
    $table->string('type');            // 'file' | 'gallery'
    $table->timestamps();
    $table->softDeletes();

    $table->unique(['work_id', 'archive_id', 'type']);
    $table->index(['work_id', 'type']);
});
```

### 1.5 Decisões de equivalência a registrar

| # | Decisão | Alternativa | Impacto |
|---|---|---|---|
| **D1** | `value`/`value_paid` como `decimal(14,2)` | manter `varchar` | **Recomendado migrar**: elimina o bug de formatação e permite agregação. Exige backfill (parse pt-BR) e ajuste do `WorksExport`. Se a base está vazia (está: 0 linhas), a migração é trivial. |
| **D2** | `latitude`/`longitude` como `decimal(10,7)` | manter `varchar` | **Recomendado migrar**: o map precisa de numérico. Requer backfill de valores corruptos (a string `"não encontrado"` gravada pelo JS — ver `05-REGRAS-E-PORTABILIDADE.md` §3.3). |
| **D3** | Índices em `status_id`, `work_category_id`, `start_date`, `contract`, `edital` | manter sem índice | A v5 faz `WHERE` nessas colunas em toda visita ao portal. **Alta prioridade** se a base crescer. |
| **D4** | `work_archives.type` como `enum` PHP (`WorkArchiveType: string`) com `string` no banco + `CHECK` | manter `string` livre | A v5 não tem CHECK; um valor inválido quebraria os dois consumidores do pivot. Recomenda-se CHECK no banco. |
| **D5** | Preservar a tabela `archives` compartilhada | criar `work_files` próprio | **Recomendado criar tabela própria** (`work_archives` → `work_files` com `path`, `name`, `extension`, `mime`, `size`, `description`, `view_pdf`, `highlight`) para eliminar o acoplamento e a ON DELETE CASCADE silenciosa. Custo: migrar os arquivos já gravados. |
| **D6** | Manter as views SQL `listagem_obras` / `listagem_categorias_obras` | trocar por queries Eloquent | **Recomendado trocar**: as views usam `INNER JOIN` (esconde obras com categoria/situação nula), `row_number()` como "ID" e não filtram `deleted_at` das tabelas relacionadas. Substituir por uma query com `LEFT JOIN` + `withTrashed` nas relacionadas. |

---

## 2. Models

```php
// app/Models/Works/Work.php
class Work extends Model
{
    use HasFactory, SoftDeletes;

    protected $fillable = [
        'title', 'edital', 'contract', 'work_category_id', 'status_id',
        'value', 'value_paid', 'start_date', 'end_date', 'cep', 'state',
        'city', 'neighborhood', 'street', 'house_number', 'complement',
        'latitude', 'longitude', 'company_holder', 'holder', 'gallery',
        'justification', 'description', 'estimated_completion_date',
        'completion_percentage',
    ];

    protected function casts(): array
    {
        return [
            'value' => 'decimal:2',
            'value_paid' => 'decimal:2',
            'latitude' => 'decimal:7',
            'longitude' => 'decimal:7',
            'start_date' => 'date',
            'end_date' => 'date',
            'estimated_completion_date' => 'date',
            'completion_percentage' => 'integer',
            'gallery' => 'boolean',
        ];
    }

    public function workCategory(): BelongsTo
    {
        return $this->belongsTo(WorkCategory::class);
    }

    public function status(): BelongsTo
    {
        return $this->belongsTo(WorkStatus::class);
    }

    public function files(): HasMany
    {
        return $this->hasMany(WorkFile::class);
    }

    public function gallery(): HasMany
    {
        return $this->files()->where('type', WorkFileType::Gallery);
    }
}
```

```php
// app/Enums/WorkStatusName.php  (rótulo exibido, usado pela regra da justificativa)
enum WorkStatusName: string
{
    case Open = 'Aberta';
    case InProgress = 'Em Andamento';
    case Finished = 'Finalizada';
    case Cancelled = 'Cancelada';
    case Suspended = 'Suspensa';

    public function requiresJustification(): bool
    {
        return in_array($this, [self::Cancelled, self::Suspended], true);
    }
}
```

> O `enum` **não** é um substituto da tabela `work_statuses` (que é
> administrável). O enum serve como **lista canônica de rótulos** para a
> regra de negócio da justificativa. Alternativa: adicionar a coluna
> `requires_justification` (boolean) em `work_statuses` — mais fiel ao
> data-driven da v5 e mais fácil de alterar sem deploy. **Recomendação:**
> coluna boolean na tabela.

### 2.1 `WorkStatus` / `WorkCategory`

```php
class WorkStatus extends Model
{
    use HasFactory, SoftDeletes;
    protected $fillable = ['name', 'requires_justification'];
}

class WorkCategory extends Model
{
    use HasFactory, SoftDeletes;
    protected $fillable = ['name'];

    public function works(): HasMany { return $this->hasMany(Work::class); }
}
```

### 2.2 Seeders (o que a v5 fazia dentro da migration)

```php
// database/seeders/WorkStatusSeeder.php
WorkStatus::upsert(
    [
        ['name' => 'Aberta', 'requires_justification' => false],
        ['name' => 'Em Andamento', 'requires_justification' => false],
        ['name' => 'Finalizada', 'requires_justification' => false],
        ['name' => 'Cancelada', 'requires_justification' => true],
        ['name' => 'Suspensa', 'requires_justification' => true],
    ],
    ['name']
);

WorkCategory::upsert([['name' => 'Infraestrutura']], ['name']);
```

O **resource** (`obras`) e o **item de menu** também são seed:
`resources` passa a ter `modulo_id` (FK para `modulos`) na v6 — o módulo deve
ser registrado em `modulos` com slug `obras` e as permissões
(`index`, `create`, `edit`, `delete`, `show`, `restore`, `inactivate`)
vinculadas.

---

## 3. Form Requests

### 3.1 `SaveWorkRequest`

```php
public function rules(): array
{
    return [
        'title' => ['required', 'string', 'max:255'],
        'edital' => ['required', 'string', 'max:255'],
        'contract' => ['required', 'string', 'max:255'],
        'work_category_id' => ['required', 'exists:work_categories,id'],
        'status_id' => ['required', 'exists:work_statuses,id'],
        'value' => ['required', 'numeric', 'min:0'],
        'value_paid' => ['required', 'numeric', 'min:0'],
        'start_date' => ['required', 'date_format:Y-m-d'],
        'end_date' => ['nullable', 'date_format:Y-m-d', 'after_or_equal:start_date'],
        'cep' => ['required', 'string', 'max:10'],
        'state' => ['required', 'string', 'size:2'],
        'city' => ['required', 'string', 'max:255'],
        'neighborhood' => ['required', 'string', 'max:255'],
        'street' => ['required', 'string', 'max:255'],
        'house_number' => ['required', 'string', 'max:10'],
        'complement' => ['nullable', 'string', 'max:255'],
        'latitude' => ['nullable', 'numeric', 'between:-90,90'],
        'longitude' => ['nullable', 'numeric', 'between:-180,180'],
        'company_holder' => ['required', 'string', 'max:255'],
        'holder' => ['required', 'string', 'max:255'],
        'gallery' => ['nullable', 'boolean'],
        'justification' => [
            'nullable', 'string', 'max:2000',
            Rule::requiredIf(fn () => $this->statusRequiresJustification()),
        ],
        'description' => ['nullable', 'string', 'max:5000'],
        'estimated_completion_date' => ['nullable', 'date_format:Y-m-d'],
        'completion_percentage' => ['nullable', 'integer', 'between:0,100'],
        'files' => ['nullable', 'array', 'max:10'],
        'files.*' => ['file', 'mimes:jpg,jpeg,png,pdf,doc,docx,xls,xlsx,zip', 'max:2048'],
        'files.*.description' => ['nullable', 'string', 'max:255'],
    ];
}

protected function prepareForValidation(): void
{
    $this->merge([
        'gallery' => $this->boolean('gallery'),
        'value' => $this->input('value') !== null
            ? str_replace(',', '.', str_replace('.', '', $this->input('value')))
            : null,
        'value_paid' => ...,
    ]);
}

private function statusRequiresJustification(): bool
{
    return WorkStatus::whereKey($this->input('status_id'))
        ->value('requires_justification') ?? false;
}
```

Mudanças deliberadas em relação à v5:

| Mudança | Motivo |
|---|---|
| `justification` vira `requiredIf` | A regra existia **só no JS** na v5 — precisa existir no servidor. |
| `value`/`value_paid` passam a `numeric` | Erro de digitação de dinheiro não pode mais entrar no banco. |
| `latitude`/`longitude` passam a `numeric` + `between` | Elimina a string `"não encontrado"` gravada pela v5. |
| Datas em `date_format:Y-m-d` | A normalização `Carbon::createFromFormat('d/m/Y')` da v5 lançava exceção 500 em formato inesperado. |
| `files.*` no lugar de `archive.*.file` | Nome mais claro; `max:10` limita o volume por requisição. |
| `authorize()` passa a checar a Policy | Na v5 retorna `true` e a permissão fica só no middleware. |

### 3.2 `DeleteWorkRequest`

```php
public function rules(): array
{
    return ['id' => ['required', 'exists:works,id']];
}
```

E o controller usa **route model binding**: `Work $work` na assinatura — o
`id` do request vira redundante. A v5 usava `$this->route('obra')`, que
**nunca existiu** como parâmetro de rota (o parâmetro é `{id}`) — bug
documentado em `05-REGRAS-E-PORTABILIDADE.md`.

### 3.3 Requests de categoria

`SaveWorkCategoryRequest` (`name` required max 255) e
`DeleteWorkCategoryRequest` (`id` exists), com o id vindo do **route model
binding** (`WorkCategory $workCategory`).

---

## 4. Policies e autorização

```php
class WorkPolicy
{
    public function viewAny(User $user): bool   { return $user->can('obras.index'); }
    public function view(User $user, Work $work): bool { return $user->can('obras.show'); }
    public function create(User $user): bool     { return $user->can('obras.create'); }
    public function update(User $user, Work $work): bool { return $user->can('obras.edit'); }
    public function delete(User $user, Work $work): bool { return $user->can('obras.delete'); }
    public function restore(User $user, Work $work): bool { return $user->can('obras.restore'); }
}
```

O mapeamento de permissão (`obras.index`, `obras.create`, ...) deve ser
mantido **idêntico** ao da v5 para não quebrar os perfis já cadastrados.
Recomenda-se manter o helper `has_access()` (ou um substituto
`user->can()`) com os mesmos slugs, e **eliminar** a heurística
`hasAccessSegment()` do middleware — no Laravel moderno a autorização é
declarativa, por rota e por policy.

> Atenção ao **alias** da v5: `categorias-obras` herda `obras`. Na
> reconstrução, dar à categoria o **próprio** par de permissões
> (`categorias-obras.index`, `.create`, `.edit`, `.delete`) é mais limpo, mas
> **quebra** os perfis existentes que hoje têm `obras.*` e dependem do alias
> para o módulo de Obras Paralisadas. Decisão de produto a registrar.

---

---

> **Continua em `12-API-E-EXECUCAO.md`:** os **endpoints REST/JSON**
> propostos, a **API Resource**, a estratégia de **fila, cache e storage**, a
> matriz de **riscos e decisões de equivalência funcional** e a **ordem
> sugerida de execução**.

## 5. Views Blade modernas

### 5.1 Componentes

| Componente | Origem na v5 |
|---|---|
| `<x-admin.work-form :work="$work" :categories="$categories" :statuses="$statuses" />` | `prefecture/works/form.blade.php` |
| `<x-admin.work-list />` (DataTable server-side via `DataTables::of($query)` ou Inertia) | `prefecture/works/index.blade.php` |
| `<x-portal.work-filters :filters="$filters" />` | `v5/resources/views/portal/components/obras/filtro.blade.php` |
| `<x-portal.work-table :works="$works" />` | `v5/resources/views/portal/components/obras/tabela.blade.php` |
| `<x-portal.work-map :works="$mapPoints" :state="$estado" />` | `v5/public/assets/js/custom/portal/work/geo.js` |
| `<x-portal.work-gallery :images="$images" />` | `v5/resources/views/portal/components/galerias/index.blade.php` |
| `<x-portal.work-files :files="$files" />` | `v5/resources/views/portal/components/obras/arquivos.blade.php` |

### 5.2 Eliminar as 6 cópias do portal

A v5 mantém `show.blade.php` e `filtro.blade.php` duplicados em
`modelo1..modelo6`. Na reconstrução, os **componentes** (`<x-portal.*>`) devem
ser únicos, e cada template apenas os inclui. As diferenças de CSS entre os
modelos passam a ficar no CSS do tema, não no Blade.

> **Item de risco:** `modelo5` referencia `$oldBidding` (variável inexistente)
> e todos os modelos compartilham a mesma estrutura. A unificação dos templates
> é uma tarefa do **módulo de templates**, não do módulo Obras — este dossiê
> registra a dependência.

### 5.3 Front do mapa

`geo.js` pode ser mantido (é JS puro com Leaflet), com duas correções:

1. trocar `http://{s}.google.com/...` por `https://`;
2. receber os pontos por **AJAX** (`/api/obras/mapa`) em vez de embutir todo o
   conjunto no HTML — resolve o carregamento integral em memória.

O `gerar-pdf-obras.js` deve ser **removido**: o PDF passa a ser gerado no
servidor (mesma origem do Excel/CSV), corrigindo o nome do arquivo
(`obras.pdf`) e a coluna "Valor Pago".

---
