# Integração WMS externa — guia do módulo

> Sinal `IntegracaoWms`: a camada que conecta o Genesis a um **WMS externo**, nas
> duas direções — **entrada** (o WMS manda ordens de trabalho que viram Missões)
> e **saída** (os movimentos executados pelos operadores são empurrados de volta,
> fechando o ciclo da ordem). Tudo **opt-in por empresa**: quem não plugar segue
> no comportamento legado (push pro galpão) sem nenhuma mudança.
>
> O domínio operacional (missões, bipagem, movimentos) NÃO mora aqui — mora em
> `../AlocacaoWms/` (ver o README de lá). Este sinal só traduz, transporta e
> rastreia.
>
> Specs/decisões: `PRD/integracao-wms-desacoplada/` (00-prd … 12-review-fase3).

---

## 1. Visão geral (leia isto primeiro)

```
ENTRADA (WMS externo → Genesis)
  POST /api/webhooks/wms-<empresaId>          (HMAC por canal — webhook_endpoints)
    → InboundHandler (genérico, Plataforma/Api)
      → WmsPayloadNormalizer (versão do contrato)
      → ReceberOrdemLaboratorio
          tenant da linha autenticada do canal → sessão sintética
          → ativo_entrada? → envelope → DEDUP por external_ref → identidade
          → TraducaoOrdemFactory (tipo → tradutor) → cria a MISSÃO
          → wms_ordens: recebida → traduzida (ou rejeitada, com motivo)

EXECUÇÃO (operador — sinal App / AlocacaoWms)
  missão aparece no app normalmente; cada confirmação grava wms_movimentos
  com integracao_erp/wms = 'pendente'  (nada bloqueia esperando o WMS)

SAÍDA + REPORT (assíncrono — worker)
  crons/wms_alocacao_integracao.php (a cada ~2 min)
    → WmsProviderFactory::build(config.provedor_saida) → provider->enviar(...)
       'galpao' (legado)  |  'http_wms' (WMS externo genérico, HMAC + external_ref)
    → marca integracao_* = enviado|falha (retry automático no próximo ciclo)
    → ReportarOrdemLaboratorio: missão de origem WMS com push enviado
       → wms_ordens.status = 'reportada'  (ordem fechada)
```

Ciclo da ordem (`wms_ordens.status`): `recebida → traduzida → reportada`, ou
`rejeitada` (sempre com `motivo` persistido — observabilidade via banco).
Idempotência: `UNIQUE (block_empresa_id, external_ref)` — reenvio da mesma ordem
devolve o veredito original (`ja_processada`), nunca duplica missão.

## 2. Mapa de diretórios

```
IntegracaoWms/
├── IntegracaoWms.php            # controller/bootstrap (registra provedores; tela de config)
├── Provedores/                  # SAÍDA plugável (Registry+Interface+Factory, espelha OtpCanal*)
│   ├── WmsProviderInterface.php #   nome() + enviar(WmsEnvioContexto): WmsEnvioResultado
│   ├── WmsProviderRegistry.php  #   registrar/resolver por nome
│   ├── WmsProviderFactory.php   #   build() + setOverride() p/ teste
│   ├── Galpao/GalpaoProvider.php#   legado (paridade byte-a-byte com o push antigo)
│   └── Http/HttpWmsProvider.php #   WMS externo genérico: Bearer + X-Signature (HMAC out) + external_ref
├── Tradutores/                  # ENTRADA: ordem → missão (Factory+enum, lista fechada)
│   ├── OrdemTipo.php                     #   separacao | movimentacao | alocacao | inventario
│   ├── TraducaoOrdemFactory.php          #   tipo => classe (tipo sem tradutor = ordem rejeitada c/ motivo)
│   ├── OperacaoWmsOrdemTradutorBase.php  #   base compartilhada (stub $galaxiaRoute, B1, localizar missão) — espelha OperacaoAdapterBase
│   ├── AlocacaoOrdemTradutor.php         #   ★ 1º tradutor (Fase 3) — molde para os próximos
│   ├── MovimentacaoOrdemTradutor.php     #   2º — mesma missão, operacao_tipo='movimentacao'
│   ├── InventarioOrdemTradutor.php       #   3º — inventario_produtos|inventario_endereco (subtipo)
│   └── (SeparacaoOrdemTradutor.php)      #   BLOQUEADO — ver §7.1, decisão de produto pendente
├── Laboratorios/
│   ├── ReceberOrdemLaboratorio.php   # target_lab do canal (pipeline completo de entrada)
│   └── ReportarOrdemLaboratorio.php  # fecha o ciclo (chamado pelo cron)
├── Validators/                  # OrdemEnvelopeValidator + OrdemIdentidadeValidator
├── Webhooks/WmsPayloadNormalizer.php # versão do contrato (v1; major desconhecida = rejeição)
├── Config/                      # IntegracaoWmsConfigSonda (por empresa) + EnvConfig (defaults)
├── Sondas/WmsOrdemSonda.php     # wms_ordens: dedup + ciclo
├── Migrations/                  # 001 config · 002 wms_ordens · 003 seed canal dev
├── html/ + forms/               # tela de config por empresa
└── Testes/                      # TddTest espelhando a árvore (dev_gx_goldie)
```

Fora do sinal: `Constelacoes/Logistica/MissaoLogistica/Strategies/{Erp,Wms}AlocacaoWmsHookStrategy.php`
(montam o push por movimento via adapter de operação) e `crons/wms_alocacao_integracao.php`
(o único worker: retry + estorno + report).

## 3. Status — o que está pronto e o que falta

| Peça | Estado |
|---|---|
| Saída plugável (registry, galpao + http_wms reais, config por empresa) | ✅ |
| Receiver de entrada (HMAC, versão, dedup, identidade, tenant fail-closed) | ✅ |
| Ciclo da ordem + report-back automático | ✅ |
| Tradutor `alocacao` (ordem → missão ALOCACAO_WMS ponta a ponta) | ✅ |
| Tradutor `movimentacao` (ordem → missão ALOCACAO_WMS, operacao_tipo='movimentacao') | ✅ |
| Tradutor `inventario` (ordem → missão ALOCACAO_WMS, `inventario_produtos`/`inventario_endereco`, subtipo explícito ou inferido) | ✅ |
| Tradutor `separacao` | 🚫 **bloqueado** — decisão de produto pendente, ver §7.1 |
| Read API de saldo do WMS | ⏳ por design push-only; seam futuro = `EsperadoProviderRegistry` |
| Atualização/cancelamento de ordem pelo WMS | ⏳ deferido (status `cancelada` já cabe no schema) |

Decisão de produto registrada (review Fase 3, B1): **missão de origem WMS não
exige aprovação de gestor** (`requer_aprovacao='0'` setado no tradutor) — senão o
gate reteria o push e a ordem nunca fecharia. Gate opcional por empresa = evolução futura.

## 4. Contrato v1 — entrada (WMS → Genesis)

**Endpoint**: `POST /api/webhooks/wms-<empresaId>` · **Auth**: header
`X-Signature: sha256=<hmac_sha256(body, secret)>` (secret da linha do canal;
timing-safe; aceita com/sem prefixo `sha256=`).

```jsonc
{
  "version": "v1",                 // major desconhecida → rejeição explícita
  "external_ref": "ORD-2026-0001", // OBRIGATÓRIO — id da ordem no WMS (chave de dedup por empresa)
  "tipo": "alocacao",              // OBRIGATÓRIO — separacao|movimentacao|alocacao|inventario
  "subtipo": "produtos",           // SÓ p/ tipo='inventario' — 'produtos'|'endereco' (ver §7)
  "itens": [                       // opcional (alocacao/movimentacao aceitam vazio = sessão livre;
                                    // inventario SEM subtipo precisa de itens não-ambíguos p/ inferir)
    { "produto_ref": "<importacao_id>", "endereco_ref": "<wms_localizacoes.codigo>", "quantidade": 10 }
  ]
}
```

- `produto_ref` = `importacao_id` do Genesis (= `codigomd5` do ERP legado).
- `endereco_ref` = `wms_localizacoes.codigo` (único por tenant).
- Identidade desconhecida → **ordem inteira rejeitada** com motivo (nunca missão "meio pronta").
- **`endereco_ref` NÃO pré-endereça a missão** em `alocacao`/`movimentacao`: é
  validado (identidade) e fica registrado na ordem, mas a execução é
  endereço-primeiro — origem/destino são bipados pelo OPERADOR na hora. Se o
  parceiro precisar de movimentação pré-dirigida (endereços impostos pela
  ordem), isso é evolução futura do contrato — hoje não suportado.
- `subtipo` (só `tipo='inventario'`): `'produtos'` → `operacao_tipo='inventario_produtos'`
  (seleção = produtos); `'endereco'` → `'inventario_endereco'` (seleção = endereços,
  `buscarItens()` não gera itens de produto — o escopo vai para
  `configuracoes.inventario_alvos`). Sem `subtipo`, o tradutor infere pelos itens
  (produto_ref presente → produtos; só endereco_ref → endereco); itens ambíguos
  (mistura dos dois padrões) ou ausência total de escopo → **ordem rejeitada**
  ("inventário precisa de escopo", não há modo livre equivalente ao de alocação).

**Respostas**: `200 {received, ok:true, ordem_id, status:'aceita'}` ·
`200/422 status:'ja_processada'` (reenvio — veredito original preservado) ·
`422 {ok:false, status:'rejeitada', motivo}` · `401` HMAC inválido · `404` canal
inexistente/inativo.

## 5. Contrato — saída (Genesis → WMS, provedor `http_wms`)

`POST <endpoint_wms|endpoint_erp>?opc=<operacao>&retorno=1`, headers
`Authorization: Bearer <token>` e `X-Signature: sha256=<hmac(body, segredo)>`.
Body = payload do movimento (montado pelo `OperacaoAdapter` da operação, sempre
com `operacao_tipo`) **+ `external_ref`** quando a missão veio de uma ordem
(correlação para o WMS fechar a ordem do lado dele).

O WMS deve responder `{"status":"ok"}` ou `{"ok":true}`; qualquer outra coisa
marca `falha` e o movimento volta à fila de retry (nada se perde). Os `opc` por
operação: `registrar_localizacao_wms`/`sync_localizacao_wms` (alocação),
`registrar/sync_contagem_wms` (inventários), `registrar/sync_movimentacao_wms`
(movimentação — payload com `localizacao_codigo_origem/destino`).

> Segredo do HMAC de saída: `define('GENESIS_WMS_HTTP_HMAC_SECRET', ...)`. Se
> ausente, cai no bearer token — **popule o define antes do primeiro WMS real**
> (bearer viaja no mesmo request; como segredo de assinatura ele não protege nada).

## 6. Checklist — plugar um WMS real (por empresa)

1. **Canal de entrada** — inserir em `webhook_endpoints` (molde:
   `Migrations/003_seed_webhook_endpoint_dev.sql`): `channel='wms-<empresaId>'`
   (sufixo DEVE bater com `block_empresa_id` — divergência é rejeitada,
   fail-closed), `secret` forte (rotação = UPDATE na linha, sem deploy),
   `signature_header='X-Signature'`, `signature_algo='hmac_sha256'`,
   `target_lab=...\ReceberOrdemLaboratorio`, `payload_mapper=...\WmsPayloadNormalizer`,
   `active=1`, `customer_id=block_empresa_id=block=<empresaId>` (**`block` > 0
   obrigatório**).
2. **Config da empresa** — na tela do sinal (`/missao/Logistica/IntegracaoWms`)
   ou via `SalvarConfigLaboratorio`: `provedor_saida='http_wms'`,
   `endpoint_erp`/`endpoint_wms` do parceiro, `ativo_saida=1`, `ativo_entrada=1`.
   Empresa sem linha = defaults (`galpao`, entrada OFF) — nada muda para as demais.
3. **Segredos via `define()`** (MainBoth.php ou equivalente):
   `GENESIS_WMS_HTTP_HMAC_SECRET` (≠ bearer) e, se necessário,
   `GENESIS_WMS_BEARER_TOKEN` / `GENESIS_WMS_URL_*` (defaults em `IntegracaoWmsEnvConfig`).
4. **Tradutores** — confirmar que os `tipo` que o parceiro vai mandar têm
   tradutor registrado (`TraducaoOrdemFactory::tiposSuportados()`); hoje
   `alocacao`, `movimentacao` e `inventario`. `separacao` está **bloqueado**
   (ver §7.1). Tipo sem tradutor é rejeitado com motivo (comportamento seguro).
5. **Cron** ativo: `crons/wms_alocacao_integracao.php` a cada ~2 min.
6. **Smoke** (dev primeiro, depois prod):

```bash
BODY='{"version":"v1","external_ref":"SMOKE-001","tipo":"alocacao","itens":[]}'
SECRET='<secret do canal>'
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" | awk '{print $2}')
curl -s -X POST "https://<host>/api/webhooks/wms-<empresaId>" \
  -H "Content-Type: application/json" -H "X-Signature: sha256=$SIG" -d "$BODY"
# esperado: {"received":true,"ok":true,"ordem_id":N,"status":"aceita"}
# conferir: missão no app (Operação WMS) + linha em wms_ordens (status='traduzida');
# executar um movimento e aguardar o cron → wms_ordens.status='reportada'.
```

## 7. Receita: adicionar um tipo de ordem (tradutor)

1. Se o tipo alvo cria uma Missão de Operação WMS (`TipoMissao::ALOCACAO_WMS`,
   id 14 — é o caso de `alocacao`/`movimentacao`/`inventario`, todas via
   `GestaoMissoesAlocacaoWms`): estenda `OperacaoWmsOrdemTradutorBase`
   (`Tradutores/OperacaoWmsOrdemTradutorBase.php`) e implemente só
   `getOrdemTipo()` + `montarSelecao(array $ordem): array` (retorna
   `operacao_tipo` + `itens_selecionados`, ou `ok=false` + `erro` pra rejeitar
   sem criar missão). A base já cuida do stub de `$galaxiaRoute`, do B1
   (`requer_aprovacao='0'`) e de localizar a missão criada por
   `(referencia_tipo, referencia_id)` — **imitar `MovimentacaoOrdemTradutor`**
   (o mais simples) ou `InventarioOrdemTradutor` (se o tipo tiver mais de um
   escopo possível). Se o tipo alvo for uma missão de outro `GestaoMissoes*`
   (ex.: `separacao` → `GestaoMissoesSeparacao`), a base não serve — ver §7.1
   antes de tentar (não é reuso mecânico).
2. Registrar: 1 linha no mapa de `TraducaoOrdemFactory::$tradutores`.
3. `Testes/Tradutores/<Tipo>OrdemTradutorTddTest.php` (espelhar
   `MovimentacaoOrdemTradutorTddTest`) + caso E2E no
   `tests/integration/IntegracaoWms/ReceberOrdemApiTest.php` (espelhar GRUPO
   D/F/G) + **atualizar B2/B3/GRUPO C** desse mesmo arquivo, que dependem de
   sempre existir pelo menos 1 tipo AINDA sem tradutor para testar o caminho
   de rejeição (ver comentário "ARMADILHA CONHECIDA" no próprio arquivo).
4. Nada mais muda: `ReceberOrdemLaboratorio`, cron e report já são genéricos.

Status dos 4 alvos (techplan §Fase 3): `alocacao` ✅, `movimentacao` ✅
(`OperacaoTipo::MOVIMENTACAO`), `inventario` ✅ (`OperacaoTipo::INVENTARIO_PRODUTOS`/
`INVENTARIO_ENDERECO`, por `subtipo`); `separacao` 🚫 **bloqueado** — ver §7.1.

### 7.1. Bloqueio conhecido: `separacao` (decisão de produto pendente)

`separacao` **não tem tradutor** e não deve ganhar um por conta própria — o
mapeamento para `TipoMissao::SEPARACAO`/`GestaoMissoesSeparacao` esbarra numa
incompatibilidade estrutural, não numa lacuna de código:

- `GestaoMissoesSeparacao::buscarItens()` (herdado de `GestaoMissoesBase::
  recuperarItensSelecionados()`) resolve **sempre** via `romaneio_id`/
  `pedido_id` → `pedidos_produtos` (modo hierárquico ou plano). Não existe um
  modo "produto direto" equivalente ao de Alocação/Movimentação/Inventário
  (que aceitam uma lista de `produto_id` sem depender de pedido/romaneio).
- O contrato v1 da ordem WMS (`external_ref`, `tipo`, `itens: [{produto_ref,
  endereco_ref, quantidade}]`) **não carrega nenhuma referência a
  pedido/romaneio** — só identidade de produto/endereço (AC5.1/5.2 da spec).
  Não há de onde tirar o `romaneio_id`/`pedido_id` que `GestaoMissoesSeparacao`
  exige.

**Pergunta exata a decidir** (produto + Tech Lead, antes de destravar):
> O WMS, ao mandar uma ordem `tipo='separacao'`, referencia um pedido/romaneio
> do Genesis (e por qual identificador — `pedidos.importacao_id`? `numero_pedido`?
> `romaneios.id`?), ou a intenção é picking **sem** pedido associado (nesse
> caso, `GestaoMissoesSeparacao` não é o destino certo — talvez seja mais uma
> operação de `GestaoMissoesAlocacaoWms`, e o nome `separacao` no contrato
> precisa de outra definição)?

Opções levantadas (nenhuma implementada — aguardando decisão):
1. Envelope ganha um campo de referência a pedido/romaneio (novo mapeamento de
   identidade, fora do que AC5.1/5.2 cobrem hoje) → tradutor monta a seleção
   hierárquica que `GestaoMissoesSeparacao` já espera (reuso real, sem
   reimplementar).
2. `GestaoMissoesSeparacao` ganha um modo "produto avulso" (mudança de escopo
   maior — é lógica de negócio nova num arquivo compartilhado com o painel de
   Designações, não uma extensão opt-in como as dos outros 3 tradutores).
3. Reconsiderar se "separação vinda do WMS" deveria, de fato, mapear para
   `TipoMissao::SEPARACAO` — ou se é conceitualmente outra operação (picking
   sem pedido) melhor servida pela família `GestaoMissoesAlocacaoWms`.

Enquanto isso, `tipo='separacao'` continua **rejeitado com motivo** (AC4.4) —
comportamento seguro, nenhuma missão "meio pronta".

## 8. Receita: adicionar um provedor de saída

1. `Provedores/<Nome>/<Nome>Provider.php` implements `WmsProviderInterface`
   (`nome()` + `enviar()` — **nunca lançar**: devolver `WmsEnvioResultado(false, erro)`).
2. Registrar em `IntegracaoWms::bootstrap()` (guard `temRegistro` já é idempotente).
3. Selecionar por empresa na tela de config (`provedor_saida`).

## 9. Testes

```bash
# unit do sinal (14 arquivos)
for t in componentes/Missoes/Logistica/sinais/IntegracaoWms/Testes/**/*TddTest.php; do php "$t"; done

# integração (exigem dev_gx_goldie; aplicam migrations; limpam resíduo)
php tests/integration/IntegracaoWms/ConfigIntegracaoTest.php
php tests/integration/IntegracaoWms/Fase2MigrationsTest.php
php tests/integration/IntegracaoWms/ReceberOrdemApiTest.php   # E2E HTTP: aceite→missão→report
```

Convenções: TddTest CLI (não PHPUnit); rodam **só em `dev_gx_goldie`**; bootstrap
de sessão em `$_SESSION['galaxiaUserInfo']` E `['dashboard']`; refs de teste
`__test%`/`ZZ-%` limpas no `finally`. O GRUPO E do `ReceberOrdemApiTest` é o
guard de regressão do lookup global de canal (`findByChannel` independe de
sessão ambiente — requisito para webhook externo real funcionar); GRUPOS
D/F/G são o E2E de cada tradutor (`alocacao`/`movimentacao`/`inventario`).

## 10. Gotchas

- **`webhook_endpoints.block` precisa ser > 0** (mesmo valor de `block_empresa_id`).
  O lookup do canal é global, mas `block > 0` é o guard de soft-delete.
- **Tenant**: a fonte da verdade é a linha autenticada do canal
  (`block_empresa_id`), repassada pelo `InboundHandler` ao laboratório. O sufixo
  numérico de `wms-<empresaId>` é convenção — se divergir da linha, a ordem é
  rejeitada (fail-closed). Mantenha os dois consistentes.
- **Sondas V2**: writes só via `create()`/`update()`; `$required=[]`;
  `block_*` sem DEFAULT; conjunto completo de colunas de auditoria (senão
  `create()` quebra — só aparece em teste de integração).
- O `InboundHandler` devolve **422 com corpo rico** quando o laboratório rejeita
  (`response.ok=false`) — o WMS parceiro deve tratar 422 como rejeição definitiva
  (corrigir e reenviar com o MESMO `external_ref` não adianta: o veredito
  original é preservado pelo dedup; usar um `external_ref` novo).
- Log de transporte: `/tmp/genesis_webhook.log` (galpao e http_wms).
