# ADR-051 — Importação operacional CSV: porta no Core, sem ETL de legado

| Campo | Valor |
|---|---|
| **Status** | Aceito (2026-10-23) |
| **Data** | 2026-10-23 |
| **Plano de origem** | `wiki/implementation-phases/P9-servicos-plataforma.md` |
| **Relacionadas** | ADR-001, ADR-002, ADR-013, ADR-015, ADR-017, ADR-024, ADR-030, ADR-035, ADR-042 |

## Contexto

Transparência fiscal, licitações e dados abertos dependem de carga inicial/periódica a partir de
exportações de planilha. A ADR-042 proíbe **ETL de conteúdo do legado**, mas não proíbe **carga
operacional em lote**: são coisas diferentes. Sem uma ferramenta comum, cada módulo implementa seu
próprio upload, escaping, idempotência e relatório — e o operador repete o erro em cada tela.

O importador não pode virar um motor de workflow, um ETL genérico nem uma segunda porta de entrada
de conteúdo editorial. O CMS continua sendo o caminho do conteúdo editorial.

## Decisão

1. **Módulo, não service:** `Infraestrutura/Importador` (CMS-only) é o dono da superfície de
   upload, preview, relatório e execução.
2. **Porta no Core:** `Core\Contracts\Ports\ImportTarget` + `ImportRegistry`. O módulo alvo
   implementa a porta e a registra em `ModuleProvider::register()`, antes dos services; o
   importador não importa o módulo.
3. **O alvo define o contrato:** `key()`, `label()`, `columns()` (nome, alias, tipo, obrigatório,
   validação), `upsert(array $rows, string $batchId): ImportReport`, `template(): ?string`.
4. **Pipeline único:** upload CSV privado no S3 (path determinístico por hash) → detecção de
   encoding/delimitador → preview paginado sem escrita → relatório **Poka-yoke** (válidas,
   inválidas, duplicadas, ignoradas, erro por campo, amostra e antes/depois) → confirmação →
   job em lote → relatório final com falhas parciais.
5. **Idempotência e segurança:** chave de upsert (`key_hash`), `batch_id`, streaming com
   `fgetcsv`, limite de linhas/bytes, bloqueio de CSV injection (`=+-@`), validação server-side,
   arquivo privado e auditoria sem PII.
6. **Sem ETL e sem deploy:** nenhum `sys_migration`, `legacy_mapping` ou import automático no
   deploy; conteúdo editorial não entra por aqui. O importador é ferramenta de operação.

## Justificativa — alternativas descartadas

| Alternativa | Por que não |
|---|---|
| Cada módulo com seu upload | Repete escaping, idempotência, relatório e testes; UX inconsistente. |
| ETL do legado no importador | Viola ADR-042 e mistura conteúdo editorial com carga operacional. |
| Importar no `migrate`/deploy | Falha de deploy vira falha de dados; sem dry-run; não determinístico. |
| Service genérico com parser próprio | O service não conhece o domínio; o alvo é quem valida e faz upsert. |
| Upload multipart de vários arquivos | Complexidade de staging e ordem; o lote é uma linha de comando/arquivo por vez. |
| Importação síncrona no request | Tempo, timeout e retry; job em fila é o caminho. |

## Consequências

**Positivas**
- Um único pipeline e uma única UX de preview/erro para todos os módulos.
- O módulo continua dono do seu schema e da sua validação.
- Carga operacional deixa de ser script manual ou `INSERT` no banco.

**Custos / riscos**
- Cada alvo precisa implementar a porta e um template.
- O preview precisa ser fiel ao apply; divergência é bug de contrato.
- Dados pessoais em planilha exigem allowlist, retenção e auditoria (ADR-019/035).

## Exemplo mínimo

```php
final readonly class DespesasImportTarget implements ImportTarget
{
    public function key(): string { return 'financeiro.despesas'; }
    public function columns(): array { /* competencia, empenho, valor, ... */ }
    public function upsert(array $rows, string $batchId): ImportReport { /* idempotente */ }
}
```

```php
// ModuleProvider::register()
app(ImportRegistry::class)->register(DespesasImportTarget::class);
```

## Verificação

- CSV inválido não grava nada; dry-run e apply produzem o mesmo relatório de linhas.
- Reimportar o mesmo arquivo não duplica registros (`key_hash` + `batch_id`).
- Linha inválida não aborta o lote inteiro; o relatório final mostra falhas parciais.
- CSV injection não executa; valor é escapado no export e rejeitado/neutralizado no import.
- `tools/arch-check` confirma que o importador não importa módulo e o módulo não importa o
  importador.
