# Alocação WMS — Fix de escopo da remoção + push do estorno

**Data:** 2026-06-25
**Status:** aprovado (brainstorming) — pronto para plano de implementação
**Módulo:** `componentes/Missoes/Logistica/sinais/AlocacaoWms` + cron + hooks em `Constelacoes/Logistica/MissaoLogistica`

## Problema

Ao remover um produto de um endereço ("voltar atrás"), o operador recebe
**"Nada a remover neste endereço."** mesmo com o produto visível no painel.

Causa-raiz (confirmada por teste em `dev_gx_goldie` —
`AlocacaoWms/Testes/Laboratorios/RemoverAlocacaoLaboratorioTddTest.php`):
**assimetria de escopo** entre as duas pontas do fluxo.

| Ponta | Método | Filtro |
|---|---|---|
| Consulta (alimenta o painel/lixeira) | `WmsMovimentoSonda::listarPorLocalizacao` / `saldoPorLocalizacaoProduto` | `localizacao_codigo` + `status` → **cross-missão** |
| Remoção (ação da lixeira) | `WmsMovimentoSonda::cancelarPorMissaoProdutoLocalizacao` | `missao_logistica_id` + `produto_id` + `localizacao_codigo` + `status` → **só a missão atual** |

Como cada designação cria uma missão nova (`GestaoMissoesAlocacaoWms`), o mesmo
endereço acumula produtos de missões diferentes. O painel mostra um produto de
outra missão; a lixeira procura na missão atual e não acha nada → `rowCount=0` →
a mensagem (`RemoverAlocacaoLaboratorio.php:65`).

Segundo requisito: o cancelamento **deve ser propagado** aos sistemas externos
(ERP + WMS). Hoje o cancelamento só vira `status=cancelada` localmente e o cron
de integração **para de enxergar** a linha (gate por `status='confirmada'`) — o
externo nunca fica sabendo do estorno. Os endpoints externos hoje são *stubs*
(não plugados de fato), mas a plumbing deve ficar pronta no mesmo trilho da
confirmação.

## Objetivos

1. **Fix:** remover do endereço deve cancelar o que o painel mostra (cross-missão).
2. **Estorno:** propagar o cancelamento ao ERP/WMS via o mesmo worker assíncrono,
   **apenas para o que já foi efetivamente enviado** (`integracao=enviado`), por
   canal (ERP e WMS independentes).

## Não-objetivos (fora desta rodada)

- Case-sensitivity de `localizacao_codigo` (S3 do teste) — fragilidade separada.
- `parse_str` mutilando `+`/`%`/`&`/`=` na WHERE bind (S4) — fragilidade separada.
- Mudar a semântica cross-`operacao_tipo` da remoção (já é o comportamento atual:
  remover do endereço tira todas as operações daquele produto ali).
- Implementar o consumidor externo real (segue stub).

## Arquitetura atual (referência)

- **Confirmação:** `ConfirmarAlocacaoLaboratorio` → `WmsMovimentoSonda::registrarMovimento`
  grava `status=confirmada`, `integracao_erp/wms=pendente`.
- **Worker:** `crons/wms_alocacao_integracao.php` (a cada 2 min, por tenant/block via
  sessão sintética) varre `status='confirmada' AND integracao_* IN ('pendente','falha')`,
  agrupa por missão e chama `hook->sincronizar($itens, $missaoId)`.
- **Hooks:** `ErpAlocacaoWmsHookStrategy` / `WmsAlocacaoWmsHookStrategy` (POST p/ endpoint
  externo) marcam `integracao_* = enviado|falha` via `marcarIntegracaoErp/Wms`.
- **opc/payload:** definidos pelos adapters de operação (`operacaoPushErp/Wms`,
  `payloadPushErp/Wms`) em `AlocacaoWms/Strategies/Operacao`.
- Colunas `integracao_erp/wms` e `status` são `varchar(20)` → **novos valores não
  exigem migration de dados**.

## Desenho

### A. Fix de escopo da remoção

`RemoverAlocacaoLaboratorio::removerProdutoDoEndereco` continua recebendo `missaoId`
para **permissão** (operador legítimo numa missão ativa) e **extrato** (auditoria na
missão atual), mas troca a chamada de cancelamento:

- **De:** `cancelarPorMissaoProdutoLocalizacao($missaoId, $produtoId, $loc)`
- **Para:** `cancelarPorProdutoLocalizacao($produtoId, $loc)` (cross-missão, tenant).

Permanece append-only (soft-delete → `cancelada`) e cross-`operacao_tipo`.
A autorização continua sendo a missão atual (o painel já é cross-missão; o operador
está autorizado para a sessão/endereço).

### B. Enum `EstadoIntegracao` (novos estados, sem migration)

```
ESTORNO_PENDENTE = 'estorno_pendente'   // cancelado; precisa avisar o externo (só se era 'enviado')
ESTORNADO        = 'estornado'          // estorno confirmado pelo externo
ESTORNO_FALHA    = 'estorno_falha'      // tentativa de estorno falhou; volta p/ retry
```
+ `label()` correspondente. Todos cabem em `varchar(20)`.

### C. `WmsMovimentoSonda` — cancelamento + fila de estorno

`cancelarPorProdutoLocalizacao(int $produtoId, string $localizacaoCodigo): int`
(substitui `cancelarPorMissaoProdutoLocalizacao`; retorna nº cancelados) via **3
`update()` idempotentes** (sem raw SQL — `feedback_no_raw_sql`):

1. `status: confirmada → cancelada` WHERE `produto_id` + `localizacao_codigo` +
   `status='confirmada'` (cross-missão, tenant via customerControl). rowCount = cancelados.
2. `integracao_erp: enviado → estorno_pendente` WHERE `produto_id` + `localizacao_codigo` +
   `status='cancelada'` + `integracao_erp='enviado'`.
3. `integracao_wms: enviado → estorno_pendente` WHERE `produto_id` + `localizacao_codigo` +
   `status='cancelada'` + `integracao_wms='enviado'`.

Os passos 2/3 só atingem o que **já foi enviado** (gatilho confirmado). O que nunca
foi enviado fica `pendente` e, como `status≠confirmada`, o cron de **registro** para
de empurrar — nada a estornar. O filtro `enviado AND cancelada` é idempotente
(quem já virou `estornado`/`estorno_pendente` não é repego como `enviado`).

Novos métodos de fila (espelham `buscarPendentesIntegracao*`):
- `buscarPendentesEstornoErp(int $limite): array` → `status='cancelada' AND integracao_erp IN ('estorno_pendente','estorno_falha')`.
- `buscarPendentesEstornoWms(int $limite): array` → idem WMS.

Marcação do resultado: **reusar** `marcarIntegracaoErp/Wms` passando
`ESTORNADO` ou `ESTORNO_FALHA` (já gravam estado + timestamp + erro).

### D. Adapters — opc/payload de reversão (default em `OperacaoAdapterBase`)

Acrescentar à interface + default no base (adapters concretos podem sobrescrever):
- `operacaoPushErpEstorno(): string` / `operacaoPushWmsEstorno(): string`
  — opc provisório `'cancelar_localizacao_wms'` (a confirmar com o time do ERP/WMS).
- `payloadPushErpEstorno(array $item, int $missaoId): array` /
  `payloadPushWmsEstorno(...)` — reusa `payloadPushErp/Wms`, troca `opc` pelo de
  estorno e adiciona `'acao' => 'estorno'`.

### E. Hooks — método `estornar($itens, $missaoId)`

Em `ErpAlocacaoWmsHookStrategy` e `WmsAlocacaoWmsHookStrategy`: espelha `sincronizar`,
mas usa `operacaoPush*Estorno()` / `payloadPush*Estorno()` e marca
`ESTORNADO`/`ESTORNO_FALHA`. Mesmo endpoint stub atual. (Refatoração opcional:
extrair `processar($itens, $missaoId, $modo)` compartilhado — decidir no plano.)

### F. Cron `wms_alocacao_integracao.php` — 2ª varredura

1. Query de descoberta de tenants passa a incluir cancelados pendentes de estorno:
   `... OR (status='cancelada' AND (integracao_erp IN ('estorno_pendente','estorno_falha')
   OR integracao_wms IN ('estorno_pendente','estorno_falha')))`.
2. Após os passes de registro, dois passes novos: `buscarPendentesEstornoErp/Wms` →
   agrupa por `missao_logistica_id` → `hook->estornar($itens, $missaoId)`.

(Opcional) migration de índice parcial cobrindo os estados de estorno — os índices
`idx_wms_movimentos_integ_erp/wms` hoje só cobrem `('pendente','falha')`. Volume baixo;
decidir no plano.

### G. Camada de chamada

`RemoverAlocacaoLaboratorio` chama o novo método da Sonda; o dispatcher
`AlocacaoWms::removerProduto` e o payload do front **não mudam**.

## Fluxo de dados (estorno)

```
remover (lixeira)
  └─ RemoverAlocacaoLaboratorio (valida missão/permissão; extrato na missão atual)
       └─ WmsMovimentoSonda::cancelarPorProdutoLocalizacao
            ├─ status: confirmada→cancelada (cross-missão)
            ├─ integracao_erp: enviado→estorno_pendente
            └─ integracao_wms: enviado→estorno_pendente
... (assíncrono) ...
cron wms_alocacao_integracao (por tenant)
  ├─ [registro] status=confirmada & integracao_* IN (pendente,falha) → hook->sincronizar
  └─ [estorno]  status=cancelada  & integracao_* IN (estorno_pendente,estorno_falha)
                 → hook->estornar → POST(opc=cancelar...) → marca estornado/estorno_falha
```

## Tratamento de erros

- Estorno é assíncrono e idempotente; falha vira `estorno_falha` (retry no próximo ciclo),
  espelhando `falha` do registro.
- Falha por tenant não derruba o worker (já é assim).
- Movimento cancelado que nunca foi `enviado`: nenhum push (correto — nada externo a reverter).

## Testes (TDD)

Integration (`dev_gx_goldie`, padrão do módulo):
- **Regressão do fix:** S2 (cross-missão) do teste de reprodução passa a **PASS**
  (remoção retorna `ok=true`, `removidos>=1`); S1 (mesma missão) continua PASS.
- **Estorno agendado só p/ enviado:** cancelar linha com `integracao_wms='enviado'`
  → vira `estorno_pendente`; cancelar linha `pendente` → continua `pendente` (sem estorno).
- **Por canal independente:** ERP e WMS agendam estorno separadamente.
- **Fila:** `buscarPendentesEstornoErp/Wms` retorna os `estorno_pendente`/`estorno_falha`.
- **Hook:** `estornar()` marca `estornado` no sucesso e `estorno_falha` na falha.
- **Cron:** 2º passe processa cancelados (pode ser exercitado via Sonda+hook ou e2e).

Cleanup obrigatório no `finally` (sem lixo no banco).

## Risco / blast radius

- `cancelarPorMissaoProdutoLocalizacao` e `removerProdutoDoEndereco` têm **1 chamador
  cada**; `StatusAlocacao::CANCELADA` de movimento só é lido dentro do próprio módulo.
- Sem mudança de schema/contrato/payload do front; soft-delete reversível/auditável.
- Mudança de comportamento consciente: remoção cross-missão pode cancelar movimento
  confirmado de outra missão no mesmo endereço (modelo endereço-primeiro = estado físico
  compartilhado). Alinhado ao painel.
