# Licitantes Sancionados — Plano de Migração para Laravel Moderno

> Destino: **PHP 8.x + Laravel 11/12** (a "v7"). O plano abaixo assume os padrões
> adotados nos demais dossiês desta coleção (Eloquent enxuto, Form Requests, Policies,
> API Resources, testes Pest/PHPUnit).

---

## 1. Schema de migração equivalente

```php
// database/migrations/2026_XX_XX_000000_create_sanctioned_bidders_table.php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
    public function up(): void
    {
        Schema::create('sanctioned_bidders', function (Blueprint $table) {
            $table->id();
            $table->unsignedSmallInteger('year')->comment('Ano da sanção');
            $table->string('sanction_number', 50)->comment('Número do processo/sanção');
            $table->string('company_name', 255)->comment('Razão social ou nome');
            $table->string('tax_id', 18)->comment('CPF/CNPJ');
            $table->date('sanction_starts_at');
            $table->date('sanction_ends_at');
            $table->timestamps();
            $table->softDeletes();
            $table->string('slug', 191)->nullable()->unique();
        });
    }

    public function down(): void
    {
        Schema::dropIfExists('sanctioned_bidders');
    }
};
```

Anexos em tabela própria (substituindo o JSON na coluna `text`):

```php
Schema::create('sanctioned_bidder_attachments', function (Blueprint $table) {
    $table->id();
    $table->foreignId('sanctioned_bidder_id')
          ->constrained('sanctioned_bidders')
          ->cascadeOnDelete();
    $table->string('disk')->default('public');
    $table->string('path');
    $table->string('original_name')->nullable();
    $table->string('description', 255)->nullable();
    $table->unsignedBigInteger('size')->nullable();
    $table->string('mime_type')->nullable();
    $table->timestamps();
});
```

### Decisões de equivalência

| Decisão | Justificativa |
|---|---|
| Manter os nomes de coluna em português ou renomear? | **Renomear para inglês** (`sanctioned_bidders`), pois o restante da v7 segue convenção nova; a migração de dados mapeia explicitamente |
| `slug` com `unique()` | A v5 não tem índice; a v7 deve garantir unicidade (o gerador inclui o id, mas o banco precisa garantir) |
| Anexos em tabela 1:N | Elimina o JSON em `text`, permite download controlado, metadados e ordenação estável por índice |
| `softDeletes` mantido | Coerente com a v5 e com o resto do portal (exclusão lógica, auditabilidade) |
| `sanction_starts_at` / `sanction_ends_at` | Mantém a semântica original; **não** modelar como `starts_at`/`ends_at` genéricos, para não confundir com vigência de outros conceitos |
| Índice composto | `index(['year', 'sanction_starts_at'])` para os filtros padrão do portal |

## 2. Modelos

```php
// app/Models/SanctionedBidder.php
namespace App\Models;

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\SoftDeletes;
use Illuminate\Support\Str;

class SanctionedBidder extends Model
{
    use HasFactory, SoftDeletes;

    protected $fillable = [
        'year',
        'sanction_number',
        'company_name',
        'tax_id',
        'sanction_starts_at',
        'sanction_ends_at',
    ];

    protected $casts = [
        'year' => 'integer',
        'sanction_starts_at' => 'date',
        'sanction_ends_at' => 'date',
    ];

    protected static function booted(): void
    {
        // Slug gerado no creating/saving, nunca no created (evita o UPDATE extra da v5)
        static::saving(function (self $bidder): void {
            $bidder->slug = Str::slug($bidder->company_name . '-' . ($bidder->id ?? 'novo'));
        });
    }

    public function attachments(): HasMany
    {
        return $this->hasMany(SanctionedBidderAttachment::class)->orderBy('id');
    }

    public function getRouteKeyName(): string
    {
        return 'slug';
    }
}
```

| Item | v5 | v7 | Nota |
|---|---|---|---|
| Classe | `App\Http\Model\Prefecture\LicitanteSancionado` | `App\Models\SanctionedBidder` | Namespace `App\Models` (PSR-4), sem autoload por classmap |
| Casts | `protected $dates` | `protected $casts` | `date` explícito |
| Slug | eventos `created` + `updating` | evento `saving` | Um único ponto de geração; sem UPDATE extra |
| Slug helper | `str_slug()` | `Str::slug()` | Helper removido no Laravel 6 |
| `SoftDeletes` | ✔ | ✔ | Mantido |
| Relação de anexos | JSON em coluna `text` | `attachments(): HasMany` | |
| `getRouteKeyName()` | não existia | retorna `slug` | Route model binding por slug, dispensando o `firstOrFail()` manual |

**Decisão a discutir com o negócio:** o slug hoje é recalculado quando o nome muda
(v5). Na v7, recomputar quebra links publicados. Alternativa: gerar o slug apenas na
criação e **mantê-lo estável**; o nome pode mudar sem afetar a URL. Recomenda-se
manter estável.

## 3. Form Requests

```php
// app/Http/Requests/StoreSanctionedBidderRequest.php
namespace App\Http\Requests;

use Illuminate\Foundation\Http\FormRequest;
use Illuminate\Validation\Rule;

class StoreSanctionedBidderRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('create', SanctionedBidder::class);
    }

    public function rules(): array
    {
        return [
            'year'              => ['required', 'integer', 'min:1900', 'max:2100'],
            'sanction_number'   => ['required', 'string', 'max:50'],
            'company_name'      => ['required', 'string', 'max:255'],
            'tax_id'            => ['required', 'string', 'max:18'],
            'sanction_starts_at'=> ['required', 'date_format:d/m/Y'],
            'sanction_ends_at'  => ['required', 'date_format:d/m/Y', 'after_or_equal:sanction_starts_at'],
            'attachments'       => ['nullable', 'array', 'max:20'],
            'attachments.*'     => ['file', 'mimes:pdf', 'max:10240'],
            'attachments.*.description' => ['nullable', 'string', 'max:255'],
        ];
    }

    protected function prepareForValidation(): void
    {
        $this->merge([
            'year' => is_numeric($this->input('year')) ? (int) $this->input('year') : $this->input('year'),
        ]);
    }
}
```

| Regra v5 | Regra v7 | Motivo |
|---|---|---|
| `date_format:d/m/Y` | mantido | Preserva o contrato com o operador (máscara `br-data`) |
| — | `after_or_equal:sanction_starts_at` **novo** | A v5 não impedia `fim < início` |
| `arquivo` `nullable\|array` | `attachments` `nullable\|array\|max:20` | Limita o número de anexos (a v5 não tinha teto) |
| `arquivo.*` `file\|mimes:pdf\|max:10240` | idem | Mantém PDF e 10 MB |
| `authorize(): true` | `can('create', ...)` | Autorização passa a ser da **Policy**, não do middleware |
| — | `prepareForValidation()` convertendo `year` | Evita "não numérico" por causa de máscara de input |

A conversão de data fica **no request** (não em `prepareData()` no controller):

```php
protected function validatedData(): array
{
    $data = parent::validatedData();
    foreach (['sanction_starts_at', 'sanction_ends_at'] as $field) {
        if (! empty($data[$field])) {
            $data[$field] = Carbon::createFromFormat('d/m/Y', $data[$field])->startOfDay();
        }
    }
    return $data;
}
```

`Carbon::createFromFormat` elimina a ambiguidade de `strtotime()` (ver A11 no dossiê
de regras).

## 4. Policies / autorização

```php
// app/Policies/SanctionedBidderPolicy.php
namespace App\Policies;

use App\Models\SanctionedBidder;
use App\Models\User;

class SanctionedBidderPolicy
{
    public function viewAny(User $user): bool  { return $user->can('sanctioned-bidders.index'); }
    public function view(User $user): bool    { return $user->can('sanctioned-bidders.show'); }
    public function create(User $user): bool  { return $user->can('sanctioned-bidders.create'); }
    public function update(User $user): bool  { return $user->can('sanctioned-bidders.edit'); }
    public function delete(User $user): bool  { return $user->can('sanctioned-bidders.delete'); }
    public function deleteAttachment(User $user): bool { return $user->can('sanctioned-bidders.edit'); }
}
```

Registro:

```php
// app/Providers/AppServiceProvider.php
protected $policies = [
    SanctionedBidder::class => SanctionedBidderPolicy::class,
];
```

E o **resource** correspondente, que na v5 **não existe** (ver G5):

```php
// database/seeders/ResourcesSeeder.php (trecho)
[
    'slug' => 'sanctioned-bidders',
    'name' => 'Licitantes Sancionados',
    'description' => 'Publicação de sanções a licitantes',
    'modulo_id' => $moduloLicitacoes->id,
],
```

Vinculando as permissões `sanctioned-bidders.{index,create,edit,delete}` ao
perfil, o módulo passa a ser operável por **qualquer perfil autorizado** — e não
apenas por `super-admin`, como na v5.

## 5. Endpoints REST/JSON sugeridos

### CMS (autenticado)

| Método | URI | Action | Policy |
|---|---|---|---|
| GET | `/cms/licitantes-sancionados` | `index` | `viewAny` |
| GET | `/cms/licitantes-sancionados/criar` | `create` | `create` |
| POST | `/cms/licitantes-sancionados` | `store` | `create` |
| GET | `/cms/licitantes-sancionados/{sanctionedBidder}/editar` | `edit` | `update` |
| PUT | `/cms/licitantes-sancionados/{sanctionedBidder}` | `update` | `update` |
| DELETE | `/cms/licitantes-sancionados/{sanctionedBidder}` | `destroy` | `delete` |
| DELETE | `/cms/licitantes-sancionados/{sanctionedBidder}/anexos/{attachment}` | `destroyAttachment` | `deleteAttachment` |
| GET | `/cms/licitantes-sancionados/{sanctionedBidder}/anexos/{attachment}/download` | `download` | `update` |

### Portal (público)

| Método | URI | Action |
|---|---|---|
| GET | `/licitantes-sancionados` | `index` (com filtros) |
| GET | `/licitante-sancionado/{slug}` | `show` |

### API (público, read-only) — **novo** na v7

| Método | URI | Descrição |
|---|---|---|
| GET | `/api/v1/licitantes-sancionados` | Lista paginada, filtros `ano`, `busca`, `dt_inicio`, `dt_fim` |
| GET | `/api/v1/licitantes-sancionados/{slug}` | Ficha + anexos |
| GET | `/api/v1/licitantes-sancionados/{slug}/anexos/{id}` | Download do anexo |

A API **não existia** na v5 (o módulo não tinha `routes/api.php`); é uma melhoria
recomendada, útil para o portal de transparência e para integrações.

## 6. Recursos de API

```php
// app/Http/Resources/SanctionedBidderResource.php
namespace App\Http\Resources;

use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;

class SanctionedBidderResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id'            => $this->id,
            'slug'          => $this->slug,
            'ano'           => $this->year,
            'numero_sancao' => $this->sanction_number,
            'nome_razao_social' => $this->company_name,
            'cpf_cnpj'      => $this->tax_id,
            'inicio_sancao' => $this->sanction_starts_at->toDateString(),
            'fim_sancao'    => $this->sanction_ends_at->toDateString(),
            'arquivos'      => SanctionedBidderAttachmentResource::collection($this->whenLoaded('attachments')),
        ];
    }
}
```

A listagem paginada usa `AnonymousResourceCollection` com
`SanctionedBidderResource::collection($paginator)`, o que já emite
`links`/`meta` no formato JSON:API.

---

> **Continuação:** as **views Blade modernas**, a **infraestrutura** (fila, cache,
> storage), a **tabela de riscos e decisões de equivalência** e o **checklist de
> migração de dados** estão em `09-MIGRACAO-FRONTEND-E-DADOS.md`.

