# Licitações — Plano de Migração para Laravel Moderno (v7)

> Alvo: PHP 8.x + Laravel 11/12, PostgreSQL. Princípio: **equivalência funcional**
> — nenhuma regra de negócio observável pode mudar sem decisão explícita do negócio.

---

## 1. Schema de migração (equivalente ao legado)

### 1.1 `biddings`

```php
Schema::create('biddings', function (Blueprint $table) {
    $table->id();                                   // id
    $table->string('number', 15);                   // número da licitação
    $table->timestamp('publication_edital_date')->nullable();
    $table->timestamp('competition_date')->nullable();
    $table->timestamp('homologation_date')->nullable();
    $table->text('object');
    $table->string('process_number')->nullable();
    $table->double('value');
    $table->boolean('show_value');
    $table->foreignId('management_units_id')->constrained('management_units');
    $table->foreignId('bidding_modalities_id')->constrained('bidding_modalities');
    $table->foreignId('bidding_types_id')->constrained('bidding_types');
    $table->foreignId('statuses_id')->constrained('statuses');
    $table->text('local')->nullable();
    $table->boolean('is_show')->nullable();
    $table->timestamps();
    $table->softDeletes();
});
```

Mudanças recommended:

| Mudança | Justificativa |
|---|---|
| `value` como `decimal(14,2)` | `double precision` provoca erro de arredondamento em valores monetários grandes. Migrar com `numeric(value, 2)`. |
| `process_number` como `string(191)` | igual ao legado (191 evita limite de índice do PostgreSQL). |
| **Índice único composto** | `unique(['number', 'bidding_modalities_id'], 'biddings_number_modality_unique')` — implementa a unicidade que hoje é só em controller. Se a regra real for “número + modalidade + secretarias”, manter a verificação no service. |
| Índice em `publication_edital_date`, `statuses_id`, `bidding_modalities_id` | A listagem pública filtra e ordena por essas colunas. |
| `is_show` com `default(false)` | Evita `NULL` separe do `false` no filtro “Apenas Shows”. |
| `number` com `string(15)` | Mantido do legado (restrição do formulário). |

### 1.2 Demais tabelas

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

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

Schema::create('bidding_jurisdictionals', function (Blueprint $table) {
    $table->id();
    $table->foreignId('biddings_id')->constrained('biddings')->cascadeOnDelete();
    $table->foreignId('contents_id')->constrained('contents')->cascadeOnDelete();
    $table->timestamps();
    $table->unique(['biddings_id', 'contents_id']);
});

Schema::create('biddings_has_archives', function (Blueprint $table) {
    $table->id();
    $table->foreignId('biddings_id')->constrained('biddings')->cascadeOnDelete();
    $table->foreignId('archives_id')->constrained('archives')->cascadeOnDelete();
    $table->softDeletes();
    $table->timestamps();
    $table->unique(['biddings_id', 'archives_id']);
});
```

`contracts` ganha `bidding_id` com `nullOnDelete()` (equivalente ao
`ON DELETE SET NULL` legado) e deve ser avaliada a **reintrodução** de `agent`
(“Fiscal do Contrato”), que existe na v5 e foi removida na v6.

### 1.3 View SQL `licitacao` — substituta

A view é consumida apenas pelo DataTable do CMS. Duas opções:

**Opção A — manter a view** (menor effort, mesma semântica):

```sql
CREATE OR REPLACE VIEW licitacao AS
SELECT row_number() OVER (ORDER BY b.created_at DESC) AS value,
       b.number                                   AS numero,
       b.value                                    AS valor,
       bm.name                                    AS modalidade,
       to_char(b.publication_edital_date, 'DD/MM/YYYY') AS data_publicacao,
       bt.name                                    AS tipo,
       s.name                                     AS situacao,
       b.id                                       AS id
FROM biddings b
JOIN bidding_modalities bm ON bm.id = b.bidding_modalities_id
JOIN bidding_types bt      ON bt.id = b.bidding_types_id
JOIN statuses s            ON s.id = b.statuses_id
WHERE b.deleted_at IS NULL;
```

Ganhos: trocar o `INNER JOIN` por `LEFT JOIN` (para não sumir com registros
órfãos), remover o `::timestamp with time zone` (deslocamento de fuso) e
trocar `value` por `row_number` para eliminar a ambiguidade de nomenclatura.

**Opção B — eliminar a view** (recomendado): um `BiddingResource`/`Query Builder`
com os mesmos nomes de coluna (`numero`, `valor`, `modalidade`,
`data_publicacao`, `tipo`, `situacao`) elimina a dependência de DDL no deploy.
Se a Opção B for adotada, a view pode ser removida depois da migração dos
consumidores (único consumidor: `Prefecture\DatatableController@get_licitacao`).

## 2. Models

### 2.1 `App\Models\Bidding`

```php
class Bidding extends Model
{
    use HasFactory, SoftDeletes;

    protected $fillable = [
        'number', 'publication_edital_date', 'competition_date', 'homologation_date',
        'object', 'process_number', 'value', 'show_value', 'local', 'is_show',
        'management_units_id', 'bidding_modalities_id', 'bidding_types_id', 'statuses_id',
    ];

    protected $casts = [
        'publication_edital_date' => 'datetime',
        'competition_date'        => 'datetime',
        'homologation_date'       => 'datetime',
        'value'                   => 'decimal:2',
        'show_value'              => 'boolean',
        'is_show'                 => 'boolean',
    ];

    public function managementUnit(): BelongsTo   { return $this->belongsTo(ManagementUnit::class, 'management_units_id'); }
    public function modality(): BelongsTo          { return $this->belongsTo(BiddingModality::class, 'bidding_modalities_id'); }
    public function type(): BelongsTo              { return $this->belongsTo(BiddingType::class, 'bidding_types_id'); }
    public function status(): BelongsTo             { return $this->belongsTo(Status::class, 'statuses_id'); }
    public function contents(): BelongsToMany      { return $this->belongsToMany(Content::class, 'bidding_jurisdictionals', 'biddings_id', 'contents_id')->withTimestamps(); }
    public function archives(): BelongsToMany      { return $this->belongsToMany(Archive::class, 'biddings_has_archives', 'biddings_id', 'archives_id')->withTimestamps(); }
    public function proposals(): HasMany           { return $this->hasMany(Proposal::class, 'biddings_id'); }
    public function contracts(): HasMany           { return $this->hasMany(Contract::class, 'bidding_id'); }
}
```

Regras **não podem** mudar:

- `show_value == false` ⇒ o portal exibe “Indisponível” no lugar do valor.
- `is_show` continua sendo o marcador de “show artístico” e o filtro “Apenas Shows”
  continua sendo `where('is_show', true)`.
- A listagem pública **não** filtra por `is_show` por padrão.
- Os accessors que formatavam datas (`d-m-Y`, `d-m-Y H:i`) viram
  `->format(...)` explícito na view ou no `ApiResource` — não no model.

### 2.2 Enums sugeridos

```php
enum BiddingStatus: string   // espelha `statuses` (categoria 2)
{
    case ABERTA = '5';
    case FINALIZADA = '6';
    case CANCELADA = '7';
}

enum ExportFormat: string { case XLS='xls'; case PDF='pdf'; case CSV='csv'; case TXT='txt'; }
```

Modalidades e tipos continuam em tabelas (são cadastráveis pelo operador),
não enums.

### 2.3 Repositórios / Services

Substituir `Repository` + `upInsert` por um service de domínio:

```php
final class BiddingService
{
    public function __construct(private BiddingRepository $biddings, private ArchiveService $files) {}

    public function create(array $data, array $jurisdictionalIds, array $files): Bidding { /* transação única */ }

    public function update(Bidding $bidding, array $data, array $jurisdictionalIds, array $files): Bidding { /* sync() + arquivos */ }

    public function delete(Bidding $bidding): void { /* bloqueia se houver propostas */ }
}
```

Regras obrigatórias:

- `DB::transaction` envolvendo licitação + secretarias + arquivos (hoje a pivot
  é gravada **fora** da transação do repository).
- Nada de `dd()`: exceção de domínio (`BiddingHasProposalsException`,
  `DuplicateBiddingNumberException`) capturada e convertida em mensagem de
  negócio.
- Datas gravadas em ISO-8601 no request; conversão para o formato de exibição
  só na view.

## 3. Form Requests

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

    public function rules(): array
    {
        return [
            'number'              => ['required', 'string', 'max:15',
                                     Rule::unique('biddings', 'number')
                                         ->where(fn ($q) => $q->whereNull('deleted_at')
                                             ->where('bidding_modalities_id', $this->input('bidding_modalities_id')))],
            'statuses_id'         => ['required', Rule::exists('statuses', 'id')->where('status_category_id', 2)],
            'management_units_id' => ['required', Rule::exists('management_units', 'id')],
            'bidding_types_id'    => ['required', Rule::exists('bidding_types', 'id')],
            'bidding_modalities_id'=> ['required', Rule::exists('bidding_modalities', 'id')],
            'jurisdicionado'      => ['required', 'array', 'min:1'],
            'jurisdicionado.*'    => ['integer', Rule::exists('contents', 'id')],
            'object'              => ['required', 'string'],
            'show_value'          => ['required', 'boolean'],
            'value'               => ['required', 'numeric', 'min:0'],
            'process_number'      => ['nullable', 'string', 'max:191'],
            'publication_edital_date' => ['nullable', 'date'],
            'competition_date'    => ['nullable', 'date'],
            'homologation_date'   => ['nullable', 'date'],
            'local'               => ['nullable', 'string', 'max:255'],
            'is_show'             => ['nullable', 'boolean'],
            'archive'             => ['nullable', 'array'],
            'archive.*.0'         => ['file', 'max:20480'],   // 20 MB — valor a definir com o negócio
            'archive.*.description'=> ['nullable', 'string', 'max:255'],
        ];
    }
}
```

Observações:

- `publication_edital_date` e `competition_date` continuam **opcionais**
  (equivalência funcional com o legado).
- O mime type de arquivo, hoje livre, deve ser restringido — **decisão de negócio**.
- `is_show` como `boolean` elimina o `merge(["is_show" => isset(...)])`.

## 4. Policies

```php
class BiddingPolicy
{
    public function viewAny(User $u): bool  { return $u->can('licitacao.index'); }
    public function create(User $u): bool   { return $u->can('licitacao.create'); }
    public function update(User $u): bool   { return $u->can('licitacao.edit'); }
    public function delete(User $u): bool   { return $u->can('licitacao.delete'); }
}
```

A chave de permissão **é** `licitacao` (singular), conforme a tabela
`resources` (id 20). Os controllers de tipo/modalidade devem usar
`licitacao.index|create|edit|delete` — nunca `licitacoes.*`.

## 5. Endpoints REST/JSON sugeridos

### 5.1 Portal (público, leitura)

| Método | URI | Equivalente legado |
|---|---|---|
| GET | `/api/v1/bidding` | `GET /licitacoes` (com filtros) |
| GET | `/api/v1/bidding/{id}` | `GET /licitacao/{id}` |
| GET | `/api/v1/bidding/{id}/proposals` | sub-tabela `proposal-dt-*` |
| GET | `/api/v1/bidding/{id}/contracts` | `licitacao/contratos.blade.php` |
| GET | `/api/v1/contracts` | `GET /contratos` |
| GET | `/api/v1/contracts/{id}` | `GET /contrato/{contractId}` |
| GET | `/api/v1/bidding/export?format=xlsx\|pdf\|csv\|txt` | `GET /exportacao` |

Parâmetros de filtro (os mesmos do legado, com nomes estáveis):
`number`, `process_number`, `statuses_id`, `bidding_modalities_id`,
`bidding_types_id`, `contents_id`, `local`, `object`, `value_min`, `value_max`,
`proponent`, `dt_inicial`, `dt_final`, `show`.

**Correção obrigatória:** `contents_id` (órgão) passa a filtrar de fato
(`whereHas('contents', fn ($q) => $q->where('contents_id', $id))`), pois o
legado exibe o campo e o ignora.

### 5.2 CMS (autenticado)

| Método | URI | Equivalente legado |
|---|---|---|
| GET | `/cms/bidding` | `/cms/licitacao` |
| GET | `/cms/bidding/create` | idem |
| POST | `/cms/bidding` | `POST /cms/licitacao` |
| GET | `/cms/bidding/{id}/edit` | idem |
| PUT | `/cms/bidding/{id}` | `PUT /cms/licitacao/{id}` |
| DELETE | `/cms/bidding/{id}` | `DELETE /cms/licitacao/{id}` |
| POST | `/cms/bidding/bulk-delete` | `POST /cms/ajax/licitacao/delete-por-lote` |
| DELETE | `/cms/bidding/{id}/files/{archiveId}` | `POST /cms/ajax/ajax-delete-files-licitacoes` (corrigir o bug do id) |
| GET/POST/PUT/DELETE | `/cms/bidding-types`, `/cms/bidding-modalities` | `/cms/tipo-licitacao`, `/cms/modalidade-licitacao` |
| GET | `/cms/bidding/datatable` | `GET /cms/datatable/licitacao` |

## 6. API Resources

```php
class BiddingResource extends JsonResource
{
    public function toArray(Request $request): array
    {
        return [
            'id'                      => $this->id,
            'number'                  => $this->number,
            'process_number'          => $this->process_number,
            'object'                  => $this->object,
            'value'                   => $this->show_value ? (float) $this->value : null,
            'show_value'              => (bool) $this->show_value,
            'is_show'                 => (bool) $this->is_show,
            'local'                   => $this->local,
            'publication_edital_date' => $this->publication_edital_date?->toDateTimeString(),
            'competition_date'        => $this->competition_date?->toDateTimeString(),
            'homologation_date'       => $this->homologation_date?->toDateTimeString(),
            'modality'                => $this->whenLoaded('modality', fn () => $this->modality->name),
            'type'                    => $this->whenLoaded('type', fn () => $this->type->name),
            'status'                  => $this->whenLoaded('status', fn () => $this->status->name),
            'management_unit'         => $this->whenLoaded('managementUnit', fn () => $this->managementUnit->name),
            'jurisdictionals'         => $this->whenLoaded('contents', fn () => $this->contents->pluck('title')),
            'files'                   => $this->whenLoaded('archives', fn () => $this->archives->map(fn ($a) => [
                'id' => $a->id, 'title' => $a->title, 'description' => $a->description, 'url' => $a->url,
            ])),
            'proposals'               => $this->whenLoaded('proposals'),
            'contracts'               => $this->whenLoaded('contracts'),
        ];
    }
}
```

Regra de equivalência: quando `show_value` for falso, `value` deve chegar
**nulo** ao cliente (o legado exibe “Indisponível”, não o número).

## 7. Views Blade modernas

- Componente `<x-bidding-table :rows="$biddings" />` (hoje o markup está
  duplicado em `modelo1`..`modelo6` e `newsbit`).
- `<x-bidding-filters :modalidades="..." :situacoes="..." ... />` com Alpine.js
  para o slider de faixa de valor (hoje jQuery UI + `mask`).
- `<x-bidding-detail :bidding="$bidding" />` e `<x-bidding-proposals />`.
- Manter o **microdata `schema.org`** (`itemtype=".../documentation/Bidding"`) —
  é o que habilita a indexação por buscadores.
- Manter o botão de expansão de propostas na própria linha (equivalência de UX).
- Manter a rota “Ir para licitações até 2018” condicionada à configuração
  `licitacoes_old`.

## 8. Fila, cache e storage

| Item | Proposta |
|---|---|
| Exportações (xls/pdf de grande volume) | `ShouldQueue` + `Excel::queue()` / job de PDF, com link temporário (`Storage::disk('local')->temporaryUrl()`). No legado a exportação é síncrona e carrega toda a coleção em memória. |
| PDF do navegador | Manter o jsPDF no cliente para o botão “pdf” da listagem (equivalência de UX), mas **não** embutir o JSON completo na página: expor `GET /api/v1/bidding?format=json` e montar a tabela no browser a partir do JSON paginado. |
| Cache | `Cache::remember` para as listas de filtro (modalidades, tipos, situacoes, secretarias, locais distintos, faixa de valores) — são raramente alteradas. Invalidar via `Cache::forget` no save de tipos/modalidades. |
| Storage | `Storage::disk('public')->putFileAs("licitacao/{$id}/arquivos", $file, $name)` com a mesma estrutura de pasta legado (`storage/licitacao/{id}/arquivos/`). Considerar `spatie/laravel-medialibrary` para multi-arquivo com metadados. |
| Fila de auditoria | `spatie/laravel-activitylog` mantido; gravar `performedOn`/`causedBy` com a mesma descrição para preservar o histórico. |

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

| # | Tema | Decisão necessária | Risco se ignorado |
|---|---|---|---|
| 1 | Valor estimado oculto (`show_value = false`) | Manter a semântica de “Indisponível” | Exposição de valor que a Prefeitura decidiu não publicar. |
| 2 | `is_show` / “Apenas Shows” | Manter o campo e o filtro; corrigir o rótulo “Show Artístico” no CMS | citizens vêem eventos filtrados de forma diferente. |
| 3 | Unicidade de `number` | Decidir se é “número + modalidade” (índice único) ou “número + modalidade + secretarias” (regra de negócio) | Duplicidade indevida de certames. |
| 4 | Datas opcionais | Manter opcionais ou exigir data para modalidades que exigem certame (a regra existiu comentada no legado) | Certame publicado sem data. |
| 5 | Bloqueio de exclusão por propostas | Manter o bloqueio; definir o que fazer com os `contracts` (hoje ficam órfãos com `bidding_id = null`) | Contrato órfão e proponente sem licitação. |
| 6 | Ordenação da listagem pública | A v6 usa homologação/publicação; a v5 usava o maior número. **Manter a v6** e confirmar com o negócio | Mudança na ordem de exibição para o cidadão. |
| 7 | `agent` (Fiscal do Contrato) | **Reintroduzir** (existe na v5, sumiu na v6) ou confirmar a remoção | Perda de identificação do fiscal do contrato. |
| 8 | Escopo da exclusão em lote | Anexar `contents()->detach()` (hoje só a exclusão individual desanexa) | Secretarias órfãs na pivot. |
| 9 | Filtro por órgão (`contents_id`) | Passar a filtrar de fato | Expectativa do usuário não atendida. |
| 10 | Valor em `double precision` | Migrar para `decimal(14,2)` | Diferença de centavos em relatórios e comparações. |
| 11 | Exibição de arquivos | Manter “Arquivos para Download” e o caminho público | Links quebrados para documentos publicados. |
| 12 | Accessors de data | Datas devem chegar cruas (ISO) no JSON | Frontend com datas ambíguas. |
| 13 | Licitações “antigas” (`licitacoes-ate-2018`) | Decidir se migram para a v7 ou ficam no sistema legado | Link quebrado no portal. |
| 14 | Route `licitacao-ajax-detalhe` | Remover (morta) | Rota que sempre devolve 500. |
