# Movimentação WMS — Plano técnico

> **Feature:** nova `operacao_tipo = movimentacao` no sinal AlocacaoWms (Missão de Operação WMS genérica).
> **Spec:** `PRD/operacao-wms/12-movimentacao-spec.md` · **PRD/decisões:** `PRD/operacao-wms/12-movimentacao-prd.md` · **Arquitetura do módulo:** `PRD/operacao-wms/02-techplan.md` (F0–F6).
> **Decisões fechadas (não reabrir):** A2 (colunas origem/destino) · B1 (Origem→Produto→Quantidade→Destino) · C1 (sessão livre) · D1 (sem validação de saldo).

---

## 1. Resumo de decisão arquitetural

A movimentação **não é um sinal novo nem um tipo de missão novo**. É a 4ª operação concreta da Missão de Operação WMS, plugada pelo **Registry de adapter** já em produção (`OperacaoAdapterFactory`). O padrão V2 dominante do módulo (`OperacaoAdapterInterface` + `OperacaoAdapterBase` + adapters por `operacao_tipo`) foi desenhado exatamente para isso: adicionar uma operação = novo `*Adapter` + chave no factory + tela + i18n, **sem tocar** enum `TipoMissao`, rotas, cron ou os Laboratórios/Validators genéricos.

A particularidade da movimentação — **duas localizações por movimento** (origem e destino) — é o único ponto que exige tocar o esqueleto compartilhado: a Sonda `WmsMovimentoSonda` e a migration ganham `localizacao_codigo_origem`/`localizacao_codigo_destino` (decisão A2). Tudo o mais é encapsulado dentro de `MovimentacaoAdapter` (validação origem≠destino + quantidade>0; `montarMovimento` que carimba as duas localizações; `payloadPush*` que carrega origem+destino) e da nova tela `html/operacaowms/movimentacao.(php|js)`.

**Por que adapter e não outro padrão:** a spec descreve **um** fluxo (não múltiplos consumidores), então não há Registry/Callbacks adicional — o adapter é o seam suficiente (princípio "não introduzir padrão prematuro"; Edicaorota usa Callbacks/Registry porque tem N módulos consumindo, o que **não** é o caso aqui). O `OperacaoAdapterFactory` já é o registry; só registramos mais uma chave.

**Não-quebrante:** `tipo_missao_id=14`/`referencia_tipo='ALOCACAO_WMS'` não mudam. As colunas novas são nullable → `alocacao` e `inventario_*` continuam com `localizacao_codigo` significando o que sempre significou. Push permanece push-only (sem read de saldo; decisão D1).

---

## 2. Sinal de referência

- **Imitar (operação address-first com 1 localização):** `componentes/Missoes/Logistica/sinais/AlocacaoWms/Strategies/Operacao/InventarioEnderecoAdapter.php` — é o adapter mais recente, identidade + view fina, validação herdada de uma base. A movimentação é parecida mas tem validação própria (origem≠destino) → estende `OperacaoAdapterBase` direto (não há base de "duas localizações" hoje; é a primeira).
- **Imitar (tela multi-step, single-record append-only):** `componentes/Missoes/Logistica/sinais/App/html/operacaowms/inventario_produtos.php` + `inventario_produtos.js` — fluxo `campo→campo→quantidade→confirmar→lista`, transporte AJAX para `/missao/Logistica/AlocacaoWms` com `sinalGxData`. A movimentação reusa esse esqueleto adicionando o 2º endereço (destino).
- **Imitar (push com payload próprio):** `componentes/Missoes/Logistica/sinais/AlocacaoWms/Strategies/Operacao/InventarioAdapterBase.php::operacaoPushErp/Wms` + os overrides em `OperacaoAdapterBase::payloadPush*`.
- **Imitar (teste de adapter):** `componentes/Missoes/Logistica/sinais/AlocacaoWms/Testes/Strategies/Operacao/InventarioEnderecoAdapterTddTest.php` (CLI, dev_gx_goldie, `check()`).
- **NÃO imitar:** V1 legado (`Inteligencias/`, `sinais/Pessoas`); usar Sondas (`feedback_sondas_v2`). Não copiar a base de inventário (qty 0 válida) — movimentação exige qty **> 0**.

---

## 3. Estrutura proposta

### Novos arquivos
```
componentes/Missoes/Logistica/sinais/AlocacaoWms/
├── Migrations/
│   └── 005_add_movimentacao_fields.sql                    # NEW (decisão A2)
├── Strategies/Operacao/
│   └── MovimentacaoAdapter.php                             # NEW (estende OperacaoAdapterBase)
└── Testes/Strategies/Operacao/
    └── MovimentacaoAdapterTddTest.php                      # NEW

componentes/Missoes/Logistica/sinais/App/html/operacaowms/
├── movimentacao.php                                        # NEW (tela operador, fluxo B1)
└── movimentacao.js                                         # NEW (JS pareado)
```

### Arquivos alterados
- `componentes/Missoes/Logistica/sinais/AlocacaoWms/Enums/OperacaoTipo.php` — novo `case MOVIMENTACAO`, ajustes em `label()`/`exigeQuantidade()` (predicados).
- `componentes/Missoes/Logistica/sinais/AlocacaoWms/Strategies/Operacao/OperacaoAdapterFactory.php` — registrar `'movimentacao' => MovimentacaoAdapter::class`.
- `componentes/Missoes/Logistica/sinais/AlocacaoWms/Sondas/WmsMovimentoSonda.php` — `registrarMovimento()` aceita `localizacaoOrigem`/`localizacaoDestino` (default null) e os grava.
- `componentes/Missoes/Logistica/sinais/AlocacaoWms/Laboratorios/ConfirmarAlocacaoLaboratorio.php` — repassar os campos de origem/destino do `montarMovimento` para `registrarMovimento`.
- `componentes/Missoes/Logistica/sinais/AlocacaoWms/AlocacaoWms.php` (dispatcher) — `confirmar()` aceita `localizacao_codigo_origem`/`localizacao_codigo_destino`.
- `crons/wms_alocacao_integracao.php` — o `array_map` que monta o `$item` (ERP e WMS) carrega `localizacao_codigo_origem`/`_destino` da linha.
- `linguagens/{pt-br,en,es}.json` — chaves novas via `show()` (harvest automático; ver §7/§10).

> **Designação (criação da missão):** **sem novos arquivos.** O seletor de operação data-driven (`#select-operacao-wms`) já existe (F6) e acende automaticamente quando o adapter é registrado em `OperacaoAdapterFactory::operacoesSuportadas()` — `GestaoMissoesAlocacaoWms::getConfiguracoesAdicionais` já valida `operacao_tipo` contra o registry e persiste `configuracoes.operacao_tipo = "movimentacao"` (AC1.4). Sessão livre = `permiteSelecaoVazia()` true para esta operação (AC1.2). Confirmar no smoke; não é esperado código novo de criação.

---

## 4. Migration — `005_add_movimentacao_fields.sql`

Decisão **A2** do PRD. Postgres, idempotente (`IF NOT EXISTS`), aditiva e não-quebrante (colunas nullable; `alocacao`/`inventario_*` ficam com NULL nelas e seguem usando `localizacao_codigo`).

`componentes/Missoes/Logistica/sinais/AlocacaoWms/Migrations/005_add_movimentacao_fields.sql`:

```sql
-- =====================================================
-- Migration 005: campos de origem/destino p/ operacao_tipo=movimentacao
-- Feature: operacao-wms / movimentacao (decisão A2 do 12-movimentacao-prd.md)
-- Data: 2026-06-24
-- =====================================================
--
-- A movimentação transfere N un. de um produto DE um endereço (origem) PARA outro
-- (destino). É a única operação WMS com DUAS localizações por movimento. As demais
-- (alocacao, inventario_*) seguem usando localizacao_codigo (= destino/local único)
-- e deixam estas colunas NULL.
--
-- Aditiva e idempotente (IF NOT EXISTS): rodar 2x é seguro. Postgres.
-- Block columns sem DEFAULT (feedback_block_columns); customerControl=true.
-- =====================================================

ALTER TABLE wms_movimentos
    ADD COLUMN IF NOT EXISTS localizacao_codigo_origem  VARCHAR(64);
        -- só movimentacao: endereço DE onde saiu (NULL nas demais operações)
ALTER TABLE wms_movimentos
    ADD COLUMN IF NOT EXISTS localizacao_codigo_destino VARCHAR(64);
        -- só movimentacao: endereço PARA onde foi (NULL nas demais operações)

-- Índices para consulta/rastreio por endereço de origem e destino, escopados por
-- tenant (block). Parciais (WHERE NOT NULL) — só indexam linhas de movimentacao.
CREATE INDEX IF NOT EXISTS idx_wms_movimentos_loc_origem
    ON wms_movimentos (localizacao_codigo_origem, block)
    WHERE localizacao_codigo_origem IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_wms_movimentos_loc_destino
    ON wms_movimentos (localizacao_codigo_destino, block)
    WHERE localizacao_codigo_destino IS NOT NULL;
```

**Semântica de `localizacao_codigo` na movimentação:** segue como **destino** (espelha o PRD A2: "a coluna atual passa a ser o destino, backward-compat"). Assim, `listarPorLocalizacao(destino)`/`saldoPorLocalizacaoProduto` continuam funcionando para "o que chegou neste endereço" sem caso especial. A origem fica **exclusivamente** em `localizacao_codigo_origem` (não há row negativo; decisão A2 ≠ A1). **Não validamos saldo de origem** (D1) → não derivamos saldo de origem aqui.

**Deploy:** aditiva pura — pode rodar em `gx_goldie` (prod) antes do código sem quebrar nada (colunas ficam NULL até existir movimento de movimentação). Aplicar em `dev_gx_goldie` primeiro e rodar `tests/integration/AlocacaoWms/MigrationSchemaTest.php` (estender a asserção de schema — ver §9).

---

## 5. Enum — `OperacaoTipo.php`

Arquivo: `componentes/Missoes/Logistica/sinais/AlocacaoWms/Enums/OperacaoTipo.php`.

Mudanças:
1. **Novo case** (após os existentes):
   ```php
   case MOVIMENTACAO = 'movimentacao';
   ```
2. **`label()`** — adicionar braço:
   ```php
   self::MOVIMENTACAO => 'Movimentação',
   ```
3. **`exigeQuantidade()`** — movimentação **exige** quantidade (> 0, validado no adapter). Incluir no `in_array`/match com os inventários:
   ```php
   self::INVENTARIO_ENDERECO, self::INVENTARIO_PRODUTOS, self::MOVIMENTACAO => true,
   ```
4. **`exigeEndereco()`** — `true` (movimentação opera sobre endereço bipado). O default atual (`!== INVENTARIO_PRODUTOS`) já retorna `true` para MOVIMENTACAO → **sem mudança**.
5. **`exigeProduto()`** — `true`. Default (`!== VALIDACAO_OCUPACAO`) já retorna `true` → **sem mudança**.

> Os predicados `precisaEndereco/Produto/Quantidade` do adapter derivam do enum via `OperacaoAdapterBase`. **Atenção semântica:** `exigeEndereco()` aqui significa "bipa pelo menos um endereço" — a movimentação tem **dois**. Os dois endereços são contrato **só da movimentação** (validados/montados no `MovimentacaoAdapter` e renderizados pela tela própria), não predicados genéricos do enum. Não criar `exigeOrigemDestino()` no enum (vazaria conceito específico para o contrato geral); a flag de "duas localizações" vive na tela `movimentacao.js` (que sempre exibe origem+destino) e na validação do adapter.

`OperacaoTipoTddTest.php` (já existe, `Testes/Enums/`) ganha asserções para `MOVIMENTACAO` (`fromConfig`, `label`, `exigeQuantidade=true`). Ver §9.

---

## 6. `MovimentacaoAdapter.php` — método a método

Arquivo: `componentes/Missoes/Logistica/sinais/AlocacaoWms/Strategies/Operacao/MovimentacaoAdapter.php`.
Estende `OperacaoAdapterBase` (não `InventarioAdapterBase` — a regra de quantidade é diferente: movimentação exige **> 0**, inventário aceita 0).

| Método | Origem | O que faz |
|---|---|---|
| `getOperacaoTipo()` | **implementa** | retorna `OperacaoTipo::MOVIMENTACAO`. |
| `viewExecucao()` | **implementa** | retorna `'html/operacaowms/movimentacao'`. |
| `operacaoPushErp()` | **implementa** | retorna `'registrar_movimentacao_wms'` (novo `opc`; o ERP/WMS dispatcha por ele — semântica de transferência, ver §8). |
| `operacaoPushWms()` | **implementa** | retorna `'sync_movimentacao_wms'`. |
| `validarOperacao(array $dados, object $missao)` | **implementa** | validação específica (abaixo). |
| `montarMovimento(array $contexto)` | **sobrescreve** | pass-through do Base **+** injeta `localizacao_codigo_origem`/`_destino` (abaixo). |
| `payloadPushErp(array $item, int $missaoId)` | **sobrescreve** | payload do Base **+** `localizacao_codigo_origem`/`_destino` (§8). |
| `payloadPushWms(array $item, int $missaoId)` | **sobrescreve** | idem para a base WMS (§8). |
| `precisaEndereco()` / `precisaProduto()` / `precisaQuantidade()` | **herda do Base** | derivam do enum (`true`/`true`/`true`). |

### 6.1 `validarOperacao()` — regras (AC4.1–4.4, RN3–6)

Recebe `$dados` (payload bruto da confirmação) com `produto_id`, `localizacao_codigo_origem`, `localizacao_codigo_destino`, `quantidade`. Ordem de checagem (mensagens específicas, AC4.7 = nada gravado em falha):

1. **produto** — `produto_id` ausente → `'ID do produto é obrigatório.'`. Resolve `Produto::findById`; não encontrado → `'Produto não encontrado no catálogo.'` (AC4.4).
2. **origem** — `localizacao_codigo_origem` vazio → `'Endereço de origem é obrigatório.'` (AC4.3).
3. **destino** — `localizacao_codigo_destino` vazio → `'Endereço de destino é obrigatório.'` (AC4.3).
4. **origem ≠ destino** — iguais (após `trim`) → `'Origem e destino devem ser endereços diferentes.'` (AC4.2 / RN4).
5. **quantidade > 0** — `null`/`''`/`<= 0` → `'Quantidade é obrigatória e deve ser positiva.'` (AC4.1 / RN3). **Diferente do inventário** (lá 0 é válido).
6. **localização relaxada** (paridade com alocação): resolve origem e destino via `WmsLocalizacaoSonda::buscarPorCodigo`; código não cadastrado **passa** com `wms_localizacao_id=null` (a movimentação grava só os códigos; D1 = não bloqueia por endereço inexistente).

Retorno em sucesso (chaves consumidas pelo Validator/Laboratorio):
```php
return [
    'ok'                 => true,
    'produto'            => $produto,
    'localizacao'        => $localizacaoDestino,   // FK do destino (compat c/ contrato genérico)
    'localizacao_origem' => $localizacaoOrigem,    // pode ser null
    'quantidade'         => (float)$quantidade,
    // origem/destino códigos seguem nos $dados; Laboratorio os repassa ao montarMovimento
];
```
> O Validator genérico (`ConfirmarAlocacaoValidator`) já faz o gate transversal (missão ativa AC4.5, permissão AC4.6) **antes** de chamar `validarOperacao()`. **Nenhuma mudança no Validator** (§7 detalha por quê).

### 6.2 `montarMovimento()` — duas localizações

Parte do `parent::montarMovimento($contexto)` (carimba `operacao = MOVIMENTACAO`, produto, quantidade, operador, origem_leitura) e:
- seta `localizacao_codigo` = **destino** (mantém a semântica do §4);
- seta `wms_localizacao_id` = FK do destino (se resolvida);
- adiciona ao array de retorno `localizacao_codigo_origem` e `localizacao_codigo_destino` (vindos do `$contexto`).

Retorno (campos extras além do contrato Base):
```php
$mov = parent::montarMovimento($contexto);
$mov['localizacao_codigo']         = (string)($contexto['localizacao_codigo_destino'] ?? '');
$mov['wms_localizacao_id']         = $contexto['wms_localizacao_id_destino'] ?? null;
$mov['localizacao_codigo_origem']  = (string)($contexto['localizacao_codigo_origem'] ?? '');
$mov['localizacao_codigo_destino'] = (string)($contexto['localizacao_codigo_destino'] ?? '');
return $mov;
```

---

## 7. `WmsMovimentoSonda`, `ConfirmarAlocacaoLaboratorio`, `ConfirmarAlocacaoValidator`

### 7.1 `WmsMovimentoSonda` — **mudança mínima** (2 parâmetros novos)

`registrarMovimento()` hoje grava um conjunto fixo de colunas e **não** inclui origem/destino. Adicionar dois parâmetros opcionais (default `null`, append-only no fim da assinatura → não quebra `registrarAlocacao()` nem os inventários):

```php
public function registrarMovimento(
    int $missaoId,
    ?int $missaoItemId,
    ?int $produtoId,
    ?string $localizacaoCodigo,
    ?int $wmsLocalizacaoId,
    ?float $quantidade,
    string $origemLeitura,
    int $operadorId,
    OperacaoTipo $operacao = OperacaoTipo::ALOCACAO,
    ?string $ocupacaoEstado = null,
    ?string $localizacaoOrigem = null,   // NEW
    ?string $localizacaoDestino = null   // NEW
): ?object
```
No `$this->data` adicionar:
```php
'localizacao_codigo_origem'  => $localizacaoOrigem,
'localizacao_codigo_destino' => $localizacaoDestino,
```
Demais operações chamam sem esses args → ficam NULL (não-regressão). Sem `class_alias`, sem novo método.

> **Consultas:** `listarPorMissao`, `listarPorLocalizacao`, `saldoPorLocalizacaoProduto` etc. **não mudam** — operam por `localizacao_codigo` (= destino na movimentação) e seguem corretas. Não há feature de "saldo por origem" nesta fase (D1).

### 7.2 `ConfirmarAlocacaoLaboratorio` — **repassar 2 campos**

O Laboratorio é genérico: chama `adapter->montarMovimento([...])` e depois `registrarMovimento(...)` posicional. Mudanças cirúrgicas:
1. No array passado a `montarMovimento`, incluir os dois códigos vindos de `$dados` (que o dispatcher já terá colocado):
   ```php
   'localizacao_codigo_origem'  => trim($dados['localizacao_codigo_origem'] ?? ''),
   'localizacao_codigo_destino' => trim($dados['localizacao_codigo_destino'] ?? ''),
   'wms_localizacao_id_destino' => $locId, // FK do destino, quando resolvida
   ```
2. Na chamada `registrarMovimento(...)`, passar os dois últimos args lidos do `$mov`:
   ```php
   $mov['operacao'],
   $mov['ocupacao_estado'],
   $mov['localizacao_codigo_origem'] ?? null,   // NEW
   $mov['localizacao_codigo_destino'] ?? null   // NEW
   ```
3. **`localizacao_codigo` para alocação/inventário continua vindo de `$dados['localizacao_codigo']`** — não mexer. Para movimentação, o adapter já reescreveu `localizacao_codigo` = destino dentro de `montarMovimento` (§6.2). O Laboratorio é agnóstico: usa `$mov['localizacao_codigo']`.

> O `findOrCreateItem` (item dinâmico por produto) e o extrato funcionam sem mudança — a movimentação é append-only por confirmação, igual à alocação (resolve "repetidos na sessão = append", ver §11). O `RegistraExtratoTrait` pode opcionalmente ganhar `localizacao_origem`/`destino` no payload de auditoria (nice-to-have; não bloqueante).

### 7.3 `ConfirmarAlocacaoValidator` — **sem mudança**

O gate transversal (missão ativa AC4.5, permissão AC4.6) é operação-agnóstico e já delega o específico ao `adapter->validarOperacao()`. O contrato de retorno (`produto`/`localizacao`/`quantidade`/`adapter`) é satisfeito pelo `MovimentacaoAdapter` (§6.1). **Nenhuma edição** — esse é o ponto do design por adapter.

### 7.4 Dispatcher `AlocacaoWms.php::confirmar()` — **aceitar origem/destino**

Hoje `confirmar()` monta `$dados` com `localizacao_codigo` único. A movimentação precisa dos dois códigos. Mudança aditiva (não afeta alocação/inventário, que não enviam esses campos):
```php
$dados = [
    'missao_id'                  => $data['missao_id'] ?? null,
    'operador_id'                => $operadorId,
    'produto_id'                 => $produtoId,
    'localizacao_codigo'         => $data['localizacao_codigo'] ?? '',          // alocacao/inventario
    'localizacao_codigo_origem'  => $data['localizacao_codigo_origem']  ?? '',  // NEW (movimentacao)
    'localizacao_codigo_destino' => $data['localizacao_codigo_destino'] ?? '',  // NEW (movimentacao)
    'origem_leitura'             => $data['origem_leitura'] ?? 'codigo_barras',
    'quantidade'                 => isset($data['quantidade']) && $data['quantidade'] !== '' ? $data['quantidade'] : null,
];
```
A resolução de `produto_id` (`resolverProdutoId` / `resolverRegistroPorCodigo`) e a ação `consultarProduto` são reusadas tal como estão (a movimentação bipa produto igual ao inventário). **Nenhuma ação nova no dispatcher** — `confirmar`/`consultarProduto`/`listar` cobrem o fluxo.

---

## 8. Push — origem e destino no payload

Pipeline (inalterado): operador confirma → `registrarMovimento` grava com `integracao_erp/_wms = pendente` → `crons/wms_alocacao_integracao.php` varre `wms_movimentos` pendentes por tenant → para cada linha monta `$item` → `Erp/WmsAlocacaoWmsHookStrategy::sincronizar` resolve o adapter por `operacao_tipo` e chama `adapter->payloadPush*($item, $missaoId)` → POST.

Duas mudanças:

### 8.1 Cron — carregar origem/destino no `$item`
No `array_map` da fila ERP **e** da fila WMS (`crons/wms_alocacao_integracao.php`), adicionar:
```php
'localizacao_codigo_origem'  => $aloc->localizacao_codigo_origem  ?? null,
'localizacao_codigo_destino' => $aloc->localizacao_codigo_destino ?? null,
```
(São NULL para alocação/inventário — inofensivo; os adapters dessas operações nem leem.)

### 8.2 `MovimentacaoAdapter::payloadPushErp/Wms` — incluir os dois endereços (AC5.2)
Override que parte do payload do Base e adiciona origem/destino explícitos:
```php
public function payloadPushErp(array $item, int $missaoId): array
{
    $p = parent::payloadPushErp($item, $missaoId); // opc, operacao_tipo, importacao_id, quantidade, ...
    $p['localizacao_codigo_origem']  = $item['localizacao_codigo_origem']  ?? null;
    $p['localizacao_codigo_destino'] = $item['localizacao_codigo_destino'] ?? null;
    // localizacao_codigo (legado) = destino — já vem do Base via $item['localizacao_codigo']
    return $p;
}
```
(idêntico para `payloadPushWms`).

### 8.3 Decisões de contrato (resolve dúvidas em aberto da spec)

- **Semântica do push (dúvida 1):** **CONFIRMADO — um único evento com origem+destino**, que o consumidor (ERP/WMS externo) aplica como **transferência atômica** (debita `localizacao_codigo_origem`, credita `localizacao_codigo_destino` da `quantidade`). `opc = registrar_movimentacao_wms` / `sync_movimentacao_wms`; `quantidade` é **positiva** e representa a quantia transferida (não delta com sinal). Não emitimos dois eventos (≠ opção A1 do PRD). O consumidor distingue movimentação de alocação pelo `operacao_tipo` e pela presença de `localizacao_codigo_origem`.
- **Feedback de falha de push (dúvida 3):** **CONFIRMADO — sem notificação ao operador nesta fase.** A confirmação responde "ok" assim que `wms_movimentos` é gravado (rápido; não bloqueia, decisão #3 do módulo). Falha de integração (incl. recusa por saldo insuficiente, AC5.6) fica em `integracao_erp/_wms = falha` + `integracao_erro`, visível ao gestor/TI no rastreio de integração, e volta à fila de retry do cron. Sem tela/push ao operador (escopo futuro). UX da tela só sinaliza sucesso/erro **da gravação local**.

---

## 9. Testes

Padrão CLI + `dev_gx_goldie` + `check()` (espelha `InventarioEnderecoAdapterTddTest.php`). Limpar resíduo (`wms_localizacoes`/`wms_movimentos` de teste) em `finally`.

### Novos
- **`Testes/Strategies/Operacao/MovimentacaoAdapterTddTest.php`** (NEW):
  - Identidade: `getOperacaoTipo() === MOVIMENTACAO`; `viewExecucao() === 'html/operacaowms/movimentacao'`; `precisaEndereco/Produto/Quantidade` todos `true`.
  - Factory: `createFromConfig(['operacao_tipo'=>'movimentacao'])` instancia `MovimentacaoAdapter`; `operacoesSuportadas()` inclui `movimentacao`.
  - Push: `operacaoPushErp() === 'registrar_movimentacao_wms'`; `operacaoPushWms() === 'sync_movimentacao_wms'`.
  - `validarOperacao` rejeições: sem produto / sem origem / sem destino / origem==destino / qty=0 / qty<0 / qty null / produto fora do catálogo — cada uma com a mensagem certa.
  - `validarOperacao` aceitações: produto + origem≠destino + qty>0 → `ok`, `quantidade` float correta.
  - `montarMovimento`: carimba `operacao=MOVIMENTACAO`, `localizacao_codigo`=destino, e popula `localizacao_codigo_origem`/`_destino`.
  - `payloadPushErp/Wms`: carregam `operacao_tipo=movimentacao` + `localizacao_codigo_origem`/`_destino` + `opc` correto.

### Estendidos
- **`Testes/Enums/OperacaoTipoTddTest.php`**: `fromConfig('movimentacao')`, `label()`, `exigeQuantidade()===true`, `exigeEndereco/Produto===true`.
- **`Testes/Sondas/WmsMovimentoSondaTddTest.php`**: `registrarMovimento(..., OperacaoTipo::MOVIMENTACAO, null, 'ORIG-01', 'DEST-02')` grava as duas colunas; e que chamar sem elas (alocação) as deixa NULL.
- **`tests/integration/AlocacaoWms/MigrationSchemaTest.php`**: asserir que `wms_movimentos` tem `localizacao_codigo_origem`/`_destino` e os dois índices parciais após migration 005.
- **Regressão (deve continuar verde):** `AlocacaoAdapterTddTest`, `InventarioProdutos/EnderecoAdapterTddTest`, `ConfirmarAlocacaoValidatorTddTest`, `ConfirmarAlocacaoLaboratorioTddTest`, `tests/integration/AlocacaoWms/AlocacaoFluxoTest.php` — provam que as 2 colunas/2 params novos não quebraram nada.

### Integração E2E (backend, dev_gx_goldie, block de teste)
Criar missão `operacao_tipo=movimentacao` → `confirmar` com origem≠destino, qty>0 → conferir 1 linha `wms_movimentos` com `operacao_tipo='movimentacao'`, `localizacao_codigo`=destino, `localizacao_codigo_origem`=origem, `integracao_*='pendente'` → rejeitar `confirmar` com origem==destino e com qty=0 (nada gravado) → limpar resíduo.

### Manual (smoke do usuário)
- **Browser — designação:** criar Operação WMS, selecionar "Movimentação" no `#select-operacao-wms`, salvar sem itens (sessão livre), confirmar `configuracoes.operacao_tipo='movimentacao'`.
- **Browser — app:** abrir a missão, fluxo Origem→Produto→Quantidade→Destino, confirmar, ver o movimento na lista, repetir (append), finalizar explicitamente.

---

## 10. Padrões de extensibilidade

- **Registry de adapter (`OperacaoAdapterFactory`)** — único ponto de extensão; já existe. Movimentação = +1 chave. Sem novo Registry/Callbacks (1 fluxo, não múltiplos consumidores — não replicar o padrão de Edicaorota cegamente).
- **i18n:** strings da tela via `show('texto PT')` (chave=md5; `galaxia_i18n_show_md5`). Bloco `#mov-i18n` JSON espelha o de `#inv-i18n`. Harvest automático; conferir no browser nas 3 línguas. Ver `genesis-views-i18n`.
- **Não introduzir:** base "duas localizações" abstrata (só há 1 operação assim); coluna de saldo de origem (D1); evento duplo debit/credit (A1 rejeitada).

---

## 11. Ordem de implementação (sequencial)

1. **Migration 005** → aplicar em `dev_gx_goldie`; estender e rodar `MigrationSchemaTest.php`.
2. **Enum** `OperacaoTipo` (case + predicados) → estender e rodar `OperacaoTipoTddTest`.
3. **Sonda** `WmsMovimentoSonda` (2 params) → estender e rodar `WmsMovimentoSondaTddTest` (grava origem/destino; NULL na alocação).
4. **`MovimentacaoAdapter`** (validar/montar/push) + **registrar no factory** → `MovimentacaoAdapterTddTest`.
5. **`ConfirmarAlocacaoLaboratorio`** (repassar origem/destino) + **dispatcher `confirmar()`** (aceitar os campos) → regressão `ConfirmarAlocacaoLaboratorioTddTest` + E2E backend.
6. **Push:** cron `$item` + overrides `payloadPush*` (passo 4 já cobre os overrides; aqui só o cron) → asserts puros de payload no adapter test.
7. **View** `html/operacaowms/movimentacao.php` + `movimentacao.js` + i18n (`show()`) → smoke no browser.
8. **Designação:** smoke (esperado zero código; só confirmar o seletor data-driven e a persistência de `operacao_tipo`).

> Cada passo mantém a alocação e os inventários **verdes** (mudanças aditivas). Backend-first: 1–6 testáveis headless; 7–8 exigem browser.

---

## 12. View — `html/operacaowms/movimentacao.php` + `movimentacao.js`

Espelha `inventario_produtos.(php|js)` (tema próprio; transporte AJAX para `/missao/Logistica/AlocacaoWms` com `sinalGxData`). Estados `iniciar | em_andamento | concluido | erro` idênticos (vêm do Handler via `viewExecucao()`; **nenhuma mudança no Handler** — ele já injeta `operacaoTipo`/`precisa*` e despacha pela `viewExecucao()` do adapter).

### Campos (fluxo B1: Origem → Produto → Quantidade → Destino)
1. **Endereço de origem** (`#campo-mov-origem`) — scan/manual; primeiro gesto.
2. **Produto** (`#campo-mov-produto`) — scan/manual; resolvido via `consultarProduto` (reusa `codigosBarrasMap` + cache local), igual ao inventário.
3. **Quantidade** (`#mov-quantidade`) — numérico, **> 0** (min/step; 0 e vazio desabilitam confirmar — ≠ inventário onde 0 vale).
4. **Endereço de destino** (`#campo-mov-destino`) — scan/manual; **último** gesto antes de confirmar (AC2.2).

### Estados de UI / fluxo de bip (`movimentacao.js`)
- Estado local: `origemCodigo`, `produtoId/Codigo/Nome`, `quantidade`, `destinoCodigo`, `campoAtivo`, `enviando`.
- **Heurística de bip por etapa** (espelha `_processarCodigo` do inventário, com 4 campos): origem vazia → bip vai para origem; origem ok & produto vazio → produto (resolve via `consultarProduto`); produto ok & qty preenchida & destino vazio → destino; override por toque (`campoAtivo`) sempre vence.
- **`consultarProduto`** rejeita produto fora do catálogo → toast "produto não encontrado" + marca erro no campo, **não avança** (AC4.4).
- **Botão Confirmar** habilita só quando origem & produto & qty>0 & destino preenchidos **e** origem≠destino (AC2.3); guard no clique repete a checagem com mensagem específica por campo faltante / origem==destino (espelho server-side em `validarOperacao`).
- **`confirmar`** posta `{ sinalGxData:'confirmar', missao_id, produto_id|codigo_barras, localizacao_codigo_origem, localizacao_codigo_destino, quantidade, origem_leitura }`.
- **Pós-sucesso** (AC2.5/2.6): toast de sucesso → `_limparTudo()` (reseta os 4 campos, foca no scanner) → `_carregarLista()` (recarrega via ação `listar`; histórico da sessão preservado, AC3.1/3.2). Cada confirmação = movimento independente (**append**, resolve dúvida 2).
- **Lista da sessão** (`listar`): exibe movimentos com origem→destino + qty + produto. Como `localizacao_codigo`=destino, a origem para exibição vem de `listarPorMissao` (a linha já tem `localizacao_codigo_origem`); se a estrutura de `ListarAlocacoesLaboratorio` agregar só por destino, exibir o par origem→destino direto da linha de movimento (ajuste de render apenas client-side; sem mudança no Laboratorio de listagem se ele já devolver as linhas brutas — confirmar no passo 7).
- **i18n** `#mov-i18n` com chaves: bipeOrigem, bipeProduto, informeQuantidade, bipeDestino, quantidadePositiva ("Quantidade deve ser maior que zero"), origemDestinoIguais ("Origem e destino devem ser diferentes"), movimentacaoRegistrada, erros de rede/campos, etc. — todas via `show()`.
- **Tema visual:** cor própria (ex.: índigo/violeta) para distinguir das telas âmbar (inventário produtos) / teal (inventário endereço) — só CSS, sem efeito funcional.

---

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

1. **`localizacao_codigo` ambíguo na movimentação (= destino).** Mitigação: documentado no header da migration e do adapter; `montarMovimento` é o único lugar que decide isso; testes asseguram que origem nunca cai em `localizacao_codigo`. Consultas de saldo seguem por destino (correto: "o que chegou ali"); saldo de origem **não** é derivado (D1).
2. **Assinatura posicional de `registrarMovimento` está ficando longa** (12 params). Mitigação: params novos são opcionais e no fim (não quebram chamadas existentes); aceitável nesta fase. Refator para DTO/array fica como dívida futura (não nesta feature — evitar mudança ampla de contrato).
3. **Push é contrato com sistema externo ainda não conectado.** Mitigação: payload validado puro nos testes do adapter; `opc` novo (`registrar/sync_movimentacao_wms`) acordado aqui; quando o WMS conectar, só ajustar o contrato sem mexer no fluxo. Falha por saldo (AC5.6) já cai em `falha`+retry (D1).
4. **Tela de listagem da sessão** pode agregar por produto×endereço (como o inventário) e perder o par origem→destino. Mitigação: render da movimentação consome a linha bruta de `wms_movimentos` (origem+destino), não a agregação — verificar `ListarAlocacoesLaboratorio` no passo 7 e, se necessário, exibir direto das linhas (sem alterar o Laboratorio compartilhado).
5. **Designação data-driven precisa do adapter registrado para o seletor acender.** Mitigação: registrar no factory (passo 4) antes do smoke de designação (passo 8); `operacoesSuportadas()` é a fonte do seletor.

---

## 14. Breaking changes

**Nenhum.** Tudo aditivo: 2 colunas nullable + 2 índices parciais; 1 case de enum; 2 params opcionais na Sonda; 2 campos opcionais no dispatcher/Laboratorio; 1 chave no factory; 1 adapter + 1 tela novos; cron carrega 2 campos a mais (NULL nas demais operações). `tipo_missao_id=14`/`referencia_tipo='ALOCACAO_WMS'` intactos; nenhuma missão migrada; payload de alocação/inventário inalterado.

---

## 15. Out of scope (confirmando a spec + acréscimos técnicos)

Da spec (§Escopo ❌):
- Validação de saldo de origem (D1 — WMS reconcilia; recusa vira `falha`+retry).
- Produto/origem/destino pré-definidos na designação (C2/C3 — sessão 100% livre nesta fase).
- Controle por lote / validade / série.
- Movimentação em lote orquestrada (lista de itens dirigida).
- Dashboard / relatório de transferências por período.
- Leitura reversa de saldo do WMS (read API).
- Abate / ajuste de estoque contábil.

Acréscimos técnicos (fora desta entrega):
- Notificação ao operador em falha de push (dúvida 3 = sem notificação agora; só rastreio).
- Evento duplo debit/credit (A1 rejeitada — um evento com origem+destino).
- Saldo derivado por endereço de origem / coluna de saldo.
- Refator da assinatura de `registrarMovimento` para DTO.
- Base abstrata "duas localizações" (só há 1 operação assim hoje — não generalizar prematuramente).
```
