# Terceirizados — Migração para Laravel Moderno (PHP 8.x / Laravel 11-12)

> Plano de reconstrução do módulo **Terceirizados**, que hoje existe **somente na v5**.
> O objetivo é preservar o **comportamento observável** (mesmas rotas, mesmas permissões,
> mesma exportação) enquanto se elimina a dívida técnica mapeada em
> `05-REGRAS-E-PORTABILIDADE.md`.

---

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

| # | Comportamento a preservar | Por quê |
|---|---|---|
| E1 | URI pública `/terceirizados` e `/terceirizados/{id}`. | Link externo já publicado (seed da Transparência Fiscal) e indexação de buscadores. |
| E2 | Nomes de rota `portal.terceirizados.index` / `portal.terceirizados.show`. | Templates do portal e breadcrumb referenciam por nome. |
| E3 | Slug de permissão `terceirizados` com as ações `.index`, `.create`, `.edit`, `.delete`. | `resources` (id 14) já existe no banco v5 e o `has_access` deriva do nome. |
| E4 | Colunas `year`, `month`, `employee_name`, `company`, `role`, `file_path`, `is_show`. | Relatórios/consultas externas e a planilha já em uso pelos municípios. |
| E5 | Exportação em **xls, pdf, csv e txt** com os **mesmos nomes de arquivo** (`terceirizados.xlsx`, `.pdf`, `.csv`, `.txt`). | Rotina do citizen. |
| E6 | Colunas do CSV/TXT/XLSX: Ano, Mês (extenso), Nome do Empregado, Empresa, Função. | Planilha consumida pela fiscalização. |
| E7 | Filtros `ano`, `mes`, `funcao`, `busca` na query string do portal. | Links compartilhados com filtros. |
| E8 | Paginação de 15 registros por página no portal. | — |
| E9 | Regra de anexos: **apenas PDF**. | — |
| E10 | Ano entre 2000 e `ano corrente + 1`; mês entre 1 e 12. | — |
| E11 | Soft delete (o registro deletado não aparece nem no CMS nem no portal). | — |
| E12 | O log de atividade no formato "O Terceirizado {nome} foi {evento}". | Auditoria já consultada. |

> **Pode mudar:** a representation interna dos anexos (JSON em coluna `text` → tabela
> própria), a implementação das permissões (middleware → Policy), a forma do filtro de
> busca (`ilike` → índice trigram), a localização dos meses (array → enum) e o formato
> técnico dos downloads (mantendo nome e conteúdo).

## 2. Schema de migração (Laravel 11/12)

```php
// (ilustrativo) database/migrations/2026_10_01_120000_create_outsourceds_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('outsourceds', function (Blueprint $table) {
            $table->id();
            $table->unsignedSmallInteger('year');
            $table->unsignedTinyInteger('month');
            $table->string('employee_name', 191);
            $table->string('company', 191);
            $table->string('role', 191);
            $table->boolean('is_show')->default(true);
            $table->timestamps();
            $table->softDeletes();

            $table->index(['is_show', 'year', 'month']);
            $table->index('company');
            $table->index('employee_name');
        });
    }

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

Tabela de anexos (substitui o JSON em `file_path`):

```php
// (ilustrativo)
Schema::create('outsourced_attachments', function (Blueprint $table) {
    $table->id();
    $table->foreignId('outsourced_id')->constrained()->cascadeOnDelete();
    $table->string('disk')->default('public');
    $table->string('path');                 // caminho relativo, ex.: terceirizados/5f3a.pdf
    $table->string('original_name');
    $table->string('description')->nullable();
    $table->unsignedBigInteger('size')->nullable();
    $table->string('mime_type')->nullable();
    $table->timestamps();

    $table->unique(['outsourced_id', 'path']);
});
```

Se a implantação exigir manter `file_path` (compatibilidade com consultas legadas),
adicionar uma coluna `jsonb` gerada ou uma view que exponha o JSON no mesmo formato:

```sql
-- (ilustrativo)
CREATE VIEW outsourceds_legacy AS
SELECT o.*,
       COALESCE((
           SELECT jsonb_agg(jsonb_build_object('path', a.path, 'description', a.description)
                            ORDER BY a.id)
           FROM outsourced_attachments a
           WHERE a.outsourced_id = o.id
       ), '[]'::jsonb) AS file_path
FROM outsourceds o;
```

> `file_path` **não** deve continuar como coluna `text` com JSON: é a origem do
> acoplamento descrito em `05-REGRAS-E-PORTABILIDADE.md` (A2).

## 3. Models

```php
// (ilustrativo) app/Models/Outsourced.php
namespace App\Models;

use App\Models\OutsourcedAttachment;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\HasMany;
use Illuminate\Database\Eloquent\SoftDeletes;
use Spatie\Activitylog\Traits\LogsActivity;

class Outsourced extends Model
{
    use SoftDeletes;
    use LogsActivity;

    protected $table = 'outsourceds';

    protected $fillable = [
        'year', 'month', 'employee_name', 'company', 'role', 'is_show',
    ];

    protected $casts = [
        'year'    => 'integer',
        'month'   => 'integer',
        'is_show' => 'boolean',
    ];

    protected $attributes = [
        'is_show' => true,
    ];

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

    public function getActivityDescriptionForEvent(string $eventName): string
    {
        return "O Terceirizado {$this->employee_name} foi {$eventName}";
    }
}
```

| Item | Decisão |
|---|---|
| Namespace | `App\Models\Outsourced` (a v5 usava `App\Prefecture` em `app/Http/Model/Prefecture/`). |
| Casts | `year`/`month` → `integer`; `is_show` → `boolean`. |
| `fillable` | `file_path` sai da lista (o anexo passa a ser uma relação). |
| Enum de mês | `enum Month: int { case Janeiro = 1; ... case Dezembro = 12; }` com `Month::from($model->month)->label()`. |
| Relação | `attachments(): HasMany`. |
| Auditoria | `spatie/laravel-activitylog` v4 (`LogsActivity`), preservando o texto de descrição. |
| Cast de competência | Atributo computado `competencia` (`"2026-03"`) para ordenação e filtro. |

## 4. Form Requests

```php
// (ilustrativo) app/Http/Requests/StoreOutsourcedRequest.php
namespace App\Http\Requests;

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

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

    public function rules(): array
    {
        return [
            'year'          => ['required', 'integer', 'min:2000', 'max:' . (now()->year + 1)],
            'month'         => ['required', 'integer', Rule::in(range(1, 12))],
            'employee_name' => ['required', 'string', 'max:191'],
            'company'       => ['required', 'string', 'max:191'],
            'role'          => ['required', 'string', 'max:191'],
            'attachments'   => ['nullable', 'array', 'max:10'],
            'attachments.*' => ['file', 'mimes:pdf', 'max:20480'],
            'descriptions.*' => ['nullable', 'string', 'max:255'],
        ];
    }
}
```

| Mudança em relação à v5 | Motivo |
|---|---|
| `authorize()` consulta a Policy | A v5 devolvia `true` e delegava tudo ao middleware de rota. |
| `max:191` em vez de `max:255` | Alinha com o `varchar(191)` do banco (limite real do PostgreSQL). |
| `max:20480` (20 MB) no arquivo | A v5 não limitava tamanho; o `post_max_size` do servidor era o único limite. |
| `max:10` anexos | Evita requisições gigantes. |
| `Rule::in(range(1, 12))` | Mais explícito que `min:1\|max:12`. |
| `descriptions.*` validado | A v5 não validava `arquivo_desc`. |

## 5. Policies e autorização

```php
// (ilustrativo) app/Policies/OutsourcedPolicy.php
namespace App\Policies;

use App\Models\Outsourced;
use App\Models\User;

class OutsourcedPolicy
{
    public function viewAny(User $user): bool  { return $user->can('terceirizados.index'); }
    public function view(User $user, Outsourced $o): bool { return $user->can('terceirizados.index'); }
    public function create(User $user): bool  { return $user->can('terceirizados.create'); }
    public function update(User $user, Outsourced $o): bool { return $user->can('terceirizados.edit'); }
    public function delete(User $user, Outsourced $o): bool { return $user->can('terceirizados.delete'); }
    public function deleteAny(User $user): bool { return $user->can('terceirizados.delete'); }
}
```

Migração da permissão: a v6 usa `resources.modulo_id` → `modulos`
(`v6/database/migrations/2024_11_01_150206_create_modulos_table.php`). Na v7, criar a
linha em `modulos` (slug `terceirizados`) e apontar a resource para ela, em vez do
`INSERT` cru da migration `2026_05_26_113000`.

## 6. Endpoints REST/JSON sugeridos

### Portal (público)

| Método | URI | Nome | Descrição |
|---|---|---|---|
| GET | `/terceirizados` | `portal.terceirizados.index` | HTML (Blade) — mesma URL da v5. |
| GET | `/terceirizados/{id}` | `portal.terceirizados.show` | HTML — mesma URL da v5. |
| GET | `/api/terceirizados` | `api.terceirizados.index` | JSON paginado, filtros `ano`, `mes`, `funcao`, `busca`, `per_page`. |
| GET | `/api/terceirizados/{id}` | `api.terceirizados.show` | JSON do registro + anexos. |

### CMS

| Método | URI | Nome |
|---|---|---|
| GET | `/cms/terceirizados` | `terceirizados.index` |
| GET | `/cms/terceirizados/create` | `terceirizados.create` |
| POST | `/cms/terceirizados` | `terceirizados.store` |
| GET | `/cms/terceirizados/{id}/edit` | `terceirizados.edit` |
| PUT | `/cms/terceirizados/{id}` | `terceirizados.update` |
| DELETE | `/cms/terceirizados/{id}` | `terceirizados.destroy` |
| POST | `/cms/terceirizados/destroyAll` | `terceirizados.destroyAll` |
| DELETE | `/cms/terceirizados/{id}/arquivo/{attachmentId}` | `terceirizados.destroy-arquivo` |
| GET | `/cms/datatable/terceirizados` | `datatable.terceirizados` |

> A rota de remoção de anexo passa a usar o **id do anexo** (não o índice posicional),
> eliminando a armadilha A13.

## 7. API Resources

```php
// (ilustrativo) app/Http/Resources/OutsourcedResource.php
namespace App\Http\Resources;

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

class OutsourcedResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id'            => $this->id,
            'year'          => $this->year,
            'month'         => $this->month,
            'month_label'   => \App\Enums\Month::from($this->month)->label(),
            'employee_name' => $this->employee_name,
            'company'       => $this->company,
            'role'          => $this->role,
            'is_show'       => (bool) $this->is_show,
            'attachments'   => $this->attachments->map(fn ($a) => [
                'id'          => $a->id,
                'description' => $a->description,
                'original_name' => $a->original_name,
                'url'         => Storage::disk($a->disk)->url($a->path),
            ]),
            'links' => [
                'portal' => route('portal.terceirizados.show', $this->id),
            ],
        ];
    }
}
```

## 8. Views Blade modernas

| Legado | Moderno |
|---|---|
| 6 cópias de `index/show/filtro` (uma por template) | **Um** conjunto de views (`resources/views/portal/terceirizados/`), incluído por todos os templates através de um componente. |
| Mapa de meses repetido 12× | `Month` enum + helper `month_label()`. |
| `json_decode` do `file_path` em 4 pontos | Accessor `Outsourced::attachments` (relação real). |
| Bloco `<tbody>` dentro do `@foreach` (HTML inválido) | `<tbody>` único com as linhas dentro. |
| JS de exclusão de anexo inline no `form.blade.php` | Componente Blade `<x-outsourced.attachment-list>` + Alpine.js ou Stimulus. |
| `Form::open` (LaravelCollective) | `<form method="POST" enctype="multipart/form-data">` com `@csrf`/`@method`. |
| DataTable JS global (`runDatatables`) | DataTables server-side em `/cms/datatable/terceirizados` **ou** Livewire/DataTable component. |

Componente de view sugerido:

```blade
{{-- (ilustrativo) resources/views/components/outsourced/attachment-list.blade.php --}}
<ul class="list-unstyled">
    @foreach ($attachments as $attachment)
        <li class="d-block p-1">
            <a href="{{ Storage::disk($attachment->disk)->url($attachment->path) }}" target="_blank">
                <i class="far fa-file-pdf fa-2x"></i>
                <small>{{ $attachment->description ?: $attachment->original_name }}</small>
            </a>
            @can('delete', $outsourced)
                <button type="button" class="btn btn-sm btn-flat btn-danger"
                        wire:click="removeAttachment({{ $attachment->id }})"
                        wire:confirm="Deseja realmente remover este arquivo?">Excluir</button>
            @endcan
        </li>
    @endforeach
</ul>
```

## 9. Fila, cache e storage

| Aspecto | v5 | v7 (proposta) |
|---|---|---|
| Storage do anexo | `FileTrait::saveUpload` → `storage/app/public/terceirizados` | `Storage::disk('public')->putFileAs('terceirizados', $file, $name)` — mesmo caminho para não quebrar URLs. |
| Remoção de anexo | Só remove o vínculo (órfão no disco) | Remover o vínculo **e** o arquivo na mesma transação; job `DeleteOrphanedAttachments` para a limpeza periódica. |
| Fila | Nenhuma | `GenerateOutsourcedExport` (queued) para exportações grandes; `DeleteOutsourcedAttachments` no delete. |
| Cache | Nenhum | `Cache::remember` para as listas de `years` e `roles` do filtro (invalidado a cada `save`/`delete`). |
| Rate limit | Nenhum | `throttle` nas rotas de exportação do portal. |

## 10. Exportações

```php
// (ilustrativo) app/Exports/OutsourcedsExport.php
namespace App\Exports;

use App\Enums\Month;
use Maatwebsite\Excel\Concerns\FromQuery;
use Maatwebsite\Excel\Concerns\ShouldAutoSize;
use Maatwebsite\Excel\Concerns\WithHeadings;
use Maatwebsite\Excel\Concerns\WithMapping;

class OutsourcedsExport implements FromQuery, WithHeadings, WithMapping, ShouldAutoSize
{
    public function __construct(private readonly array $filters) {}

    public function query()
    {
        return Outsourced::query()
            ->where('is_show', true)
            ->when($this->filters['ano']   ?? null, fn ($q, $v) => $q->where('year', $v))
            ->when($this->filters['mes']   ?? null, fn ($q, $v) => $q->where('month', $v))
            ->when($this->filters['funcao']?? null, fn ($q, $v) => $q->where('role', 'ilike', '%' . $v . '%'))
            ->when($this->filters['busca'] ?? null, fn ($q, $v) => $q->where(fn ($w) =>
                $w->where('employee_name', 'ilike', '%' . $v . '%')
                  ->orWhere('company', 'ilike', '%' . $v . '%')))
            ->orderByDesc('year')->orderByDesc('month')->orderBy('employee_name');
    }

    public function headings(): array
    {
        return ['Ano', 'Mês', 'Nome do Empregado', 'Empresa', 'Função'];
    }

    public function map($row): array
    {
        return [
            $row->year,
            Month::from($row->month)->label(),
            $row->employee_name,
            $row->company,
            $row->role,
        ];
    }
}
```

| Formato | v7 |
|---|---|
| XLSX / CSV | `Excel::download(new OutsourcedsExport($filters), 'terceirizados.xlsx')` — `FromQuery` evita carregar tudo em memória (corrige A5). |
| PDF | Manter `dompdf` com uma view dedicada (`exports/outsourceds-pdf.blade.php`), separada da view do Excel. |
| TXT | Manter o formato com pipes (mesmo cabeçalho) para não quebrar consumidores. |

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

| Risco | Impacto | Mitigação |
|---|---|---|
| Mudar o formato do `file_path` quebra consumidores que leem a coluna diretamente. | Alto se houver relatórios externos. | Manter a coluna durante uma transição, populada por um accessor/observer, ou expor uma view de compatibilidade. |
| Migração do banco v5 → v7 perde os anexos se o storage não for copiado. | Alto. | Inventariar `storage/app/public/terceirizados/` e reimportar os arquivos para a tabela `outsourced_attachments`. |
| Remover o `check.route.permission` sem recriar as permissões bloqueia o CMS. | Alto. | Criar `modulos`/`resources` com o slug `terceirizados` e as ações `.index/.create/.edit/.delete` **antes** de desligar o middleware. |
| Unificar os 6 templates do portal em uma view quebra os temas específicos. | Médio. | Manter os temas via um único componente parametrizado pelo layout ativo. |
| Trocar `ilike` por índice trigram muda o comportamento da busca com acentos. | Médio. | Manter `unaccent`/`ILIKE` além do índice GIN (`pg_trgm`), ou normalizar em aplicação. |
| Ajustar o nome do arquivo exportado quebra rotinas. | Baixo. | Manter `terceirizados.xlsx/.pdf/.csv/.txt`. |
| Limitar a 20 MB o anexo pode reprovar arquivos legados já aceitos. | Baixo. | Definir o limite com a prefeitura antes do go-live. |
