# Integração WMS desacoplada — Plano técnico

> Faseamento aprovado pelo stakeholder (strangler-fig, defaults do PO).
> Cada fase entrega valor isolado e **mantém a baseline verde**. Decisões técnicas
> deferidas na spec (auth, contrato, idempotência, reconciliação, identidade
> desconhecida) estão resolvidas abaixo, fundamentadas no padrão do repo.

## Resumo de decisão arquitetural

**Novo Sinal** `componentes/Missoes/Logistica/sinais/IntegracaoWms/` — uma camada de
integração coesa e **opt-in por empresa**, que consome (não reescreve) os domínios
existentes: `AlocacaoWms` (`OperacaoTipo` + `OperacaoAdapter` + push), `MissaoLogistica`
(criação de missão) e a superfície `Plataforma/Api` (webhook inbound). Justifica-se um
sinal próprio porque a integração é um **bounded concern novo** com tabelas próprias
(config por empresa, ciclo de ordem/idempotência), provedor plugável e auth própria —
e porque manter isso fora de `AlocacaoWms` preserva o sinal de execução agnóstico ao
WMS específico (mesma lógica do PRD: "core agnóstico").

Padrão V2 dominante: **Registry+Interface(+Factory)** para o provedor de saída (espelha
`OtpCanalRegistry`/`OtpCanalInterface`/`OtpCanalFactory`) e **Factory+enum** para a
tradução de ordem→missão (lista fechada de 4 tipos, type-safe — espelha
`OperacaoAdapterFactory`/`DesignacaoStrategyFactory`). Config por empresa via Sonda
`block_empresa_id` (espelha `ScanCotaConfigSonda`) + defaults via env (espelha
`EvolutionConfig`).

**Descoberta crítica que encurta a Fase 2:** o receiver de webhook autenticado por
empresa **já existe** e é genérico —
`componentes/Missoes/Plataforma/sinais/Api/Routes.php:101-102`
(`POST /api/webhooks/{channel}`) → `Api/Handlers/Webhooks/InboundHandler.php` (lookup do
canal em `webhook_endpoints` → valida HMAC → `PayloadNormalizer` → invoca `target_lab`),
com `Sondas/WebhookEndpointSonda.php`, `Webhooks/HmacValidator.php` e a interface
`Webhooks/PayloadNormalizer.php`. A Fase 2 **não cria rota nem auth nova**: registra um
canal por empresa em `webhook_endpoints` e pluga um `PayloadNormalizer` + um `target_lab`
nossos. Auth do webhook **resolvida = HMAC por canal** (já implementado).

## Sinal de referência

- **Imitar (provedor plugável)**: `componentes/Missoes/Mensageria/sinais/Otp/Canais/{OtpCanalRegistry,OtpCanalInterface,OtpCanalFactory}.php` — porque é o gold standard de Registry+Interface+Factory-com-override-de-teste, e o legado (galpão) vira "mais um" registrado, exatamente como `LogCanal` é fallback.
- **Imitar (gate de config por env)**: `componentes/Missoes/Mensageria/sinais/Otp/Canais/Whatsapp/EvolutionConfig.php` (`configurado()`, defaults via `define()` global) — para tirar URL/token do código.
- **Imitar (config por empresa)**: `componentes/Constelacoes/Logistica/MissaoLogistica/Sondas/ScanCotaConfigSonda.php` — `obterConfig(empresaId)`/`salvarConfig()` upsert, `customerControl=false`, `configDefault()` desligado (compat retroativa).
- **Imitar (tradução por tipo)**: `componentes/Missoes/Logistica/sinais/AlocacaoWms/Strategies/Operacao/{OperacaoAdapterInterface,OperacaoAdapterFactory}.php` (Factory+enum, lista fechada) — molde do `TraducaoOrdemFactory`.
- **Imitar (criação de missão)**: `componentes/Missoes/Logistica/sinais/Designacoes/Laboratorios/GestaoMissoesAlocacaoWms.php` (template method `GestaoMissoesBase::processarEnvio`, com `buscarItens`/`getConfiguracoesAdicionais`/`permiteSelecaoVazia`) — o tradutor monta o `$data` e despacha por aqui, sem reimplementar persistência de missão.
- **Imitar (receiver + tenant sintético)**: `Api/Handlers/Webhooks/InboundHandler.php` (receiver) + `crons/wms_alocacao_integracao.php:101-114` (sessão sintética por `block`) — o `ReceberOrdemLaboratorio` resolve o tenant do canal autenticado e estabelece a mesma sessão antes de criar a missão.
- **NÃO imitar**: `Constelacoes/.../Inteligencias/*` e `sinais/Pessoas`/`Designacoes` legados (V1) — código novo é só Sondas (`feedback_sondas_v2`, `feedback_referencias_v2`). Não copiar `Edicaorota/Callbacks/Registry` por reflexo: lá há **múltiplos módulos consumindo** um evento; aqui o provedor de saída é Registry (correto), mas a tradução de ordem é **lista fechada** → Factory+enum, não Registry de string.

## Estrutura proposta

### Novos arquivos
```
componentes/Missoes/Logistica/sinais/IntegracaoWms/
├── IntegracaoWms.php                          # controller/bootstrap do sinal (registra provedores; __call→render)
├── Provedores/
│   ├── WmsProviderInterface.php               # nome(): string; enviar(WmsEnvioContexto): WmsEnvioResultado
│   ├── WmsProviderRegistry.php                # registrar/temRegistro/resolver/listar/reset
│   ├── WmsProviderFactory.php                 # build() + setOverride()/clearOverride() (espelha OtpCanalFactory)
│   ├── WmsEnvioContexto.php                   # VO: destino(erp|wms), opc, payload, externalRef, config
│   ├── WmsEnvioResultado.php                  # VO: ok, erro, raw
│   ├── Galpao/GalpaoProvider.php              # legado strangler — paridade byte-a-byte do push de hoje
│   └── Http/HttpWmsProvider.php               # provedor genérico p/ WMS externo (HMAC out + endpoint/token de config)
├── Config/
│   ├── IntegracaoWmsConfigSonda.php           # por block_empresa_id (espelha ScanCotaConfigSonda)
│   ├── IntegracaoWmsEnvConfig.php             # defaults via env (espelha EvolutionConfig: configurado())
│   ├── Laboratorios/ConsultarConfigLaboratorio.php
│   └── Laboratorios/SalvarConfigLaboratorio.php
├── Sondas/
│   └── WmsOrdemSonda.php                       # idempotência (dedup por external_ref) + ciclo da ordem
├── Tradutores/
│   ├── OrdemTipo.php                           # enum: separacao|movimentacao|alocacao|inventario
│   ├── TraducaoOrdemInterface.php
│   ├── TraducaoOrdemFactory.php                # OrdemTipo->classe (closed map, type-safe)
│   ├── AlocacaoOrdemTradutor.php               # Fase 3 (1º tipo)
│   ├── SeparacaoOrdemTradutor.php              # Fase 3
│   ├── MovimentacaoOrdemTradutor.php           # Fase 3
│   └── InventarioOrdemTradutor.php             # Fase 3
├── Webhooks/
│   └── WmsPayloadNormalizer.php                # implements Api\Webhooks\PayloadNormalizer (envelope versionado)
├── Laboratorios/
│   ├── ReceberOrdemLaboratorio.php             # target_lab do canal (dedup→traduz→cria missão→grava ordem)
│   └── ReportarOrdemLaboratorio.php            # avança ciclo da ordem no report de volta
├── Validators/
│   ├── OrdemEnvelopeValidator.php              # versão do contrato + campos comuns
│   └── OrdemIdentidadeValidator.php            # produto (importacao_id) e endereço (wms_localizacoes.codigo) conhecidos
├── Migrations/
│   ├── 001_create_integracao_wms_config.sql
│   ├── 002_create_wms_ordens.sql
│   └── 003_seed_webhook_endpoint_dev.sql       # canal wms-<empresa> p/ dev_gx_goldie
├── html/integracaowms.php + integracaowms.js   # tela de config por empresa (genesis-views-i18n)
└── Testes/
    ├── Provedores/{WmsProviderRegistryTddTest,GalpaoProviderParidadeTddTest}.php
    ├── Config/IntegracaoWmsConfigSondaTddTest.php
    ├── Sondas/WmsOrdemSondaTddTest.php
    ├── Webhooks/WmsPayloadNormalizerTddTest.php
    ├── Tradutores/{TraducaoOrdemFactory,AlocacaoOrdemTradutor}TddTest.php
    └── Laboratorios/ReceberOrdemLaboratorioTddTest.php
```

### Arquivos alterados
- `componentes/Constelacoes/Logistica/MissaoLogistica/Strategies/HookSincronizadorBase.php` — remover `urlWebhook`/`bearerToken` chumbados (linhas 15, 20-22); transporte passa a vir do provedor resolvido por config do tenant.
- `componentes/Constelacoes/Logistica/MissaoLogistica/Strategies/ErpAlocacaoWmsHookStrategy.php` e `WmsAlocacaoWmsHookStrategy.php` — em vez do `enviarRequisicaoJson` direto, resolver `WmsProviderFactory::build($config->provedorSaida)` e delegar `enviar(WmsEnvioContexto)`; manter o mapeamento de resultado→`EstadoIntegracao` idêntico. Carregar `external_ref` no contexto p/ o report fechar a ordem.
- `crons/wms_alocacao_integracao.php` — já itera por tenant com sessão sintética (linhas 101-114); passa a (a) carregar a config do tenant e (b) após push `enviado`, chamar `ReportarOrdemLaboratorio` p/ avançar o ciclo de `wms_ordens` quando a missão tem `referencia_tipo='WMS_ORDEM'`.
- `componentes/Missoes/Plataforma/sinais/Api/Bootstrap.php` — chamar `IntegracaoWms::bootstrap()` (registra `galpao`/`http_wms` no `WmsProviderRegistry`, com guard `temRegistro`), espelhando o registro de canais OTP já presente.
- `componentes/Missoes/Plataforma/sinais/Api/Routes.php` — **nenhuma mudança** (o webhook `POST /api/webhooks/{channel}` já existe; é o ganho da descoberta).
- `linguagens/*` / `galaxia/linguagens/*` — i18n via `show("texto PT")` (md5, auto-harvest) na tela de config e nas mensagens de erro; ver `genesis-views-i18n` + memória `galaxia_i18n_show_md5`. Sem prescrever chaves legíveis.

## Sondas e schema

### `integracao_wms_config` (Sonda: `IntegracaoWmsConfigSonda`) — Fase 1
```sql
CREATE TABLE integracao_wms_config (
    id SERIAL PRIMARY KEY,
    -- saída
    provedor_saida   VARCHAR(40)  NOT NULL DEFAULT 'galpao' COMMENT 'chave no WmsProviderRegistry',
    ativo_saida      SMALLINT     NOT NULL DEFAULT 1         COMMENT 'push legado ON por padrão (não-regressão)',
    endpoint_erp     VARCHAR(255)                            COMMENT 'sobrescreve o default de env; NULL=usa env',
    endpoint_wms     VARCHAR(255),
    -- entrada
    ativo_entrada    SMALLINT     NOT NULL DEFAULT 0         COMMENT 'receiver opt-in (OFF por padrão)',
    contrato_versao  VARCHAR(10)  NOT NULL DEFAULT 'v1',
    -- credencial de saída (segredo do WMS externo; NULL=usa env/legado)
    token_saida_ref  VARCHAR(120)                            COMMENT 'ref/nome do segredo; valor real fica em env, não na linha',
    -- multi-tenant (block_* sem DEFAULT 1; framework injeta)
    block_empresa_id INTEGER,
    block_filial_id  INTEGER,
    block_usuario_id INTEGER,
    block            INTEGER,
    data_cadastro       TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW(),
    data_atualizacao    TIMESTAMP WITHOUT TIME ZONE,
    usuario_atualizacao VARCHAR(255)
);
CREATE UNIQUE INDEX uniq_integracao_wms_config_empresa ON integracao_wms_config (block_empresa_id);
```
**`propeties`**: `customerControl: false` (espelha `ScanCotaConfigSonda`: a tabela não tem `block_company_id`; o isolamento é explícito por `block_empresa_id` em `obterConfig(empresaId)`/`salvarConfig()`). `configDefault()` devolve `provedor_saida='galpao'`, `ativo_saida=1`, `ativo_entrada=0` → **empresa sem linha = comportamento de hoje** (AC1.5, AC2.3).

> O **valor** de credenciais não vai na linha: `token_saida_ref` aponta um segredo em `define()` global (padrão `EvolutionConfig`), evitando segredo em banco. O secret de **entrada** (HMAC) já mora em `webhook_endpoints.secret`.

### `wms_ordens` (Sonda: `WmsOrdemSonda`) — Fase 2
```sql
CREATE TABLE wms_ordens (
    id SERIAL PRIMARY KEY,
    external_ref         VARCHAR(120) NOT NULL COMMENT 'id da ordem no WMS — chave de dedup (AC6.1)',
    tipo                 VARCHAR(30)  NOT NULL COMMENT 'separacao|movimentacao|alocacao|inventario',
    contrato_versao      VARCHAR(10)  NOT NULL DEFAULT 'v1',
    status               VARCHAR(20)  NOT NULL DEFAULT 'recebida'
        COMMENT 'recebida|rejeitada|traduzida|em_execucao|concluida|reportada',
    missao_logistica_id  INTEGER REFERENCES missao_logistica(id) ON DELETE SET NULL,
    motivo               TEXT         COMMENT 'razão de rejeição/quarentena (AC5.3, AC4.4) — observabilidade',
    payload_bruto        JSONB        COMMENT 'envelope recebido, p/ reprocesso/auditoria',
    recebida_em          TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW(),
    reportada_em         TIMESTAMP WITHOUT TIME ZONE,
    -- multi-tenant: espelha missao_logistica (block_empresa_id, customerControl=false)
    block_empresa_id INTEGER,
    block_filial_id  INTEGER,
    block_usuario_id INTEGER,
    block            INTEGER,
    data_cadastro       TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW(),
    data_atualizacao    TIMESTAMP WITHOUT TIME ZONE,
    usuario_atualizacao VARCHAR(255)
);
-- idempotência: a mesma ordem reenviada NÃO duplica (AC6.2)
CREATE UNIQUE INDEX uniq_wms_ordens_ref ON wms_ordens (block_empresa_id, external_ref);
CREATE INDEX idx_wms_ordens_status ON wms_ordens (status, block_empresa_id);
```
**`propeties`**: `customerControl: false` — **decisão de tenant**: `wms_ordens` se acopla a `missao_logistica` (que usa `block_empresa_id`, `customerControl=false`), não a `wms_movimentos` (`customerControl=true`/`block`). Escolher o mesmo controle do que ela mais cruza evita a armadilha multi-tenant da spec (regra 15 / restrição "cruzar exige resolver o mesmo tenant"). O receiver grava sob a sessão sintética do tenant do canal. **Espelhar o conjunto completo de colunas de auditoria/block** senão `create()` quebra (memória `modelinteligencia_create_audit_columns`).

> Sem novo schema para **marcar origem**: a missão WMS usa o que já existe —
> `missao_logistica.referencia_tipo='WMS_ORDEM'` + `referencia_id=wms_ordens.id` +
> `configuracoes.origem='wms'`. Distingue das nativas (AC8.2) e dá o link de volta
> para o report. `wms_movimentos` não ganha coluna: o cron já agrupa por
> `missao_logistica_id` e resolve a `referencia` da missão uma vez por grupo.

## Endpoints / fluxo

| Método | Rota | Handler | Laboratório | Auth |
|---|---|---|---|---|
| POST | `/api/webhooks/{channel}` *(já existe)* | `Api\Handlers\Webhooks\InboundHandler` | `IntegracaoWms\Laboratorios\ReceberOrdemLaboratorio` (via `webhook_endpoints.target_lab`) | **HMAC por canal** (`HmacValidator` + `webhook_endpoints.secret`, por empresa) |
| — (cron) | `crons/wms_alocacao_integracao.php` | — | `ReportarOrdemLaboratorio` + `Erp/WmsAlocacaoWmsHookStrategy` (via `WmsProvider`) | sessão sintética por tenant |
| — (UI) | view do sinal `IntegracaoWms` (Galaxia) | controller `IntegracaoWms` | `Consultar/SalvarConfigLaboratorio` | sessão do gestor/TI (não `/api/v1`) |

Config NÃO usa `/api/v1` (interno, gateado por allowlist de devs) — segue o padrão
`ScanCotaConfig` (Laboratório + view do sinal). O receiver NÃO usa `/api/v1` (regra 7
da spec: superfície externa separada). Ambas as restrições já são satisfeitas pela
infra existente.

### Fluxo inbound (Fase 2)
1. WMS chama `POST /api/webhooks/wms-<empresa>` com header de assinatura HMAC + envelope versionado.
2. `InboundHandler`: lookup do canal → valida HMAC → `WmsPayloadNormalizer::normalizar()` (resolve versão; rejeita major desconhecida → contrato versionado, AC3.5) → invoca `target_lab = ReceberOrdemLaboratorio`.
3. `ReceberOrdemLaboratorio`: resolve tenant do `webhook_endpoints.customer_id`/`block_empresa_id` → **estabelece sessão sintética** (padrão do cron) → `OrdemEnvelopeValidator` → **dedup** por `external_ref` (`WmsOrdemSonda`): se existe, responde aceite idempotente "já-processada" (AC3.3/6.2/6.3) → `OrdemIdentidadeValidator` (produto/endereço) → `TraducaoOrdemFactory::create(OrdemTipo)` → tradutor monta `$data` e cria a missão via `GestaoMissoes*` → grava `wms_ordens` (status `traduzida`, `missao_logistica_id`).
4. Resposta inequívoca ao WMS: `200 {received, ordem_id, status: aceita|ja_processada}` ou `4xx {rejeitada, motivo}`.

### Fluxo report-back (Fase 2, reuso da infra assíncrona)
- O operador executa a missão WMS no app de hoje (sem mudança de UX) → grava `wms_movimentos` com `integracao_wms='pendente'` (já é assim).
- O cron empurra via `WmsProvider` configurado (Fase 1), incluindo `external_ref` no payload → o WMS fecha a ordem. Em `enviado`, `ReportarOrdemLaboratorio` avança `wms_ordens.status` → `reportada`. Falha → fica `pendente`/`falha` e volta à fila de retry (AC7.2/7.4), nada se perde.

## Padrões de extensibilidade

- **Provedor de saída — Registry+Interface+Factory** (`Provedores/`): há **múltiplos destinos plugáveis** (galpão legado, WMS externo genérico, futuros) selecionados por config — exatamente o caso de uso do `OtpCanalRegistry`. A spec **exige** trocar/adicionar provedor sem editar o núcleo (AC2.2) e tirar endpoint/credencial do código (AC1.2/2.1). `WmsProviderFactory` ganha `setOverride()` p/ testar sem HTTP real (espelha `OtpCanalFactory`). Interface enxuta (2 métodos: `nome()`, `enviar()`) — dentro do limite do checklist `genesis-callbacks-adapters`.
- **Tradução ordem→missão — Factory+enum** (`Tradutores/`): a spec define uma **lista fechada e conhecida** de 4 tipos (separação, movimentação, alocação, inventário), mapeada 1:1 a `OperacaoTipo`/`TipoMissao`. Type-safety importa e a lista é do próprio sinal → Factory+enum (estilo `OperacaoAdapterFactory`), **não** Registry de string. Adicionar tipo = novo `*Tradutor` + chave no Factory.
- **Receiver — Adapter via `PayloadNormalizer`** (`Webhooks/WmsPayloadNormalizer`): implementa a interface já existente do `InboundHandler`; isola o envelope/versão do WMS do `$data` interno. É o ponto onde o **versionamento de contrato** vive (AC3.5).
- **NÃO introduzir** Registry adicional nem Callbacks: há **um** consumidor por concern (um receiver, um pipeline de report). O seam de "fonte da verdade do saldo" já existe e é o `EsperadoProviderRegistry` (`Inventariociclico/Esperado/`) — reusar, não recriar.

## Ordem de implementação (sequencial, cada fase mantém a baseline verde)

### Fase 1 — Saída plugável + config por empresa (sem regredir o push legado)
1. Migration `001_create_integracao_wms_config.sql` → `php tests/integration/IntegracaoWms/config_migration_test.php`.
2. `IntegracaoWmsConfigSonda` + `IntegracaoWmsEnvConfig` (defaults = URLs/token de hoje, via env).
3. `WmsProviderInterface`/`Registry`/`Factory` + VOs + `GalpaoProvider` (encapsula o push atual byte-a-byte) + `HttpWmsProvider` (stub nesta fase).
4. Refatorar `HookSincronizadorBase` + `Erp/WmsAlocacaoWmsHookStrategy` p/ resolver provedor por config; `crons/wms_alocacao_integracao.php` carrega a config do tenant.
5. `IntegracaoWms::bootstrap()` registra provedores; ligado em `Api/Bootstrap.php`.
6. Tela de config (`html/`, genesis-views-i18n) + `Consultar/SalvarConfigLaboratorio`.
7. Testes TDD + **baseline de não-regressão**: `GalpaoProviderParidadeTddTest` (payload/destino/opc idênticos a hoje p/ a empresa default) + a suíte WMS atual verde.
- **Mantém verde**: empresa sem `integracao_wms_config` → `configDefault()` → galpão como hoje. Os testes de push da alocação (`AlocacaoAdapter`/hooks) passam sem alteração de payload.

### Fase 2 — Receiver de entrada + tradução + idempotência + origem + ciclo de report
8. Migration `002_create_wms_ordens.sql` + `003_seed_webhook_endpoint_dev.sql` → integration migration test.
9. `WmsOrdemSonda` (dedup por `external_ref`, transições de `status`).
10. `WmsPayloadNormalizer` (envelope versionado) + `OrdemEnvelopeValidator`.
11. `TraducaoOrdemInterface`/`Factory` + `OrdemTipo` (sem tradutores concretos ainda — Fase 3).
12. `ReceberOrdemLaboratorio` (tenant sintético → dedup → validar → traduzir → criar missão → gravar ordem; marca `referencia_tipo='WMS_ORDEM'`).
13. `ReportarOrdemLaboratorio` + plugar no cron; `Http/HttpWmsProvider` real (HMAC out + `external_ref`).
14. Seed do canal `webhook_endpoints` por empresa (`target_lab=ReceberOrdemLaboratorio`, `payload_mapper=WmsPayloadNormalizer`, `secret` por empresa).
15. Testes: receiver (HMAC ok/inválido, dedup idempotente, tenant correto, identidade desconhecida→rejeição com motivo), ciclo de ordem.
- **Mantém verde**: receiver é opt-in (`ativo_entrada=0` default; canal só existe p/ empresa plugada). Missões nativas e o push legado (Fase 1) intactos. Sem canal registrado, `/api/webhooks/*` responde `404` como hoje.

### Fase 3 — Cobrir os 4 tipos de ordem (um por vez, depois generalizar)
16. **1º tipo = `alocacao`/recebimento** (`AlocacaoOrdemTradutor`) — recomendado começar por ele: mapeia direto a `OperacaoTipo::ALOCACAO`, cuja execução **e** push já estão completos (operacao-wms F1-F5), provando o loop ponta-a-ponta com o mínimo de superfície nova.
17. Generalizar: `SeparacaoOrdemTradutor` (→ `TipoMissao::SEPARACAO`, reusa `GestaoMissoesSeparacao`), `MovimentacaoOrdemTradutor` (→ `OperacaoTipo::MOVIMENTACAO`), `InventarioOrdemTradutor` (→ `OperacaoTipo::INVENTARIO_*`).
18. Cada tipo = `*Tradutor` + chave no `TraducaoOrdemFactory` + teste de tradução (identidade resolvida, itens corretos, `operacao_tipo`/`tipo_missao` corretos). Tipo não suportado pelo release → `ReceberOrdemLaboratorio` rejeita com motivo (AC4.4).
- **Mantém verde**: tradutor ausente = ordem daquele tipo **rejeitada com motivo** (nunca missão "meio pronta"). Tipos já liberados não são afetados ao adicionar o próximo.

> Decisão de produto deferida (atualização/cancelamento de ordem pelo WMS): **fora das
> Fases 1-3**; v1 cobre criação + dedup de reenvio. O `WmsOrdemSonda`/`status` já
> comporta um `cancelada` futuro sem migração.

## Riscos e decisões não-óbvias

1. **Tenant no receiver (armadilha multi-tenant).** O `InboundHandler` não estabelece sessão de tenant; `wms_movimentos` é `customerControl=true`/`block` e `missao_logistica` é `block_empresa_id`/`customerControl=false`. **Mitigação**: `ReceberOrdemLaboratorio` resolve o tenant do canal autenticado (`webhook_endpoints.customer_id`/`block_empresa_id`) e estabelece a **sessão sintética** (mesmo padrão de `crons/wms_alocacao_integracao.php:101-114`, setando `galaxiaUserInfo` na raiz E em `$_SESSION['dashboard']`) antes de qualquer Sonda. `wms_ordens` fica `customerControl=false`/`block_empresa_id` p/ alinhar com a missão que cria. Teste de integração obrigatório cruzando 2 tenants (AC3.4).
2. **Auth do webhook (deferida → resolvida).** **HMAC por canal** (`hmac_sha256` via `HmacValidator`, já implementado) sobre token estático (sem proteção a tamper) e mTLS (ops pesado). **Replay**: exigir header de timestamp no envelope + janela curta; o `external_ref` único (dedup) já neutraliza reprocessar. Rotação: novo `secret` na linha do canal (sem deploy). Documentar no contrato.
3. **Identidade desconhecida (deferida → resolvida).** **Rejeitar a ordem inteira** com motivo gravado em `wms_ordens.motivo` (determinístico, atômico, observável) — não criar parcial (AC4.4 proíbe "meio pronta"). Quarentena fica documentada como evolução (campo `status` já comporta), mas v1 = rejeição. Produto via `importacao_id`=`codigomd5` (memória `erp_importacao_id_mapping`); endereço via `wms_localizacoes.codigo` (único por `block`); divergência de namespace legado (`codigo_enderecamento`) resolvida **no tradutor**, sem vazar ao operador (AC5.4).
4. **Fonte da verdade / reconciliação (deferida → resolvida).** Genesis **não sobrescreve** o WMS: `wms_movimentos` é log append-only de execução; o report empurra, o WMS concilia. Conflito de saldo na tradução: **não bloquear** por saldo local (segue a decisão D1 já vigente em `movimentacao`) — confiar na ordem do WMS. O seam `EsperadoProviderRegistry` permite, no futuro, trocar `EsperadoLocalProvider`→`EsperadoWmsProvider` p/ ler o saldo oficial sem mexer no motor. A UI já avisa "saldo local" (memória `project_alocacaowms_endereco_primeiro`); com WMS plugado, rotular "WMS é a fonte".
5. **Não-regressão do push legado.** Risco de a refatoração do provedor mudar payload/destino. **Mitigação**: `GalpaoProvider` é extração mecânica (sem mudança de shape) + `GalpaoProviderParidadeTddTest` comparando `opc`/payload/endpoint com os atuais; `ativo_saida=1` no default mantém o push ligado para quem não plugou.
6. **Storage da idempotência (deferida → resolvida).** Tabela dedicada `wms_ordens` com `UNIQUE(block_empresa_id, external_ref)` — não em JSONB da missão (sem unique/observabilidade) nem em `api_rate_limit`. Concentra dedup + ciclo + fila de report num lugar.
7. **Versionamento do contrato.** `WmsPayloadNormalizer` resolve por `version` do envelope; major desconhecida → rejeição explícita (AC3.5). `contrato_versao` persistido em `wms_ordens` e `integracao_wms_config` p/ rastreio.

## Breaking changes
Nenhum. Tudo é adição + extração mecânica. Invariantes preservadas: `tipo_missao_id`/`referencia_tipo` existentes não mudam; nenhuma missão migrada; push legado idêntico para empresa sem config; `/api/v1` e `/api/webhooks/{channel}` sem mudança de rota.

## Plano de teste
- **Unit (TDD)**: `WmsProviderRegistry`/`Factory` (resolve/override/reset); `GalpaoProviderParidade` (payload/destino idênticos); `IntegracaoWmsConfigSonda` (default-off, upsert por empresa); `WmsOrdemSonda` (dedup, transições de status); `WmsPayloadNormalizer` (versão ok/desconhecida); `TraducaoOrdemFactory` + `AlocacaoOrdemTradutor` (identidade resolvida, itens/operacao_tipo corretos); `ReceberOrdemLaboratorio` (HMAC, dedup idempotente, identidade desconhecida→rejeição).
- **Integration (`dev_gx_goldie`, block de teste)**: migrations aplicadas; ponta-a-ponta inbound = `POST /api/webhooks/wms-<empresa>` assinado → missão criada no tenant correto → executar no app → cron empurra com `external_ref` → `wms_ordens.status=reportada`; reenvio do mesmo `external_ref` → sem 2ª missão; cross-tenant (2 empresas) sem vazamento. Limpar resíduo (endereços/refs `ZZ-*TEST`).
- **Manual (smoke do usuário)**: na tela de config, plugar provedor/credencial de uma empresa de dev; disparar uma ordem de alocação assinada (curl/Postman); ver a missão aparecer no app e a ordem fechar de volta; conferir que uma empresa **sem** config segue empurrando ao galpão como hoje.

## Out of scope (confirmando spec)
- Reescrever o app do operador ou a bipagem — só muda a *origem* da missão e o *report* de volta.
- Apagar/migrar de imediato o push legado do galpão — segue vivo como provedor `galpao`.
- Transformar o Genesis em WMS / gerir estoque contábil — WMS externo é a fonte da verdade.
- Abrir o `/api/v1` interno ao WMS — a entrada é `/api/webhooks/{channel}` com HMAC própria.
- A feature `separacao-por-endereco` — é **consumidora** desta camada (o `SeparacaoOrdemTradutor` da Fase 3 é o ponto de plugue), não esta camada.
- **Acréscimos técnicos**: atualização/cancelamento de ordem pelo WMS (deferido pós-F3); quarentena de identidade desconhecida (v1 = rejeição); `EsperadoWmsProvider` real lendo saldo do WMS (seam pronto, implementação futura); rotação automática de secret (manual por enquanto).
