# Migração moderna — Licitações no V7

> Alvo: `public-v7`, Laravel 12 / PHP 8.4, DDD-lite, PostgreSQL 18, sem traits,
> abstract ou generics. O módulo é reconstruído, não copiado. O conteúdo legado
> entra à mão pelo CMS (ADR-042). Os blocos PHP deste arquivo são propostas
> (`// exemplo ilustrativo`), não código existente; `src/...` indica o destino.

## 1. Identidade do módulo

Estrutura de diretórios conforme a convenção do V7: `ModuleProvider.php`, `Database/{Migrations,Models}`, `Logic/{Actions,Controllers,DTOs,Requests}`, `Layout/Views`, `Seo`, `Lang/pt_BR` e `Test`.

```php
final class ModuleProvider implements ModuleProviderContract
{
    public function register(): void
    {
        app(SitemapRegistry::class)->register(LicitacaoSitemapSource::class);
    }

    public function config(): array
    {
        return ['title' => 'licitacoes::menu.title', 'icon' => 'lucide-file-text',
            'position' => 20, 'route' => 'licitacoes.cms.index',
            'permissions' => [Permission::VIEW, Permission::EDIT, Permission::APPROVE, Permission::ARCHIVE]];
    }
}
```

Token canônico: `licitacoes`. Permissões: `licitacoes.ver`, `licitacoes.editar`,
`licitacoes.aprovar`, `licitacoes.arquivar` (ADR-033). Não declarar `aprovar`
se o fluxo de publicação for removido do produto.

## 2. Schema

Uma migration idempotente com schema dedicado, UUIDv7 nativo e `timestamptz`,
no padrão de `src/modules/Publicacoes/Noticias/Database/Migrations/2026_10_07_120000_create_noticias_table.php`.
A migration assume `CREATE EXTENSION IF NOT EXISTS pg_trgm;` no banco.

```php
return new class extends Migration
{
    private const string SCHEMA = 'licitacoes';

    public function up(): void
    {
        DB::statement("SET lock_timeout = '3s';");
        DB::statement('CREATE SCHEMA IF NOT EXISTS '.self::SCHEMA.';');
        DB::statement('SET search_path TO '.self::SCHEMA.', public;');

        if (Schema::hasTable('licitacoes')) {
            DB::statement('RESET search_path;');

            return;
        }

        DB::beginTransaction();

        try {
            Schema::create('licitacoes', function (Blueprint $table): void {
                $id = $table->uuid('id')->primary();
                $id->default(new Expression('uuidv7()'));

                $table->text('number');
                $table->text('title');
                $table->text('slug')->unique();
                $table->jsonb('object');                 // AST TipTap (ADR-028)
                $table->text('object_text')->nullable();  // busca/SEO
                $table->text('process_number')->nullable();
                $table->decimal('value', 15, 2)->default(0);
                $table->boolean('show_value')->default(true);
                $table->timestampTz('publication_edital_date')->nullable();
                $table->timestampTz('competition_date')->nullable();
                $table->timestampTz('homologation_date')->nullable();
                $table->boolean('is_active')->default(false);
                $table->text('status')->default('aberta');
                $table->text('reason')->nullable();
                $table->text('local')->nullable();
                $table->text('management_unit')->nullable();
                $table->jsonb('metadata')->nullable();    // metadata.seo (ADR-027)
                $table->timestampsTz();
                $table->softDeletesTz();
            });

            DB::statement("ALTER TABLE licitacoes ADD CONSTRAINT licitacoes_status_check
                CHECK (status IN ('aberta', 'finalizada', 'cancelada'))");
            DB::statement("ALTER TABLE licitacoes ADD CONSTRAINT licitacoes_object_check
                CHECK (jsonb_typeof(object) = 'object')");
            DB::statement('CREATE INDEX licitacoes_publicas_idx ON licitacoes
                (publication_edital_date DESC) WHERE deleted_at IS NULL AND is_active');
            DB::statement('CREATE INDEX licitacoes_object_gin ON licitacoes
                USING gin (object jsonb_path_ops)');
            DB::statement("CREATE INDEX licitacoes_busca_trgm ON licitacoes
                USING gin ((number || ' ' || title) gin_trgm_ops)");

            Schema::create('anexos', function (Blueprint $table): void {
                $id = $table->uuid('id')->primary();
                $id->default(new Expression('uuidv7()'));
                $table->uuid('licitacao_id');
                $table->jsonb('media_data');
                $table->text('original_name');
                $table->text('title')->nullable();
                $table->integer('order')->default(0);
                $table->timestampsTz();
                $table->softDeletesTz();
                $table->foreign('licitacao_id')->references('id')->on('licitacoes')->cascadeOnDelete();
                $table->index(['licitacao_id', 'order']);
            });

            Schema::create('orgaos', function (Blueprint $table): void {
                $id = $table->uuid('id')->primary();
                $id->default(new Expression('uuidv7()'));
                $table->text('name')->unique();
                $table->boolean('is_active')->default(true);
                $table->timestampsTz();
                $table->softDeletesTz();
            });

            Schema::create('licitacao_orgao', function (Blueprint $table): void {
                $table->uuid('licitacao_id');
                $table->uuid('orgao_id');
                $table->primary(['licitacao_id', 'orgao_id']);
                $table->foreign('licitacao_id')->references('id')->on('licitacoes')->cascadeOnDelete();
                $table->foreign('orgao_id')->references('id')->on('orgaos')->cascadeOnDelete();
                $table->index('orgao_id');
            });

            DB::commit();
        } catch (Throwable $e) {
            DB::rollBack();
            Log::emergency('FALHA NA MIGRAÇÃO licitacoes: '.$e->getMessage());
            throw $e;
        }

        DB::statement('RESET search_path;');
    }
};
```

`down()` renomeia as quatro tabelas para `backup_*` com data e hora no sufixo; nunca `DROP`
(ADR-017).

### 2.1 Decisões de schema e equivalência

| Legado | V7 | Motivo |
|---|---|---|
| `v2_licitacao_status` (tabela) | `status` text + CHECK | não há CRUD de status; nomes canônicos viram enum de domínio, rótulos/cores em código (ADR-001) |
| `jurisdicionados` jsonb | relação N:N `orgaos` | elimina a segunda lista de nomes |
| `arquivos` jsonb | tabela `anexos` | uma única verdade para arquivos |
| `v1_id` / `imported_at` | não existem | ADR-042: base nova, conteúdo manual |
| `metadata` livre | `metadata` só para `seo` | ADR-017/ADR-027 |
| `object` text | `object` jsonb (AST) | ADR-028 |
| `datetime` sem tz | `timestamptz` | ADR-017 |
| UUID v4 em PHP | `uuidv7()` no banco | ordenável e nativo |
| `order` integer | mantido | ordem editorial explícita |

`object_text` é a única coluna derivada: existe para busca e meta description
sem percorrer o AST. `SaveLicitacao` a grava com `RichTextRenderer::toText()` na
mesma transação. Se a equipe recusar denormalização, a busca fica restrita a
`number`/`title`.

## 3. Modelos

```php
final class Licitacao extends Model
{
    use HasUuids;
    use SoftDeletes;

    protected $table = 'licitacoes.licitacoes';

    public const array STATUSES = ['aberta', 'finalizada', 'cancelada'];
    public const array STATUS_COLORS = [
        'aberta' => 'success', 'finalizada' => 'secondary',
        'cancelada' => 'danger',
    ];

    protected $fillable = [
        'number', 'title', 'slug', 'object', 'object_text', 'process_number',
        'value', 'show_value', 'publication_edital_date', 'competition_date',
        'homologation_date', 'is_active', 'status', 'reason', 'local',
        'management_unit', 'metadata',
    ];

    protected function casts(): array
    {
        return [
            'object' => 'array',
            'metadata' => 'array',
            'value' => 'decimal:2',
            'show_value' => 'boolean',
            'is_active' => 'boolean',
            'publication_edital_date' => 'datetime',
            'competition_date' => 'datetime',
            'homologation_date' => 'datetime',
        ];
    }

    public function anexos(): HasMany
    {
        return $this->hasMany(LicitacaoAnexo::class)->orderBy('order');
    }

    public function orgaos(): BelongsToMany
    {
        return $this->belongsToMany(LicitacaoOrgao::class, 'licitacoes.licitacao_orgao');
    }

    public function scopePublicadas(Builder $query): Builder
    {
        return $query->where('is_active', true);
    }
}
```

`value` como `decimal:2` evita o `float` do legado. `object` no cast é array
PHP; o `RichTextRenderer` aceita array.

`LicitacaoAnexo` tem `media_data` (array), `original_name`, `title`, `order`,
soft delete e `displayName()` com fallback para `original_name`. O payload é o
de `EditorMediaStore::execute()` — `kind`, `path`, `mime`, `extension`,
`width`, `height`, `sources`, `checksum`, `size`, `warnings`
(`src/services/Media/Contracts/EditorMediaStore.php:21-34`). Nunca guardar URL.

`LicitacaoOrgao` tem `name` unique no banco (corrige a corrida do legado),
`is_active` e soft delete. A relação inversa usa o mesmo pivô. O global scope
de soft delete **não** se aplica automaticamente ao outro lado do
`belongsToMany`; decidir entre bloquear remoção associada ou filtrar
`orgaos.deleted_at` na consulta.

## 4. DTO e Form Request

`LicitacaoData` é `final readonly`, com escalares e `object` array. `slug`
`null` significa “gerar”.

```php
final class SaveLicitacaoRequest extends FormRequest
{
    public function authorize(): bool
    {
        return true; // a autorização é do controller (ADR-033)
    }

    public function rules(): array
    {
        $licitacao = $this->route('licitacao');
        $id = $licitacao instanceof Licitacao ? $licitacao->getKey() : null;

        return [
            'number' => ['required', 'string', 'max:50'],
            'title' => ['required', 'string', 'max:255'],
            'slug' => ['nullable', 'string', 'max:255',
                'regex:/^[a-z0-9]+(?:-[a-z0-9]+)*$/', $this->uniqueSlug($id)],
            'object' => ['required', 'array'],
            'process_number' => ['nullable', 'string', 'max:100'],
            'value' => ['nullable', 'numeric', 'min:0'],
            'show_value' => ['boolean'],
            'publication_edital_date' => ['nullable', 'date'],
            'competition_date' => ['nullable', 'date'],
            'homologation_date' => ['nullable', 'date'],
            'is_active' => ['boolean'],
            'status' => ['required', Rule::in(Licitacao::STATUSES)],
            'reason' => ['nullable', 'string', 'max:2000'],
            'local' => ['nullable', 'string', 'max:500'],
            'management_unit' => ['nullable', 'string', 'max:191'],
            'orgao_ids' => ['nullable', 'array'],
            'orgao_ids.*' => ['uuid', 'exists:licitacoes.orgaos,id'],
        ];
    }
}
```

A unicidade de `slug` usa query direta em `licitacoes.licitacoes`, porque
`Rule::unique` interpreta o ponto do schema como conexão (mesmo motivo de
`src/modules/Publicacoes/Noticias/Logic/Requests/SaveNoticiaRequest.php:67-81`).
Na Action, `null` significa gerar; nunca gravar `null`.

## 5. Actions e autorização

### 5.1 `SaveLicitacao`

Em transação: normaliza slug único (excluindo a própria linha, com retry em
violação de unique); calcula `object_text` com `RichTextRenderer::toText()`;
persiste; sincroniza órgãos; emite telemetria. A auditoria é automática pelo
`GlobalAuditSubscriber` (ADR-035).

### 5.2 Permissões por operação

| Operação | Permissão |
|---|---|
| listar, ver detalhe, ver arquivadas | `licitacoes.ver` |
| criar, editar, anexos, CRUD de órgão | `licitacoes.editar` |
| ativar/desativar no portal | `licitacoes.aprovar` |
| arquivar, restaurar, excluir em definitivo | `licitacoes.arquivar` |

O controller de store chama `Permission::authorize('Licitacoes', Permission::EDIT)`
antes de delegar à Action e redireciona para a edição com `status`. Não criar
`Policy` se a checagem no controller cobre o caminho; ADR-033 aceita as duas
formas.

### 5.3 Arquivar, restaurar, excluir

- `ArchiveLicitacao` faz `delete()` e não altera `is_active`.
- `RestoreLicitacao` usa `withTrashed()->findOrFail()` e `restore()`.
- `DeleteLicitacao` carrega anexos **com trashed**, remove os objetos via Media
  e então `forceDelete()`. A FK cascade resolve as linhas; o storage só é
  limpo se o código pedir. A UI usa `<dialog>` com confirmação explícita.

## 6. Anexos no V7

Usar `Services\Media\Contracts\MediaIngestor`; não implementar magic bytes no
módulo. O `MediaInputer` detecta MIME por finfo, otimiza e grava no S3
(ADR-026). O módulo mantém a allowlist de **tipos de documento** e os limites
da configuração `media` do V7.

```php
$payload = app(MediaIngestor::class)->ingest($file->getRealPath());

$anexo = $licitacao->anexos()->create([
    'media_data' => $payload,
    'original_name' => $file->getClientOriginalName(),
    'title' => $data->title,
    'order' => $data->order,
]);
```

O payload do `MediaInputer` é `['type' => MediaType, 'payload' => [...]]`
(`src/services/Media/README.md`); normalizar no DTO do módulo. Ordenação:
validar que todos os ids pertencem à licitação, rodar em transação e gravar
posições contíguas. Download: links para `media.stream` com `type` do MIME;
não expor o endpoint interno do S3.

## 7. Rotas

```php
Route::prefix('licitacoes')->group(function (): void {
    Route::get('/', LicitacoesCmsIndexController::class)->name('licitacoes.cms.index');
    Route::get('/criar', LicitacaoCreateController::class)->name('licitacoes.cms.create');
    Route::post('/', LicitacaoStoreController::class)->name('licitacoes.cms.store');
    Route::get('/{licitacao}/editar', LicitacaoEditController::class)->name('licitacoes.cms.edit');
    Route::put('/{licitacao}', LicitacaoUpdateController::class)->name('licitacoes.cms.update');
    Route::post('/{licitacao}/publicacao/{state}', LicitacaoPublicacaoController::class)
        ->whereIn('state', ['publicar', 'despublicar'])->name('licitacoes.cms.publicacao');
    Route::delete('/{licitacao}', LicitacaoDestroyController::class)->name('licitacoes.cms.destroy');
    Route::post('/{licitacao}/anexos', LicitacaoAnexoStoreController::class)->name('licitacoes.cms.anexos.store');
    Route::patch('/{licitacao}/anexos/ordem', LicitacaoAnexoOrderController::class)->name('licitacoes.cms.anexos.order');
    Route::delete('/{licitacao}/anexos/{anexo}', LicitacaoAnexoDestroyController::class)->name('licitacoes.cms.anexos.destroy');
    Route::get('/orgaos', LicitacaoOrgaoIndexController::class)->name('licitacoes.cms.orgaos.index');
    Route::post('/orgaos', LicitacaoOrgaoStoreController::class)->name('licitacoes.cms.orgaos.store');
});

Route::get('/licitacoes', LicitacoesIndexController::class)->name('licitacoes.index');
Route::get('/licitacao/{slug}', LicitacoesShowController::class)->name('licitacoes.show');
```

CMS em um arquivo de rotas do CMS e outro das rotas públicas. Nomes sem prefixo
`v2` (ADR-004/ADR-014); sem closures (ADR-008).

## 8. Views e SEO

- Listagem pública paginada (`paginate(15)->withQueryString()`) com filtros de
  servidor `q`, `orgao`, `status`; busca cobre `number`, `title` e
  `object_text`; `<caption>` e links com contexto.
- Detalhe renderiza `object` por `RichTextRenderer::toHtml()`; valor só se
  `show_value`; 404 real para inativo; `X-Robots-Tag: noindex`.
- SEO: `<x-service-seo::tags>` com `GovernmentService` e `BreadcrumbList`;
  `metadata['seo']` editável por `<x-service-seo::input>`; sitemap via
  `LicitacaoSitemapSource` só com ativas (ADR-027).
- CMS: componentes do shell, `<x-richtext::editor>`, `<dialog>` nativo, JS em
  módulo Vite, sem `onclick` (ADR-009/ADR-010/ADR-022).

## 9. Fila, cache e storage

- Upload síncrono no CMS ou `202` + job Valkey se o Media já oferecer o fluxo
  de quarentena; não criar fila própria.
- Listagem pública com `Cache-Control`/`ETag` (ADR-022), invalidada ao
  publicar/despublicar/editar. Nunca cachear o CMS.
- Storage no disco `s3` via `MediaStorageInterface` (ADR-030); nunca
  `Storage::disk('public')`.

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

| Risco | Decisão |
|---|---|
| URLs públicas existentes | manter `/licitacoes` e `/licitacao/{slug}`; slug alterado gera 301 (ADR-027) |
| `show_value` | nunca vazar valor em listagem, detalhe, JSON-LD, sitemap ou API |
| `is_active` | detalhe inativo = 404, não soft-404 |
| três datas | exibir `d/m/Y`, guardar `timestamptz`; validar ordem quando todas existirem |
| status | manter os três rótulos atuais e cores; nomes novos exigem decisão |
| ordem dos anexos | transacional e estável; não usar `count()` como próxima ordem |
| órgão removido | decidir entre bloquear remoção ou filtrar na exibição |
| objeto legado | conversão editorial para AST; sem `strip_tags` em conteúdo rico |
| `arquivos` jsonb | não portar; anexos legados entram como linhas com `media_data` |
| perfis `read`/`write` | migrar para `ver` e `editar`/`aprovar`/`arquivar` conforme a função |
| auditoria | não portar `V2AuditLogger`; usar a trilha nativa (ADR-035) |
| busca | substituir o filtro no navegador por consulta de servidor + paginação |

**Não pode mudar**: significado de `show_value`, visibilidade por `is_active`,
obrigatoriedade de número/título/objeto, download dos anexos e criação de órgão
sem sair do formulário.
