# Cota mínima de bipagem — Plano técnico

## Resumo de decisão arquitetural

Não é Sinal novo nem Adapter. A feature **estende a constelação `MissaoLogistica`** em três
pontos de extensão já existentes e canônicos:

1. **Config por empresa** → nova **Sonda V2 dedicada** `ScanCotaConfigSonda` em
   `componentes/Constelacoes/Logistica/MissaoLogistica/Sondas/`, sobre uma tabela própria
   `missao_scan_cota_config` (multi-tenant via `block_empresa_id`, mesmo padrão da tabela
   `missao_logistica`). NÃO estende `metas` nem mete a config em colunas de `missao_logistica`.
2. **Snapshot** → uma nova etapa no `GestaoMissoesBase::processarEnvio`, ao lado do
   `enriquecerCodigosBarras()` já existente, congelando `configuracoes.scan_cota` no payload
   da missão (mesmo mecanismo do snapshot de códigos de barras).
3. **Enforcement** → cálculo puro num **Laboratório** novo (`ScanCotaLaboratorio`), consumido
   por (a) o snapshot na criação, (b) o `MissaoPresenter` para mandar `data-*` ao front, e
   (c) o backstop dentro de `MissoesStatus::finalizar`.

Padrão V2 dominante: Sonda (persistência/leitura), Laboratório (regra pura, testável),
Presenter→`data-*` (contrato com o front). **Sem Registry/Strategy nova** — a feature tem
um único algoritmo de cota; o que varia (separação vs. carregamento) já é um toggle de
config, não um ponto de polimorfismo. Reaproveita os counters `quantidade_bipada_scan` /
`quantidade_bipada_manual` que **já existem na tabela** `missao_logistica_itens`.

## Sinal de referência

- **Imitar (snapshot na criação)**: `componentes/Missoes/Logistica/sinais/Designacoes/Laboratorios/GestaoMissoesBase.php`
  — método `enriquecerCodigosBarras()` (linhas ~187-258) e seu ponto de chamada em
  `processarEnvio()` (linha ~112). O snapshot de `scan_cota` entra exatamente aqui, porque
  TODOS os tipos de missão passam por este Template Method e o denominador N precisa dos
  itens já enriquecidos com `codigos_barras`.
- **Imitar (contrato front)**: `componentes/Constelacoes/Logistica/MissaoLogistica/Presenters/MissaoPresenter.php`
  — `montarJsonFrontendBase()` já entrega `codigosBarras`/`codigos_validos` por item lendo
  o snapshot. A cota viaja no mesmo veículo (campo mission-wide + flag `bipavel` por item).
- **Imitar (Sonda V2)**: `componentes/Constelacoes/Logistica/MissaoLogistica/Sondas/RecomecarItemSonda.php`
  (Sonda fina, sem estado) e `.../Sondas/PedidoErpSonda.php` (extends `ModelInteligencia`,
  `init()` com `propeties`). A config segue o `propeties` de
  `componentes/Constelacoes/Metas/Crud/Inteligencias/Meta.php` (`companyControl` canônico),
  mas com a Sonda fazendo o filtro de tenant explícito (ver "Sondas e schema").
- **Imitar (backstop)**: `componentes/Constelacoes/Logistica/MissaoLogistica/Satelites/MissoesStatus.php`
  — método `finalizar()` (linhas ~267-369). A checagem de cota entra no MESMO gate, logo
  após a checagem de "itens pendentes" (linha ~303).
- **Imitar (counters)**: `componentes/Constelacoes/Logistica/MissaoLogistica/Satelites/MissoesBipagem.php`
  — `atualizarItemNoBanco()` (linha ~296) já escreve `quantidade_bipada_scan` /
  `quantidade_bipada_manual` separados por `metodo_entrada`. A definição autoritativa de
  "item 100% por scanner" deriva desses dois counters. NÃO mexer na escrita; só ler.

- **Não imitar**: `sinais/Designacoes/Strategies/...` para a cota — não há polimorfismo de
  cota (um único algoritmo). `sinais/Edicaorota/Callbacks/Registry` — aquele Registry existe
  porque há N módulos consumindo callbacks; aqui há 1 consumidor de cada ponto. Sinais
  legados `Designacoes`/`Pessoas` (V1) não são referência de padrão.

## Estrutura proposta

### Novos arquivos

```
componentes/Constelacoes/Logistica/MissaoLogistica/
├── Sondas/
│   └── ScanCotaConfigSonda.php                 # CRUD da config por empresa (companyControl)
├── Laboratorios/
│   └── ScanCotaLaboratorio.php                 # regra pura: N, meta, cota, qualificados
├── Enums/
│   └── (reuso de AcaoExtrato — ver "Registro de violação", sem arquivo novo)
├── migrations/
│   └── 2026_06_17_create_missao_scan_cota_config.sql
└── Testes/
    ├── Sondas/ScanCotaConfigSondaTddTest.php
    └── Laboratorios/ScanCotaLaboratorioTddTest.php
```

### Arquivos alterados

- `componentes/Missoes/Logistica/sinais/Designacoes/Laboratorios/GestaoMissoesBase.php`
  — em `processarEnvio()`, após `enriquecerCodigosBarras()`, chamar
  `congelarScanCota($itensFinais, $tipoMissao)` e injetar o resultado em
  `$configs['scan_cota']` antes do `json_encode($configs)`. Novo método protegido
  `congelarScanCota()` que: lê a config via `ScanCotaConfigSonda`, checa o toggle do fluxo,
  conta N (itens com `dados_adicionais['codigos_barras']` não-vazio), e delega o cálculo ao
  `ScanCotaLaboratorio`. Se desligado → grava `scan_cota` ausente/`{ativo:false}`.
- `componentes/Constelacoes/Logistica/MissaoLogistica/Presenters/MissaoPresenter.php`
  — `montarJsonFrontendBase()`: marcar cada item com `bipavel` (bool, derivado de
  `codigos_validos` não-vazio) e `manchado` (bool, derivado de
  `quantidade_bipada_manual > 0`). Novo método estático `montarScanCotaFront(object $missao,
  array $jsItensData): array` que lê `configuracoes.scan_cota` da missão e devolve o bloco
  mission-wide para os `data-*` (ver "Endpoints / fluxo").
- `componentes/Constelacoes/Logistica/MissaoLogistica/Satelites/MissoesStatus.php`
  — `finalizar()`: após a checagem de pendentes, chamar
  `ScanCotaLaboratorio::validarFinalizacao($missao, $itens)`; se reprovar, retornar
  `Message::error(...)` com a contagem que falta e **registrar a violação** via
  `registrarExtrato(...)`.
- `componentes/Constelacoes/Logistica/MissaoLogistica/Enums/AcaoExtrato.php`
  — adicionar case `SCAN_COTA_VIOLADA = 'SCAN_COTA_VIOLADA'` (+ `descricao`/`icon`/`categoria`
  na categoria `item`/`missao`).
- Front (sem lógica nova de domínio, só ler `data-*` e bloquear/avisar):
  - `componentes/Missoes/Logistica/sinais/App/html/shared/components/v2/execucao_bipagem.php`
    — emitir os `data-*` mission-wide da cota (componente unificado de carregamento).
  - `componentes/Missoes/Logistica/sinais/App/html/shared/components/execucao/execucao_card.php`
    — idem para separação (já tem o bloco de `data-*` hidden inputs, linhas ~344-361).
  - `componentes/Missoes/Logistica/sinais/App/html/shared/components/execucao/execucao_card.js`
    e `.../carregamento/item.js` — desabilitar +/− quando cota zera; modal de aviso na última.
- `linguagens/{pt-br,en,es}.json` — chaves novas (ver "i18n").

## Sondas e schema

### `missao_scan_cota_config` (Sonda: `ScanCotaConfigSonda`)

Tabela nova no MESMO banco Postgres `gx_goldie` onde vive `missao_logistica`. Por isso segue
a convenção de `block_*` desse banco (`block_empresa_id`, `block_filial_id`,
`block_usuario_id`), **não** os `block_company_id`/`block_customer_id` do `ModelDb`
moderno — `missao_logistica` e filhas usam `block_empresa_id` (confirmado no schema vivo:
`block_empresa_id` populado, 1 tenant por dev). 1 linha por empresa.

```sql
-- 2026_06_17_create_missao_scan_cota_config.sql
CREATE TABLE IF NOT EXISTS missao_scan_cota_config (
    id SERIAL PRIMARY KEY,
    -- domínio:
    ativo               SMALLINT     NOT NULL DEFAULT 0,   -- master on/off
    pct                 NUMERIC(5,2) NOT NULL DEFAULT 0,   -- percentual obrigatório (0..100)
    ativo_separacao     SMALLINT     NOT NULL DEFAULT 0,   -- toggle fluxo separação
    ativo_carregamento  SMALLINT     NOT NULL DEFAULT 0,   -- toggle fluxo carregamento
    -- multi-tenant (mesma convenção de missao_logistica neste banco):
    block_empresa_id    INTEGER,                           -- sem DEFAULT; framework/Sonda injeta
    block_filial_id     INTEGER,
    block_usuario_id    INTEGER,
    -- auditoria padrão das tabelas deste banco:
    data_cadastro       TIMESTAMP WITHOUT TIME ZONE DEFAULT NOW(),
    data_atualizacao    TIMESTAMP WITHOUT TIME ZONE,
    usuario_cadastro    VARCHAR(255),
    usuario_atualizacao VARCHAR(255),
    block               INTEGER,
    block_data          TIMESTAMP WITHOUT TIME ZONE,
    block_quem          VARCHAR(255)
);

-- 1 config por empresa (idempotência + busca rápida no snapshot)
CREATE UNIQUE INDEX IF NOT EXISTS uq_scan_cota_empresa
    ON missao_scan_cota_config (block_empresa_id);
```

**`propeties` da Sonda** (`ScanCotaConfigSonda::init()`):
`["customerControl" => false, 'db' => 'gx_goldie', "companyControl" => false, "userControl" => false]`.
Justificativa: o `companyControl` do `ModelDb` filtra por `block_company_id` (coluna que esta
tabela **não** tem — ela usa `block_empresa_id`, igual a `missao_logistica`). Portanto a Sonda
**não delega** ao `companyControl` automático; faz o escopo de tenant **explícito** por
`block_empresa_id`, lendo o empresa atual da sessão da MESMA forma que o resto da constelação
(`getSession('galaxiaUserInfo')->principalEmpresa->empresa_id`, com fallback). Isto evita o
mismatch coluna-framework e mantém o isolamento por tenant (AC5.3). Default de fábrica
**desligado** garante AC5.2 (empresa sem linha → `obterConfig()` devolve `ativo=false`).

API da Sonda (mínima):
- `obterConfig(int $empresaId): object` — 1 linha ou objeto default desligado.
- `salvarConfig(int $empresaId, array $dados): bool` — upsert idempotente (usa o índice único).

### Snapshot na missão — `configuracoes.scan_cota` (JSONB existente)

Nenhuma migration: reusa a coluna `missao_logistica.configuracoes` (JSONB), mesmo lugar de
`is_retira`. Formato congelado na criação:

```json
"scan_cota": {
  "ativo": true,
  "fluxo": "SEPARACAO",
  "pct": 10,
  "denominador_n": 25,
  "meta_itens": 3,
  "cota_manuais": 22
}
```

Quando desligado (regra inativa no fluxo, `pct=0` ou `N=0`): grava `"scan_cota": {"ativo": false}`
(ou omite). Front e backstop tratam ausência/`ativo:false` como "sem cota" (AC1.2, AC4.5).

### Counters por item — `quantidade_bipada_scan` / `quantidade_bipada_manual`

**RESOLVIDO (ponto em aberto do PRD): as colunas JÁ EXISTEM no banco vivo `dev_gx_goldie`**
(`numeric`, default NULL), mas **não estão em `tables.sql` nem em nenhuma migration** — foram
aplicadas ad-hoc. Ação: **incluir as duas colunas no `tables.sql`** e **criar uma migration
idempotente** `ADD COLUMN IF NOT EXISTS` para fechar o débito e garantir paridade em outros
ambientes (sem isso, o backstop quebra fora de dev). O backstop **não** precisa derivar do
extrato — lê os counters direto. Migration adicional:

```sql
-- 2026_06_17_backfill_counters_scan_manual.sql  (fecha débito de schema)
ALTER TABLE missao_logistica_itens
  ADD COLUMN IF NOT EXISTS quantidade_bipada_scan   DECIMAL(15,4) DEFAULT 0,
  ADD COLUMN IF NOT EXISTS quantidade_bipada_manual DECIMAL(15,4) DEFAULT 0;
```

Nota de integridade: ~23,6k itens legados têm ambos counters NULL (8,3k com
`quantidade_bipada>0`). Isso **não afeta** o backstop: ele só roda em missões cujo snapshot
tem `scan_cota.ativo=true` (criadas após o rollout), e nessas os counters sempre são
populados pela escrita de `MissoesBipagem`. Itens com counter NULL são tratados como `0`
(`COALESCE`) no Laboratório por segurança.

## Endpoints / fluxo

A feature **não cria rotas REST novas**. Tudo entra nos handlers/satélites já roteados pelo
controller `componentes/Missoes/Logistica/sinais/App/App.php` (cases `MissoesBipagem`,
`MissoesStatus`) e no pipeline de criação (`Designacoes`).

| Ponto | Onde | Quem chama | Papel da cota |
|---|---|---|---|
| Criação da missão | `GestaoMissoesBase::processarEnvio` → `congelarScanCota()` | Designacoes (enviarMissoes) | Lê config, conta N, congela `scan_cota` |
| Render execução | `MissaoPresenter::montarScanCotaFront` | Separacao/CarregamentoPresenter | Emite `data-*` mission-wide + `bipavel`/`manchado` por item |
| Bipagem (scan/manual) | `MissoesBipagem::atualizarItemNoBanco` | App (biparItem / lote) | **Sem mudança** — counters já gravados |
| Finalização (backstop) | `MissoesStatus::finalizar` → `ScanCotaLaboratorio::validarFinalizacao` | App (finalizarMissao) | Recomputa qualificados, recusa + registra violação |
| Config por empresa | `ScanCotaConfigSonda::salvarConfig/obterConfig` | tela admin existente da empresa | CRUD da regra |

### Contrato Presenter → `data-*` (front)

Mission-wide (um bloco, lido por separação e carregamento) — emitido em
`execucao_bipagem.php` / `execucao_card.php`:

```
data-scan-cota-ativo      = "1" | "0"
data-scan-cota-meta       = <meta_itens>          # itens que precisam ser 100% scan
data-scan-cota-manuais    = <cota_manuais>        # cota inicial = N - meta
data-scan-cota-n          = <denominador_n>
```

Por item (no JSON `jsItensData` que o front já consome):
```
bipavel  : bool   # tem código bipável (entra no denominador N)
manchado : bool   # quantidade_bipada_manual > 0  (já consumiu/queimou elegibilidade)
```

O front **reconstrói a cota a partir do estado renderizado** (AC2.6): cota disponível =
`cota_manuais − (nº de itens bipáveis com manchado=true)`. Isso torna o refresh idempotente
sem store local persistente — a fonte da verdade é o estado dos itens vindo do servidor
(que já reflete os counters). O store local só guarda o estado de sessão entre toques; ao
recarregar, recalcula de `manchado`. Bloqueio de +/− quando cota disponível ≤ 0 **e**
`bipavel && !manchado`. Modal de aviso (AC3) dispara quando o próximo toque manual num item
limpo levaria a cota disponível a 0.

## Padrões de extensibilidade

**N/A — deliberadamente sem Registry/Strategy/Adapter novos.**

Justificativa (anti-padrão prematuro): a spec descreve **um único** algoritmo de cota,
parametrizado por config; a variação separação×carregamento é um par de toggles booleanos,
não um ponto de polimorfismo. O cálculo vive num **Laboratório** (`ScanCotaLaboratorio`) com
métodos puros e estáticos/instanciáveis:
- `calcular(int $n, float $pct): array` → `{meta_itens, cota_manuais}` (`ceil`, `meta ≤ N`).
- `contarBipaveis(array $itens): int` → N (itens com `codigos_barras` não-vazio; KG/pesagem e
  sem código ficam fora).
- `itemQualificado(object $item): bool` → `scan == quantidade && manual == 0` (autoritativo).
- `validarFinalizacao(object $missao, array $itens): array` → `{ok, qualificados, meta, faltam}`.

Um Laboratório (não Satélite) porque é regra pura, sem I/O, 100% testável em unit — o
I/O (ler config, ler missão) fica nos chamadores (snapshot/Presenter/Status).

## Ordem de implementação (sequencial)

1. **Migration** `2026_06_17_create_missao_scan_cota_config.sql` + atualizar `tables.sql`
   (config + as 2 colunas de counter) e a migration de backfill dos counters →
   rodar `tests/integration/...` de schema (modelo: `tests/integration/AlocacaoWms/MigrationSchemaTest.php`).
2. **Sonda** `ScanCotaConfigSonda` (`obterConfig`/`salvarConfig`, escopo `block_empresa_id`).
3. **Laboratório** `ScanCotaLaboratorio` (regra pura) + `ScanCotaLaboratorioTddTest`
   (cobre AC1.1/1.3/1.4, AC6.3 — cálculo de N/meta/cota; itemQualificado).
4. **Enum** `AcaoExtrato::SCAN_COTA_VIOLADA`.
5. **Snapshot**: `GestaoMissoesBase::congelarScanCota()` + ponto de chamada em
   `processarEnvio` (AC6.*).
6. **Backstop**: `MissoesStatus::finalizar` chama `validarFinalizacao` + registra violação
   (AC4.*).
7. **Presenter→front**: `MissaoPresenter` (`bipavel`/`manchado` + `montarScanCotaFront`) e
   emissão dos `data-*` em `execucao_bipagem.php` / `execucao_card.php`.
8. **Front JS**: bloqueio +/− e modal de aviso em `execucao_card.js` e `carregamento/item.js`
   (AC2.*, AC3.*). Mencionar `genesis-views-i18n` ao implementar.
9. **i18n** `pt-br/en/es` (strings novas).
10. **Testes** Satélite/integração do gate de finalização.

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

1. **Mismatch coluna×framework (`block_empresa_id` vs `block_company_id`)** — o
   `companyControl` automático do `ModelDb` filtra por `block_company_id`, coluna que a tabela
   de config (alinhada a `missao_logistica`) NÃO tem. Mitigação: Sonda com
   `companyControl=false` e **filtro de tenant explícito** por `block_empresa_id` na própria
   Sonda. Documentado em "Sondas e schema". (Não copiar o `propeties` de `Meta.php` cegamente:
   `metas` é MySQL/V1 e seu `block_empresa_id` é tratado por outro caminho.)
2. **Counters não-versionados no schema** — existem só no banco vivo. Sem a migration de
   backfill, o backstop quebra em homolog/prod. Mitigação: migration `ADD COLUMN IF NOT EXISTS`
   + adicionar ao `tables.sql` (item 1).
3. **N só é confiável após `enriquecerCodigosBarras`** — o denominador depende de
   `dados_adicionais['codigos_barras']` já populado. Por isso `congelarScanCota` roda DEPOIS
   de `enriquecerCodigosBarras` no `processarEnvio`. Ordem invertida → N sempre 0 → cota
   sempre desligada (bug silencioso).
4. **Front reconstrói cota do estado, não de store persistente** — evita drift no refresh
   (AC2.6). Risco: se `manchado` não for emitido corretamente por item, a cota "renasce" no
   reload. Mitigação: `manchado` derivado server-side de `quantidade_bipada_manual>0`, fonte
   única.
5. **Caminho de bipagem em lote** (`MissoesBipagem::bipagemLote`) também grava counters por
   `metodo_entrada`; o backstop é agnóstico a real-time vs. lote porque lê o estado final dos
   counters. Sem ação, mas é o motivo de o backstop ser autoritativo (cobre o operador que
   sincroniza offline). Validar no teste de integração.
6. **Item KG/pesagem** nunca entra em N (sem código bipável e/ou `tipo PESAGEM=12`);
   `contarBipaveis` filtra por `codigos_barras` não-vazio, o que já exclui pesagem. Confirmar
   que itens KG não recebem `codigos_barras` no snapshot (hoje recebem só se o produto tiver
   código — risco de falso-positivo se um produto KG tiver EAN). Mitigação no Laboratório:
   excluir explicitamente unidades de peso (lista já existe em `MissoesBipagem::isUnidadePeso`).

## Breaking changes

Nenhum. É adição pura:
- Tabela nova + 2 colunas idempotentes (`IF NOT EXISTS`).
- `scan_cota` ausente ⇒ comportamento idêntico ao atual (default desligado, AC5.2/4.5).
- Counters já são escritos hoje; só passam a ser **lidos** pelo backstop.
- Nenhuma rota, assinatura pública ou contrato de extrato existente é alterado (só novo case
  de enum, aditivo).

## Plano de teste

- **Unit (`ScanCotaLaboratorioTddTest`)**: `calcular` (AC1.1 N=25/pct=10→meta=3,cota=22;
  AC1.3 meta≤N; AC1.2 pct=0/N=0→desligado), `contarBipaveis` (exclui KG e sem-código, AC1.4),
  `itemQualificado` (scan==qtd && manual==0; manchado nunca qualifica, AC4.3), `validarFinalizacao`.
- **Unit (`ScanCotaConfigSondaTddTest`)**: upsert idempotente, isolamento por `block_empresa_id`
  (AC5.3), default desligado p/ empresa sem linha (AC5.2).
- **Integration (contra `dev_gx_goldie`)**: criar missão com regra ativa → `configuracoes.scan_cota`
  congelado correto (AC6.1/6.3); alterar config depois → missão antiga inalterada (AC6.2);
  finalizar com `qualificados<meta` → recusa + extrato `SCAN_COTA_VIOLADA` (AC4.2/4.4);
  finalizar missão com `scan_cota.ativo=false` → passa (AC4.5). Bootstrap de sessão nos dois
  lugares (`$_SESSION` raiz + `dashboard`), modelo `MigrationSchemaTest.php`.
- **Manual (smoke do usuário)**: ligar regra 10% numa empresa de teste, gerar missão de
  separação, bipar/contar até a cota zerar → +/− bloqueia nos itens limpos, modal avisa na
  última, scanner continua liberado; tentar finalizar abaixo da meta → recusa com contagem;
  bipar o restante → finaliza. Repetir em carregamento. Confirmar refresh no meio (AC2.6).

## Out of scope (confirmando spec)

Da spec `01-spec.md` (seção "Fora de escopo"), mantido:
- Fluxo de **retorno** (não valida código; permanece sem cota).
- **Pesagem/KG**: fora do denominador N.
- Contagem **por unidades** (sempre por SKU/linha).
- **Recuperação de item manchado** (manchamento permanente).
- **Tela de admin sofisticada** (basta CRUD por empresa via Sonda).
- Refatorar o **débito de i18n das mensagens de bipagem já existentes** (só strings novas
  nascem internacionalizadas).

Acréscimos técnicos ao out-of-scope:
- **Não** alterar o cálculo/escrita dos counters em `MissoesBipagem` (só leitura no backstop).
- **Não** introduzir Strategy/Registry de cota (um único algoritmo).
- **Não** criar rota REST `/api/v1` nova — tudo via handlers/satélites já roteados em `App.php`.
- **Backfill** de counters em itens legados (NULL) **não** é necessário: backstop só atua em
  missões com snapshot ativo, posteriores ao rollout.

## i18n (chaves novas, 3 línguas)

`linguagens/{pt-br,en,es}.json`:
- `scan_cota_manual_bloqueado` — toast/inline quando +/− bloqueia ("Cota de manuais esgotada;
  bipe este item.").
- `scan_cota_aviso_ultima_titulo` / `scan_cota_aviso_ultima_texto` — modal AC3 (última cota).
- `scan_cota_aviso_confirmar` / `scan_cota_aviso_cancelar` — botões do modal.
- `scan_cota_finalizacao_recusada` — mensagem do backstop (AC4.2), com placeholder de
  quantos itens faltam por scanner.
- `scan_cota_config_*` — labels da config por empresa (master, pct, toggles).

Textos exatos: pendentes de UX (item aberto na spec); chaves criadas com placeholder.
