# Fornecedores — Migração para Laravel Moderno

> Plano de reconstrução do módulo em **PHP 8.x + Laravel 11/12**.

---

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

| Item | Regra |
|---|---|
| Campos | nome, e-mail, site, telefone, WhatsApp, endereço, mapa (URL), logotipo e ícone |
| Slug derivado do nome | mantido, mas deve poder ser preservado em edição |
| Telefone só com dígitos | idem |
| "Fornecedor ativo" | exatamente um fornecedor marcado como ativo, exibido na página de Suporte e no rodapé do CMS |
| Listagem | mesma composição de colunas (com alias `telefone`/`endereco` se possível) |
| Exclusão | soft delete (o registro sai da listagem) |

---

## 2. Schema de migração Laravel

```php
Schema::create('suppliers', function (Blueprint $table) {
    $table->id();
    $table->string('name');
    $table->string('slug')->unique();
    $table->string('email')->nullable();      // nullable (como na v5)
    $table->string('site')->nullable();        // nullable (como na v5)
    $table->string('phone', 20)->nullable();
    $table->string('whatsapp', 20)->nullable();
    $table->string('address', 500)->nullable();
    $table->string('map_url', 500)->nullable(); // substitui 'locale'
    $table->string('image')->nullable();         // caminho relativo no disk public
    $table->string('icon')->nullable();
    $table->boolean('is_active')->default(false);
    $table->timestamps();
    $table->softDeletes();
});
```

### 2.1 Restrição de "um ativo"

```php
// índice parcial único: no máximo um fornecedor ativo
DB::statement('CREATE UNIQUE INDEX suppliers_one_active ON suppliers (is_active) WHERE is_active = true');
```

Alternativa mais portável (SQLite/MySQL): garantir a unicidade na
aplicação, dentro de uma transação, e validar com um teste.

### 2.2 Migração dos dados

```php
DB::table('suppliers')->orderBy('id')->get()->each(function ($s) {
    Supplier::updateOrCreate(
        ['slug' => $s->slug],
        [
            'name'      => $s->name,
            'email'     => $s->email,
            'site'      => $s->site,
            'phone'     => $s->phone,
            'whatsapp'  => $s->whatsapp,
            'address'   => $s->address,
            'map_url'   => $s->locale,
            'image'     => $s->image,
            'icon'      => $s->icon,
            'is_active' => (bool) $s->fornecedor_ativo,
        ]
    );
});
```

> Antes de criar o índice único, resolver o caso de múltiplos ativos:
> manter apenas o mais recente.

---

## 3. Modelos

```php
// app/Models/Supplier.php
final class Supplier extends Model
{
    use HasFactory, SoftDeletes;

    protected $fillable = [
        'name', 'slug', 'email', 'site', 'phone', 'whatsapp',
        'address', 'map_url', 'image', 'icon', 'is_active',
    ];

    protected $casts = ['is_active' => 'boolean'];

    public function scopeActive(Builder $q): Builder
    {
        return $q->where('is_active', true);
    }

    public function getPhoneFormattedAttribute(): string
    {
        return preg_replace('/(\d{2})(\d{5})(\d{4})/', '$1 $2-$3', (string) $this->phone);
    }

    public function getImageUrlAttribute(): ?string
    {
        return $this->image ? Storage::disk('public')->url($this->image) : null;
    }
}
```

> A view SQL `fornecedores` pode ser mantida como **view de
> compatibilidade** (`CREATE OR REPLACE VIEW`) durante a transição, ou
> eliminada de uma vez — a v7 deve usar o model + API Resource.

---

## 4. Form Requests

```php
final class StoreSupplierRequest extends FormRequest
{
    public function authorize(): bool
    {
        return $this->user()->can('create', Supplier::class);
    }

    public function rules(): array
    {
        return [
            'name'     => ['required', 'string', 'min:5', 'max:120'],
            'email'    => ['nullable', 'email', 'max:191'],
            'site'     => ['nullable', 'url', 'max:191'],
            'phone'    => ['nullable', 'string', 'min:5', 'max:20'],
            'whatsapp' => ['nullable', 'string', 'min:5', 'max:20'],
            'address'  => ['nullable', 'string', 'min:5', 'max:500'],
            'map_url'  => ['nullable', 'url', 'max:500'],
            'image'    => ['nullable', 'image', 'mimes:jpeg,png,jpg,gif', 'max:2048'],
            'icon'     => ['nullable', 'image', 'mimes:jpeg,png,jpg,gif', 'max:2048'],
        ];
    }

    protected function prepareForValidation(): void
    {
        $this->merge([
            'phone'    => $this->digitsOnly($this->input('phone')),
            'whatsapp' => $this->digitsOnly($this->input('whatsapp')),
        ]);
    }

    private function digitsOnly(?string $value): ?string
    {
        return $value === null ? null : (preg_replace('/\D/', '', $value) ?: null);
    }
}
```

> A sanitização do telefone passa a acontecer **antes** da validação
> (no `prepareForValidation`), o que elimina a inconsistência entre a
> máscara do cliente e o `min:5` do servidor.

---

## 5. Policies

```php
final class SupplierPolicy
{
    public function viewAny(User $u): bool   { return $u->hasCapability('supplier.index'); }
    public function create(User $u): bool    { return $u->hasCapability('supplier.create'); }
    public function update(User $u, Supplier $s): bool { return $u->hasCapability('supplier.edit'); }
    public function delete(User $u, Supplier $s): bool { return $u->hasCapability('supplier.delete'); }
    public function activate(User $u, Supplier $s): bool { return $u->hasCapability('supplier.edit'); }
}
```

E o recurso correspondente, que **deve** ser criado:

| recurso | ação | descrição |
|---|---|---|
| `supplier` | `index` | Listar fornecedores |
| `supplier` | `create` | Cadastrar fornecedor |
| `supplier` | `edit` | Editar fornecedor |
| `supplier` | `delete` | Excluir fornecedor |
| `supplier` | `activate` | Ativar fornecedor |

> O slug **`supplier`** deve ser mantido: é o que o menu e a view do
> legado já usam (`has_access(['supplier.index'])`).

---

## 6. Endpoints REST/JSON sugeridos

| Método | URI | Descrição | Autorização |
|---|---|---|---|
| GET | `/api/suppliers` | lista paginada | `supplier.index` |
| POST | `/api/suppliers` | cria | `supplier.create` |
| GET | `/api/suppliers/{supplier}` | detalhe | `supplier.index` |
| PUT | `/api/suppliers/{supplier}` | atualiza | `supplier.edit` |
| DELETE | `/api/suppliers/{supplier}` | exclui (soft) | `supplier.delete` |
| POST | `/api/suppliers/{supplier}/activate` | marca como ativo (desativando o anterior, em transação) | `supplier.activate` |
| GET | `/api/suppliers/active` | fornecedor ativo | `supplier.index` |

Todas as rotas de escrita passam a exigir **autenticação** (`auth` +
Policy) — corrigindo a falha de segurança do legado.

---

## 7. API Resources

```php
final class SupplierResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id'         => $this->id,
            'name'       => $this->name,
            'slug'       => $this->slug,
            'email'      => $this->email,
            'site'       => $this->site,
            'phone'      => $this->phone,
            'phone_formatted' => $this->phone_formatted,
            'whatsapp'   => $this->whatsapp,
            'address'    => $this->address,
            'map_url'    => $this->map_url,
            'image'      => $this->image_url,
            'icon'       => $this->icon ? Storage::disk('public')->url($this->icon) : null,
            'is_active'  => $this->is_active,
        ];
    }
}
```

O `slug` e o `whatsapp` passam a ser expostos (a view SQL legado não os
expunha).

---

## 8. Views Blade modernas

| Tela legada | v7 |
|---|---|
| `fornecedor/index.blade.php` | `<x-supplier-table>` (Livewire/Inertia) com coluna de logotipo e ícone |
| `fornecedor/create.blade.php` + `form.blade.php` | `<x-supplier-form :supplier="..." />` |
| `fornecedor/edit.blade.php` | idem (o mesmo componente) |
| `admin/layouts/about.blade.php` (Suporte) | `<x-supplier-card :supplier="..." />` com estado vazio explícito |
| Rodapé do AdminLTE | `@inject` substituído por um view composer com `Supplier::active()` em cache |

Componente da listagem (Livewire 3):

```php
final class SupplierTable extends Component
{
    public function render(): View
    {
        return view('livewire.supplier-table', [
            'suppliers' => Supplier::query()->latest('created_at')->paginate(15),
        ]);
    }

    public function activate(int $id): void
    {
        abort_unless(auth()->user()->can('activate', Supplier::class), 403);

        DB::transaction(function () use ($id) {
            Supplier::query()->where('is_active', true)->update(['is_active' => false]);
            Supplier::query()->whereKey($id)->update(['is_active' => true]);
        });
    }
}
```

---

## 9. Fila, cache e storage

| Recurso | Proposta v7 |
|---|---|
| Storage | `Storage::disk('public')->putFileAs('img/images_fornecedores', $file, $name)`; nome = `Str::uuid()` |
| Cache | `Cache::rememberForever('supplier.active', fn () => Supplier::active()->first())`, invalidado no `update`/`activate` |
| Fila | Nenhuma. Opcional: limpeza de arquivos órfãos |
| Sessão | Não utilizada |
| Logs | Registrar auditoria (`activity()`) em create/update/delete/activate |

---

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

| Risco | Impacto | Mitigação |
|---|---|---|
| Tornar `email`/`site` nullable |Behavior diferente do que a v6 impõe | já é o estado da v5; a v7 deve explicitar a decisão |
| Índice único de "um ativo" | installations com dois ativos quebram | resolver antes de aplicar o índice |
| Renomear `locale` -> `map_url` | consumidores que leem a view SQL quebram | manter uma view de compatibilidade ou migrar em bloco |
| Remover a view SQL `fornecedores` | o `DatatableController` legado quebra | remover junto, na mesma entrega |
| `slug` único | dois fornecedores com o mesmo nome colidem | desambiguar com sufixo no backfill |
| Passar a exigir autenticação nas escritas | integrações externas que gravam por URL quebram | avisar; o ganho de segurança justifica |
| Trocar o toggle por "sempre ativar" | o operador perde a capacidade de desativar | manter o toggle OU adicionar um botão "Desativar" explícito |
| Apagar arquivos no `delete` | exclusão irreversível das imagens | manter soft delete do registro e apagar o arquivo só na "remoção definitiva" |
