# Missão de Operação WMS — Plano técnico (refatoração strangler)

## Resumo de decisão arquitetural

A feature **não cria um sinal novo nem um tipo de missão por operação**. Ela **generaliza o que já existe** (`AlocacaoWms` + `wms_alocacoes` + Handler/Gestao/Strategy/Hooks) em três eixos, mantendo a Alocação WMS rodando sem regressão (strangler-fig):

1. **Tipo de missão genérico, não-quebrante**: mantém o case/`id=14`/valor `ALOCACAO_WMS` do enum `TipoMissao` (linhas em produção dependem de `tipo_missao_id=14` e `referencia_tipo='ALOCACAO_WMS'`). Apenas relabela para "Operação WMS" e introduz o discriminador `configuracoes.operacao_tipo` (default `alocacao`). **Nenhuma migração de dados de missões.** Alternativa (case novo `OPERACAO_WMS` id=15 + backfill) fica documentada em "Alternativas", mas **não** é a recomendada.
2. **Persistência unificada**: `wms_alocacoes` → `wms_movimentos` com coluna `operacao_tipo` (rename + add column, backfill `'alocacao'`). O histórico append-only, o saldo local e o gate de integração por status continuam idênticos.
3. **Isolamento por operação via Adapter + Registry**: um `OperacaoAdapter` por `operacao_tipo` (espelhando `DesignacaoStrategyFactory`) concentra escopo de Designação, fluxo de execução, validações e payload de push. A alocação vira o **primeiro adapter**, com paridade comportamental garantida por testes de baseline.

Integração permanece **push-only** (Genesis empurra os movimentos; sem read API de saldo oficial). Banco PostgreSQL (`gx_goldie`/`dev_gx_goldie`), colunas `block_*` sem `DEFAULT`, writes via `ModelInteligencia` (`update()`/`create()`), sem raw SQL.

## Sinal de referência
- **Generalizar (não recriar)**: `componentes/Missoes/Logistica/sinais/AlocacaoWms/` é a base — Sonda, Laboratorios, Validators, Enums, Migrations.
- **Imitar (registry)**: `Designacoes/Strategies/Designacao/DesignacaoStrategyFactory.php` (mapa enum→classe, `create()`/`createFromId()`) — modelo para o `OperacaoAdapterFactory`.
- **Imitar (criação)**: `Designacoes/Laboratorios/GestaoMissoesBase.php` (template method `processarEnvio`) — já tem os hooks `permiteSelecaoVazia()` e `getConfiguracoesAdicionais()`.
- **Imitar (execução)**: `App/Handlers/BaseHandler.php` (`executarFluxoBipagem`, `renderMissionListing`, `loadActivatedMission`) e o atual `AlocacaoWmsHandler` (sessão livre, conclusão explícita).
- **Imitar (hook push)**: `Constelacoes/Logistica/MissaoLogistica/Strategies/HookSincronizadorBase.php` + os dois hooks atuais.
- **NÃO imitar**: V1 legado (`Inteligencias/`, `sinais/Pessoas`); usar Sondas (`feedback_sondas_v2`).

## Decisão do tipo de missão (não-quebrante)

`componentes/Constelacoes/Logistica/MissaoLogistica/Enums/TipoMissao.php`:
- **Manter** `case ALOCACAO_WMS = 'ALOCACAO_WMS';`, `id()=14`, `name()='ALOCACAO_WMS'`, `fromId(14)`.
- **Alterar apenas** `label()`: `'Alocação WMS'` → `'Operação WMS'`.
- O "tipo" continua sendo um só; a **operação concreta** é `configuracoes.operacao_tipo`. Missões antigas (sem a chave) caem no default `alocacao`.

> Por que não renomear o case: o valor `'ALOCACAO_WMS'` está gravado em `missao_logistica_itens.referencia_tipo` e é casado em código (`AlocacaoWmsStrategy::getTipo`, `GestaoMissoesAlocacaoWms`, hooks). Renomear exigiria migração de dados + varredura de strings. O ganho de "nome bonito" não paga o risco — a generalização real vem do `operacao_tipo`.

## Enum de operação

Novo `componentes/Missoes/Logistica/sinais/AlocacaoWms/Enums/OperacaoTipo.php` (backed enum string):

```php
enum OperacaoTipo: string {
    case ALOCACAO                 = 'alocacao';
    case INVENTARIO_ENDERECO      = 'inventario_endereco';
    case INVENTARIO_PRODUTOS      = 'inventario_produtos';
    case CONFERENCIA_LOCALIZACAO  = 'conferencia_localizacao';
    case VALIDACAO_OCUPACAO       = 'validacao_ocupacao';

    public static function fromConfig(?array $configuracoes): self {
        $v = $configuracoes['operacao_tipo'] ?? null;
        return self::tryFrom((string)$v) ?? self::ALOCACAO; // default não-quebrante
    }
    public function label(): string { /* rótulos PT via show() na view */ }
    public function exigeQuantidade(): bool { return in_array($this, [self::INVENTARIO_ENDERECO, self::INVENTARIO_PRODUTOS], true); }
    public function exigeEndereco(): bool   { return $this !== self::INVENTARIO_PRODUTOS; }
}
```

## Migração `wms_alocacoes` → `wms_movimentos` (rename feito na F1)

Decidido fazer o **rename físico agora** (ambiente DEV; WMS externo ainda não
conectado, sem dado de prod relevante) em vez de adiar via expand/contract.
Migração `004_generalizar_para_wms_movimentos.sql` (Postgres, idempotente —
`IF EXISTS`/`IF NOT EXISTS`, rodar 2x é seguro):

```sql
ALTER TABLE IF EXISTS wms_alocacoes RENAME TO wms_movimentos;
ALTER INDEX IF EXISTS idx_wms_alocacoes_missao    RENAME TO idx_wms_movimentos_missao;   -- + produto/integ_erp/integ_wms/operacao
ALTER INDEX IF EXISTS wms_alocacoes_pkey          RENAME TO wms_movimentos_pkey;
ALTER TABLE wms_movimentos ADD COLUMN IF NOT EXISTS operacao_tipo VARCHAR(40) NOT NULL DEFAULT 'alocacao';
ALTER TABLE wms_movimentos ADD COLUMN IF NOT EXISTS ocupacao_estado VARCHAR(30);
CREATE INDEX IF NOT EXISTS idx_wms_movimentos_operacao ON wms_movimentos (operacao_tipo, block);
```

No mesmo passo: Sonda renomeada (`WmsAlocacaoSonda` → `WmsMovimentoSonda`,
`entity='wms_movimentos'`), raw SQL do cron e SQLs de teste atualizados, e todas
as ~10 referências de classe migradas. **Deploy:** a migração precisa rodar no
prod (`gx_goldie`) junto do código — como é o passo de rename, é o deploy
coordenado (aceitável: feature ainda não está em uso real).

Colunas existentes preservadas: `missao_logistica_id`, `missao_item_id` (nullable), `produto_id`, `localizacao_codigo`, `wms_localizacao_id`, `quantidade` (nullable), `origem_leitura`, `status` (`confirmada`/`cancelada`), `operador_id`, `confirmada_em`, `integracao_erp/_wms` (+`_em`), `integracao_erro`, `block_*`.

> **Semântica de `quantidade` por operação** (documentar; sem mudança de tipo): `alocacao` = quanto alocado (acumula); `inventario_*` = contagem absoluta no momento; `conferencia_localizacao` = conferido (opcional); `validacao_ocupacao` = `NULL`. Para `validacao_ocupacao`, o estado de ocupação vai em coluna nova `ocupacao_estado VARCHAR(30) NULL` (add nesta migration) ou em `missao_item_id`-less record com origem — **decisão**: coluna dedicada `ocupacao_estado` (consulta/índice simples).

## Sonda generalizada

`AlocacaoWms/Sondas/WmsAlocacaoSonda.php` → renomear arquivo/classe para `WmsMovimentoSonda` (manter um alias/`class_alias` temporário se houver `use` espalhado), `self::$entity = 'wms_movimentos'`. Ajustes:
- `registrarAlocacao(...)` → `registrarMovimento(..., OperacaoTipo $operacao, ?string $ocupacaoEstado = null)`: grava `operacao_tipo` (e `ocupacao_estado` quando aplicável). Manter um wrapper `registrarAlocacao()` que delega com `OperacaoTipo::ALOCACAO` (compat).
- Consultas (`listarPorMissao`, `listarPorLocalizacao`, `saldoPorLocalizacaoProduto`, `mapaLocalizacaoConfirmadaPorProduto`) ganham filtro opcional por `operacao_tipo` (default: todas, ou `alocacao` quando a semântica de saldo exigir). O gate `status='confirmada'` em `buscarPendentesIntegracao*` permanece.
- `marcarIntegracaoErp/Wms` inalterados (operam por `id`).

## Adapter + Registry de operação (isolamento)

Novo `AlocacaoWms/Strategies/Operacao/` espelhando o factory de designação:

```
Strategies/Operacao/
├── OperacaoAdapterInterface.php   # contrato
├── OperacaoAdapterBase.php        # defaults
├── OperacaoAdapterFactory.php     # OperacaoTipo->value => classe (create/createFromConfig)
├── AlocacaoAdapter.php            # operacao_tipo = alocacao  (paridade com hoje)
├── InventarioEnderecoAdapter.php
├── InventarioProdutosAdapter.php
├── ConferenciaLocalizacaoAdapter.php
└── ValidacaoOcupacaoAdapter.php
```

Contrato (`OperacaoAdapterInterface`), responsabilidades isoladas por operação:
- `getOperacaoTipo(): OperacaoTipo`
- **Designação**: `escopoSelecao(): string` (`produtos|enderecos|conjunto_produtos|livre`), `permiteSelecaoVazia(): bool`, `buscarItens(array $selecao): array` (estrutura de `missao_logistica_itens`).
- **Execução**: `viewExecucao(): string` (partial sob `App/html/operacaowms/`), `precisaEndereco(): bool`, `precisaProduto(): bool`, `precisaQuantidade(): bool`.
- **Confirmação**: `validar(array $contexto): ValidationResult`, `montarMovimento(array $contexto): array` (campos p/ `WmsMovimentoSonda::registrarMovimento`).
- **Push**: `operacaoPushErp(): string`, `operacaoPushWms(): string`, `payloadPush(object $movimento): array`.

`AlocacaoAdapter` encapsula exatamente o fluxo atual (modos livre/enderecamento, endereço-primeiro, conclusão explícita) — **nada de comportamento novo na alocação**.

## Generalização do domínio (confirmação)

- `AlocacaoWms/Laboratorios/ConfirmarAlocacaoLaboratorio.php`: vira orquestrador genérico (pode manter o nome ou criar `ConfirmarOperacaoLaboratorio` e deixar o antigo como wrapper). Resolve a missão → `OperacaoTipo::fromConfig` → `OperacaoAdapterFactory` → `adapter->validar()` → `adapter->montarMovimento()` → `WmsMovimentoSonda::registrarMovimento()` → registra extrato → marca integração pendente (igual hoje).
- `AlocacaoWms/Validators/ConfirmarAlocacaoValidator.php`: mantém as validações **transversais** (missão ativa, `operadorTemPermissao`) e delega as **específicas** ao `adapter->validar()` (quantidade obrigatória, endereço requerido, etc.). A trava relaxada de endereço da alocação fica dentro do `AlocacaoAdapter`.
- `RemoverAlocacaoLaboratorio.php` (voltar atrás): generalizar a mensagem/escopo, mas a mecânica (soft-delete `status=CANCELADA`) serve a todas as operações.

## Generalização da criação (Designação)

- `Designacoes/Laboratorios/GestaoMissoesAlocacaoWms.php`:
  - `getConfiguracoesAdicionais()` passa a gravar `operacao_tipo` (validado contra `OperacaoTipo`) **junto** do `modo_alocacao` atual (que continua válido só para `alocacao`).
  - `buscarItens()` e `permiteSelecaoVazia()` delegam ao adapter (`OperacaoAdapterFactory::createFromConfig`): produtos para alocação/inventário-produtos; endereços para inventário-endereço/conferência/validação; vazio quando o adapter permite.
- `Designacoes/Strategies/Designacao/AlocacaoWmsStrategy.php`: `getTitle()` → "Operação WMS"; `getPrimaryItems()` passa a depender do escopo do adapter (produtos vs endereços). Mantém `getSatelite()='GestaoMissoesAlocacaoWms'` e o `versaoGalaxia=2`.
- `DesignacaoStrategyFactory` permanece com a única chave `ALOCACAO_WMS` → `AlocacaoWmsStrategy` (o tipo continua um só).
- UI: `Designacoes/html/designar_unificado.php` + `.js` e `forms/enviar_unificado.php` ganham o **seletor de operação** (`operacao_tipo`) e a troca do escopo (produtos↔endereços) conforme a operação. Reusa o input oculto já existente (`input-modo-alocacao`) como modelo.

## Generalização da execução (App)

- `App/Handlers/AlocacaoWmsHandler.php`: após carregar a missão, resolve `OperacaoTipo::fromConfig($configuracoes)` e **despacha por operação** — renderiza a view do adapter (`adapter->viewExecucao()`), passando `operacaoTipo`, flags (`precisaEndereco/Produto/Quantidade`) e os dados que hoje já passa (`modoAlocacao`, `codigosBarrasMap`, etc.). O guard de acesso (`ACESSO_ALOCACAO_WMS`) e a conclusão explícita são mantidos.
- Views sob `App/html/` — **decisão**: criar `App/html/operacaowms/` com `item.php` "casca" que inclui o partial por operação (`_alocacao.php`, `_inventario_endereco.php`, …), reaproveitando `App/html/shared/*`. O `App/html/alocacaowms/item.(php|js)` atual é portado como o partial `_alocacao` (sem mudança de comportamento) ou mantido e incluído. Rotas em `App/App.php`: manter `alocacaowms` e adicionar alias `operacaowms` apontando ao mesmo handler.
- `App/Strategies/Lista/AlocacaoWmsListaStrategy.php`: título/ícone passam a refletir "Operação WMS"; opcionalmente segmenta a lista por `operacao_tipo`.

## Generalização da integração (push)

- `Constelacoes/Logistica/MissaoLogistica/Strategies/ErpAlocacaoWmsHookStrategy.php` e `WmsAlocacaoWmsHookStrategy.php`: o `opc`/operação e o payload passam a vir do `adapter->operacaoPush*()`/`payloadPush()`, incluindo `operacao_tipo`. Para `alocacao`, o payload é idêntico ao atual (`registrar_localizacao` / `sync_localizacao_wms`).
- `crons/wms_alocacao_integracao.php`: troca a entidade para `wms_movimentos` (via Sonda) e a descoberta de tenants (`SELECT DISTINCT block FROM wms_movimentos WHERE status='confirmada' AND (integracao_erp IN (...) OR integracao_wms IN (...))`). O gate por status permanece. Carrega o adapter por movimento para montar o payload.

## Padrões de extensibilidade
- **Registry de adapter** é o ponto de extensão: adicionar uma operação = novo `*Adapter` + chave no `OperacaoAdapterFactory` + (se precisar UI própria) um partial em `App/html/operacaowms/`. Sem tocar enum `TipoMissao`, rotas ou cron.
- Não introduzir Registry/Callbacks além do adapter — há um fluxo, não múltiplos consumidores.

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

1. **F0 — Docs** (esta entrega): `00-prd`, `01-spec`, `02-techplan`.
2. **F1 — Dados** ✅ FEITO: migration `004_generalizar_para_wms_movimentos.sql` (rename `wms_alocacoes`→`wms_movimentos` + índices + colunas `operacao_tipo`/`ocupacao_estado`); Sonda renomeada `WmsAlocacaoSonda`→`WmsMovimentoSonda` (`entity='wms_movimentos'`) com `registrarMovimento(...)` genérico e `registrarAlocacao()` delegando; ~10 referências de classe + cron + SQLs de teste migrados; enum `OperacaoTipo` + teste. ✅ aplicado em dev_gx_goldie; suíte verde: OperacaoTipo 18, GestaoMissoes 15, WmsMovimentoSonda 19, MapaLoc 2, ConfirmarAlocacao 19, ConsultarEndereco 14, MigrationSchema 41, AlocacaoFluxo 29 (157 asserts, 0 fail).
3. **F2 — Domínio + Adapter** ✅ FEITO: `OperacaoAdapterInterface`/`OperacaoAdapterBase`/`OperacaoAdapterFactory` (registry `Strategies/Operacao/`) + `AlocacaoAdapter` (paridade). `ConfirmarAlocacaoValidator` mantém o gate transversal (missão ativa + permissão) e delega a validação específica (produto/localização/quantidade) a `adapter->validarOperacao()`; `ConfirmarAlocacaoLaboratorio` persiste via `adapter->montarMovimento()` → `registrarMovimento` (carimba `operacao_tipo`). Contrato do Validator preservado (mesmos erros/retorno). ✅ suíte verde: AlocacaoAdapter 25, ConfirmarValidator 19, ConfirmarLaboratorio 19, AlocacaoFluxo 29 (+ regressão completa 10 suítes / 201 asserts, 0 fail; 0 resíduo).
4. **F3 — Criação** ✅ FEITO (backend + plumbing): `GestaoMissoesAlocacaoWms::getConfiguracoesAdicionais` persiste `operacao_tipo` (validado contra o registry `OperacaoAdapterFactory::operacoesSuportadas()`; default não-quebrante `alocacao`; `modo_alocacao` só p/ alocação). `forms/enviar_unificado.php` ganha hidden `operacao_tipo`; `designar_unificado.js` o popula (lê `#select-operacao-wms` se existir, senão `alocacao`). `AlocacaoWmsStrategy::getOperacoesSuportadas()` expõe value/label das operações com adapter. **Seletor visível multi-operação adiado p/ F6** (1 opção hoje não tem o que escolher; o JS/form já estão forward-compat — a operação acende sozinha ao registrar o adapter). ✅ GestaoMissoes 20/20; regressão 10 suítes/206 asserts, 0 fail.
5. **F4 — Execução** ✅ FEITO (dispatch por operação): `AlocacaoWmsHandler::AlocacaoWms_item` resolve `OperacaoTipo::fromConfig(configuracoes)` → `OperacaoAdapterFactory::create` → renderiza `$adapter->viewExecucao()` em todos os estados (início/concluído/em_andamento), com fallback p/ alocação se a operação não tiver adapter. `viewExecucao()` entrou no contrato (Interface/Base/AlocacaoAdapter→`html/alocacaowms/item`). A view em_andamento recebe `operacaoTipo` + `precisaEndereco/Produto/Quantidade` (a tela legada ignora; telas novas da F6 consomem). **Alocação: paridade total** (mesma view/saída). Restruturação em `App/html/operacaowms/` (casca+partials) adiada p/ F6, quando há >1 tela. ✅ AlocacaoAdapter 26/26; regressão 10 suítes/207 asserts, 0 fail.
6. **F5 — Integração (push)** ✅ FEITO (preparado; WMS externo não conectado): contrato de push no adapter — `operacaoPushErp/Wms()` (nome do `opc`) + `payloadPushErp/Wms(item, missaoId)` (default no Base = shape legado **+ `operacao_tipo`**). `AlocacaoAdapter` mantém `registrar_localizacao_wms`/`sync_localizacao_wms`. `Erp/WmsAlocacaoWmsHookStrategy::sincronizar` resolvem o adapter por movimento (defensivo: cai na alocação) e usam `opc`+payload dele; cron passa `operacao_tipo` nas filas ERP/WMS. **Alocação: payload idêntico + `operacao_tipo`.** Push HTTP não testável sem o WMS; payloads validados puros. ✅ AlocacaoAdapter 37/37; regressão 10 suítes/218 asserts, 0 fail.
7. **F6 — Novas operações** (uma por vez): cada operação = `*Adapter` + (registro no factory + tela + seletor).
   - **`inventario_produtos` — ✅ COMPLETO (usável ponta a ponta)**:
     - Backend: `InventarioProdutosAdapter` (produto + endereço + **quantidade contada obrigatória**, 0 válido; localização relaxada; push `registrar/sync_contagem_wms`) + `InventarioProdutosAdapterTddTest` 28/28; **registrado no factory**.
     - Tela: `App/html/operacaowms/inventario_produtos.php` + `.js` (fluxo produto→endereço→quantidade→confirmar→listar; mesmo transporte AJAX do `item.js`; tema âmbar; i18n via `show()` PT). Handler já despacha via `viewExecucao()`.
     - Designação: seletor de operação (`#select-operacao-wms`, data-driven via `operacoesSuportadas`) aparece quando há >1 operação; "apenas endereçar" some quando a operação não é alocação. `getConfiguracoesAdicionais` aceita `inventario_produtos`.
     - Verificado: regressão 11 suítes (GestaoMissoes 22, InventarioProdutos 28) + smoke E2E backend (rejeita sem qty; grava `operacao_tipo=inventario_produtos`, qty=7, confirmada) — 0 fail, 0 resíduo. Front não testável headless.
   - **`inventario_endereco` — ✅ COMPLETO (usável ponta a ponta)**: contagem compartilha `InventarioAdapterBase` com `inventario_produtos` (qty obrigatória; push de contagem). `InventarioEnderecoAdapter` (address-first, `precisaEndereco=true`) + `InventarioEnderecoAdapterTddTest` 19; registrado no factory. Tela `App/html/operacaowms/inventario_endereco.(php|js)`: bipa endereço → `consultarEndereco` lista produtos+saldo local → conta cada um (botão por linha) → confirma; descoberta de produto não listado via `consultarProduto`; tema teal. Verificado: regressão 12 suítes (GestaoMissoes 23, InventarioEndereco 19) + smoke E2E backend (operacao_tipo=inventario_endereco). Front pede smoke no browser.
   - **`movimentacao` — ✅ COMPLETO (usável ponta a ponta)**: transferência produto × endereço-origem → endereço-destino (decisão A2: `localizacao_codigo`=destino; D1: sem gate de saldo). `MovimentacaoAdapter` (qty>0; push `registrar/sync_movimentacao_wms`; payload com `localizacao_codigo_origem/destino`) registrado no factory. Tela `App/html/operacaowms/movimentacao.(php|js)`: sessão livre endereço-primeiro — bipa origem → painel de contexto (produtos+saldo Genesis) + busca manual por nome → seleciona produto + qty → bipa destino → confirma; após confirmação mantém origem e re-consulta painel (padrão `_limparParaProxima`). Seletor de operação na designação usa cards visuais (Configurações, Seção 3). Verificado: `MovimentacaoAdapterTddTest` 40/40 + `MovimentacaoE2ETddTest` 36/36 + regressão GestaoMissoes 25/25, 0 fail. Front pede smoke no browser.
   - **`validacao_ocupacao` — reescopada**: não é bipagem, é VALOR DERIVADO (% = Σ volume produto × qtd ÷ capacidade cúbica do endereço). Depende de campo de **capacidade em `wms_localizacoes`** (não existe hoje; produto já tem `cub_altura/largura/profund`) + dados de inventário. Provável forma: relatório, não tela de operador. Adiada até definir a origem da capacidade do endereço.
   - Pendente: `conferencia_localizacao` (depende de exibir saldo local; melhor com read API do WMS).

8. **Extensão — busca manual + ocorrência "sem código de barras"** (spec/plano: `10-...-spec.md` / `11-...-plan.md`) — **✅ COMPLETO em dev (backend+front; smoke browser pendente)**:
   - Busca manual por descrição na execução da alocação (`BuscarProdutoLaboratorio` + ação `buscarProduto`; read-only; reusa `_stagearProduto` no front).
   - Ocorrência de código reusa o sinal `SugestoesCodigoBarras` (operador só sinaliza; sem digitar código), com a coluna `tipo` discriminando o motivo: `ausencia` (produto SEM código) e `codigo_errado` (produto COM código, mas errado). Migration `002` (`codigo_sugerido` NULLable + `tipo`), `MissaoCodigoSugestaoSonda::registrarOcorrencia($tipo)` (`registrarAusencia` delega), Validator bifurca a pré-condição de `codigo_barras` por tipo, `SinalizarSemCodigoLaboratorio` aceita `tipo`, ações `sinalizar/cancelarSemCodigo` (dispatcher encaminha `tipo`). Front mostra botão adaptado (sem código vs código errado).
   - Na conclusão da alocação (hook em `MissoesStatus` agora com `ALOCACAO_WMS`), gera **1** missão `AJUSTE_DADOS` **executável** (itens pendentes, `campos_solicitados=['codigo_barras']`), idempotente. Ramo legado `sugestao` (separação/carregamento) intacto.
   - Verificado: regressão do sinal + E2E `tests/integration/AlocacaoWms/OcorrenciaSemCodigoFluxoTest.php` (52/52 PASS); 4 falhas pré-existentes alheias confirmadas (não-regressão). Front pede smoke no browser.

9. **Aprovação por movimento (gate do gestor)** (spec/plano: `14-aprovacao-movimento-wms-spec.md` / `14-...-plan.md`) — **✅ COMPLETO em dev (backend+front; smoke browser pendente)**:
   - Coluna nova `wms_movimentos.aprovacao_gestor` (migration `007`, default `aprovado` → zero regressão) + enum `AprovacaoGestor` (`aprovado`/`pendente`/`rejeitado`). Estados em `Enums/AprovacaoGestor.php`.
   - Operador que grava em missão com `requer_aprovacao` carimba `pendente` (`ConfirmarAlocacaoLaboratorio` lê `$missao->requer_aprovacao`); o gate de integração (`buscarPendentesIntegracao{Erp,Wms}` + descoberta de tenants no cron) ganha `AND aprovacao_gestor='aprovado'`, então o retido nunca sobe. `status` (`confirmada`/`cancelada`) e o saldo local do operador inalterados.
   - Gestor revisa na lista de designações: modal `Designacoes/forms/revisar_movimentos_wms.php` (1 switch/movimento, default ON, "Confirmar/Descartar todos", contador) consumindo `RevisarMovimentosLaboratorio` (`listar`/`alternar` via `labs/apis`, persiste cada toggle como `pendente⇄rejeitado`). "Enviar" chama `MissoesAprovacao::aprovarOperacaoWms` → `WmsMovimentoSonda::commitAprovacao` (`pendente→aprovado`, `rejeitado→cancelada`) e conclui a missão pela via canônica (dispara o hook AJUSTE_DADOS já existente).
   - Ativação ponta a ponta: `GestaoMissoesAlocacaoWms::processarEnvio` parou de forçar `requer_aprovacao='0'`; o checkbox do painel (default marcado) flui até a persistência.
   - Verificado: suíte WMS 10 testes / 141 asserts, 0 fail (`AprovacaoGestor` 5, `WmsMovimentoSonda` 29, `...Aprovacao` 6, `...Revisao` 8, `ConfirmarAlocacao` 19, `ConfirmarAlocacaoAprovacao` 2, `AprovarOperacaoWms` 3, `RevisarMovimentos` 5, `AlocacaoAdapter` 37, `GestaoMissoesAlocacaoWms` 27); 0 resíduo no banco. Front pede smoke no browser.
   - **Hardening pós code-review (high effort, 36 agentes):** (i) `forcarConclusao` bloqueia missão WMS com pendentes (senão órfãos fora da integração — decisão: bloquear, `ForcarConclusaoWmsGuardTddTest`); (ii) modal escapa conteúdo do banco no `innerHTML` (XSS) e desabilita "Enviar" até a lista carregar (fail-open); (iii) "Confirmar/Descartar todos" vira 1 request batch (`alternarAprovacaoTodos`); toggle ressincroniza com o servidor na falha (sem desync UI/DB); (iv) conclusão por CAS atômico (anti double-submit) + `finalizarMissao` best-effort em try/catch reaproveitando `$missao` (sem re-SELECT); (v) cron sai limpo se a coluna `aprovacao_gestor` não existir (janela de deploy). Suíte total: 12 testes / 152 asserts, 0 fail.
   - **Race [3] eliminada (snapshot scoping):** o "Enviar" manda `snapshot_max_id` (maior id que o gestor carregou — placeholder `{snapshot_max_id}` substituído no `data-acao-dados` a cada render). Como os ids de `wms_movimentos` são sequenciais, `WmsMovimentoSonda::contarPendentesNovos(missaoId, snapshotMaxId)` detecta qualquer pendente confirmado depois do load; se houver, `aprovarOperacaoWms` bloqueia o envio e recarrega a lista (`chamarFuncao('rmwRecarregar')`) — nada é aprovado sem o gestor ter visto. Decisão: **bloquear e recarregar** (consistente com o [0]). Testes `SnapshotScopingTddTest` 7. `snapshot_max_id` ausente (-1) desabilita o guard (retrocompat). Total da suíte: 13 testes / 159 asserts, 0 fail.

## Não-regressão (invariantes)
- `tipo_missao_id=14` e `referencia_tipo='ALOCACAO_WMS'` **não mudam**; nenhuma missão existente é migrada.
- O fluxo de alocação (modos livre/enderecamento, endereço-primeiro, remover produto, X limpar, saldo local, conclusão explícita) é preservado byte-a-byte de comportamento — encapsulado no `AlocacaoAdapter`.
- Payload de push da alocação é idêntico (`registrar_localizacao`/`sync_localizacao_wms`).

## Plano de testes
- **Baseline (deve continuar passando)**: `Designacoes/Testes/Laboratorios/GestaoMissoesAlocacaoWmsTddTest.php` (15/15); teste saldo-0 da alocação (3/3) em `dev_gx_goldie` block 22, limpando resíduo.
- **Novos TDD**:
  - `OperacaoTipo` enum (`fromConfig` default, `exigeQuantidade/Endereco`).
  - `OperacaoAdapterFactory` (resolve cada `operacao_tipo`; fallback `alocacao`).
  - Cada `*Adapter`: `validar()` (regras específicas) e `montarMovimento()` (campos corretos).
  - Sonda `WmsMovimentoSonda` contra `wms_movimentos` (grava `operacao_tipo`, filtra por operação, gate de status).
  - Migration: aplicar em `dev_gx_goldie`, conferir rename + coluna + backfill='alocacao' + índices.
- **Integração (dev_gx_goldie, block 22)**: criar uma missão de cada `operacao_tipo`, executar, conferir registro em `wms_movimentos` com o `operacao_tipo` correto, limpar resíduo (endereços `ZZ-*TEST`).
- **Browser**: Designação → escolher "Operação WMS" → operação → escopo → executar no app; conferir UI adaptada.

## Alternativas consideradas (não adotadas)
- **Case novo `OPERACAO_WMS` (id=15) + migração** das missões de alocação: mais "limpo" no nome, mas exige migração de dados e varredura de strings `referencia_tipo` — risco desnecessário. Rejeitada em favor do relabel + `operacao_tipo`.
- **Tabela por operação** (`wms_contagens`, …): duplicaria saldo/integração/cron. Rejeitada em favor de `wms_movimentos` + `operacao_tipo`.
- **`operacao_tipo` só em `dados_adicionais`** (sem coluna): perde índice/consulta por operação no histórico. Rejeitada — coluna dedicada.
