Skip to content

Estoque

Visão Geral

Sistema completo de controle de estoque com alertas de reposição.

Localização

frontend-react/src/views/admin/InventoryView.tsx

Funcionalidades

  • Cadastro de Itens:

    • Nome
    • SKU (código)
    • Unidade (kg, l, un)
    • Quantidade atual
    • Quantidade mínima
    • Custo unitário
    • Data de validade
  • Movimentações:

    • Entrada
    • Saída
    • Ajuste
    • Transferência
  • Alertas:

    • Estoque baixo (abaixo do mínimo)
    • Validade próxima (vencidos, 7 dias, 30 dias)
  • Importação de NFS (Nota Fiscal de Entrada):

    • Upload de XML NF-e de fornecedor
    • Criação automática de itens não cadastrados
    • Registro de movimentação de entrada (tipo entry, motivo purchase)
  • Controle de Estoque Automático:

    • Item de cardápio vinculado a item de estoque
    • Dedução automática ao entregar pedido
  • Etiquetas QR (Label Tokens):

    • Admin gera token vinculado a item + quantidade + motivo
    • QR imprimível com preview ao vivo; área printável via @media print
    • Scan público (POST /inventory/scan/:token) faz baixa atômica e registra InventoryMovement
    • Token single-use — segundo scan retorna 404

Campos do Item (Schema Completo)

Schema: backend/src/inventory/schemas/inventory.schema.ts

Identidade

CampoTipoDescrição
skustring (required, unique/tenant)Código único do produto no tenant
namestring (required)Nome do produto
descriptionstring?Descrição livre
barcodestring?Código de barras (EAN-13 etc.)
ncmstring?Nomenclatura Comum do Mercosul (fiscal)
categorystring?Categoria livre
unitenum (required)Unidade base: un kg g l ml cx pc kt m pct
unitPurchasestring?Unidade de compra (ex: "fardo", "dz")
conversionFactornumber?Fator de conversão unitPurchase → unit

Níveis de Estoque

CampoTipoDescrição
physicalQuantitynumber (default 0)Quantidade física total
availableQuantitynumber (default 0)Disponível para venda
reservedQuantitynumber (default 0)Reservado para pedidos em aberto
locationstring?Localização geral no armazém
warehousestring?Armazém/depósito
aislestring?Corredor
shelfstring?Prateleira
levelstring?Nível/andar

Financeiro

CampoTipoDescrição
costPricenumber?Preço de aquisição (R$)
salePricenumber?Preço de venda (R$)
wholesalePricenumber?Preço atacado (R$)
icmsRatenumber?Alíquota ICMS (%)
ipiRatenumber?Alíquota IPI (%)
fiscalGroupObjectId? → FiscalGroupGrupo fiscal para NF-e automática

Ponto de Reposição

CampoTipoDescrição
minQuantitynumber?Estoque mínimo — dispara alerta
maxQuantitynumber?Estoque máximo desejado
reorderQuantitynumber?Quantidade padrão de reposição

Status e Datas

CampoTipoDescrição
statusenumactive | blocked | expired | discontinued
expirationDateDate?Data de validade do lote
manufactureDateDate?Data de fabricação
validUntilDate?Válido até (uso alternativo)
imageUrlstring?URL da imagem do produto

Fornecedor

CampoTipoDescrição
supplierstring?Nome do fornecedor (legacy — use schema Supplier)
supplierCodestring?Código do produto no fornecedor (cProd da NF-e)
notesstring?Observações livres

Índices:

  • { sku, tenant } — unique
  • { barcode, tenant }
  • { tenant, status }

Importação de NFS (XML de Fornecedor)

Como usar

  1. Acesse Estoque no menu lateral
  2. Clique em Importar NFS (canto superior direito)
  3. Selecione o arquivo .xml da nota fiscal do fornecedor
  4. O sistema processa e exibe um resumo da importação

Resultado da importação

✓ Importação concluída
NF-e: 35260312345678000195550010000000421234567890
Emitente: FORNECEDOR LTDA

Itens processados: 12
• Itens criados: 3
• Itens atualizados: 9
• Movimentações registradas: 12

O que acontece por trás

Para cada <det> (produto) na NF-e:

  1. Busca InventoryItem por cProd (código do produto) ou xProd (nome)
  2. Se não encontrado → cria novo item com NCM, unidade e preço do XML
  3. Registra InventoryMovement (type=entry, reason=purchase, quantity=qCom, unitCost=vUnCom)
  4. Atualiza physicalQuantity e availableQuantity

Backend

backend/src/inventory/nfs-import.service.ts


Alertas de Validade

A aba Alertas exibe itens com vencimento próximo ou vencidos:

CorSignificado
VermelhoVencidos (já passaram da expirationDate)
LaranjaVencem em até 7 dias
AmareloVencem em até 30 dias

Endpoint

GET /t/:slug/inventory/expiration-alerts


Etiquetas QR (Label Tokens)

Permite gerar etiquetas físicas com QR Code vinculadas a uma baixa de estoque pendente. Ao escanear, o endpoint público consome o token e registra o InventoryMovement atomicamente.

Schema: LabelToken

backend/src/inventory/schemas/label-token.schema.ts

CampoTipoDescrição
tokenstring (unique)UUID gerado por crypto.randomUUID()
itemObjectId → InventoryItemItem que será baixado
quantitynumber (min 0.001)Quantidade a deduzir
reasonstringloss | theft | expiration | adjustment
notesstring?Observação livre
statusstringpending (inicial) | used (após scan)
usedAtDate?Momento do consumo
tenantObjectId → TenantIsolamento multi-tenant

API

POST /t/:slug/inventory/labels          → Gera token (requer ManageInventory)
POST /t/:slug/inventory/scan/:token     → Consome token (público, sem auth)

Garantia de single-use (race condition)

consumeLabelToken usa findOneAndUpdate atômico com filtro { status: 'pending' }:

findOneAndUpdate(
  { token, tenant, status: 'pending' },
  { status: 'used', usedAt: now },
  { new: true }
)
→ null se já usado → lança NotFoundException
→ doc se disponível → cria InventoryMovement (type=exit, reason=<reason>)

O segundo scan simultâneo recebe null e falha — sem movimento duplicado.

Frontend

ArquivoPapel
frontend-react/src/components/inventory/LabelGeneratorModal.tsxModal: form + preview com react-qr-code + print via @media print
frontend-react/src/components/inventory/InventoryItemsPanel.tsxBotão "Etiqueta" por linha (mobile + desktop)
frontend-react/src/views/ScanLabelView.tsxPágina pública /t/:slug/scan/label/:token — loading/success/error

Rota pública

O controlador público (InventoryPublicController) não tem guards de classe — o tenantId vem do TenantMiddleware (middleware Express já aplicado a todas as rotas t/*).

typescript
// backend/src/inventory/inventory-public.controller.ts
@Controller('t/:slug/inventory')
export class InventoryPublicController {
  @Post('scan/:token')
  consumeLabel(@TenantId() tenantId: string, @Param('token') token: string) {
    return this.inventoryService.consumeLabelToken(tenantId, token);
  }
}

Estoque por Filial (BranchStock)

O PopinaFlow usa um modelo de dois níveis para controle de estoque em ambientes com múltiplos terminais:

  • InventoryItem — catálogo central (compartilhado entre todas as filiais)
  • BranchStock — ledger por PDV que registra a quantidade de cada item em cada terminal

Schema: backend/src/branch-stock/schemas/branch-stock.schema.ts

CampoTipoDescrição
itemObjectId → InventoryItemItem do catálogo central
pdvObjectId → PdvTerminal/filial
physicalQuantitynumber (default 0)Quantidade física no PDV
availableQuantitynumber (default 0)Disponível para venda no PDV
minQuantitynumber?Ponto de alerta para esta filial

Índice: { item, pdv } unique.

Operações disponíveis:

  • Upsert — define quantidade absoluta (PUT /branch-stock/:pdvId)
  • Adjust — incremento/decremento por delta (POST /branch-stock/:pdvId/adjust)
  • Transfer — transferência atômica entre PDVs (POST /branch-stock/transfer)

UI: Abas "Branch Stock" e "Matrix" em InventoryView.tsx. A aba Matrix exibe uma grade PDVs × itens com células coloridas: vermelho (zerado), amarelo (abaixo do mínimo), verde (ok).

Ver detalhes completos de operações e API em Transferência de Estoque.


Fornecedores

Cadastro de fornecedores vinculados ao tenant. Permite associar fornecedores a Pedidos de Compra e a NF-es de entrada.

Schema: backend/src/inventory/schemas/supplier.schema.ts

CampoTipoDescrição
namestring (required)Nome/razão social
cnpjstring?CNPJ (único sparse por tenant)
emailstring?E-mail de contato
phonestring?Telefone
contactNamestring?Nome do contato
addressstring?Endereço completo
categoriesstring[]Categorias de produtos fornecidos
notesstring?Observações
activeboolean (default true)Ativo/inativo

Índice: { tenant, cnpj } sparse unique.

UI: Aba "Fornecedores" em InventoryView.tsx, componentes SuppliersTab.tsx + SupplierModal.tsx.

Acesso: Plan feature supplierManagement; role Admin ou Superadmin.

API resumida:

MétodoRotaDescrição
GET/t/:slug/inventory/suppliers?active=trueListar fornecedores
GET/t/:slug/inventory/suppliers/:idDetalhe
POST/t/:slug/inventory/suppliersCriar
PUT/t/:slug/inventory/suppliers/:idAtualizar
DELETE/t/:slug/inventory/suppliers/:idRemover

Ver detalhes em API — Fornecedores.


Pedidos de Compra

Controle de ordens de compra enviadas a fornecedores, com recebimento de itens e atualização automática de estoque.

Schema: backend/src/inventory/schemas/purchase-order.schema.ts

CampoTipoDescrição
orderNumberstring (auto)Formato PO-YYYYMMDD-NNNN
supplierObjectId → SupplierFornecedor
itemsPurchaseOrderItem[]Itens do pedido
totalCostnumberTotal calculado pelo servidor
statusenumdraft | sent | partial | received | cancelled
expectedDeliveryDate?Previsão de entrega
receivedAtDate?Data de recebimento efetivo
receivedByObjectId? → UserUsuário que recebeu

Sub-documento PurchaseOrderItem:

CampoTipoDescrição
inventoryItemObjectId → InventoryItemItem
itemNamestringNome (denormalizado)
quantitynumberQuantidade solicitada
unitCostnumberCusto unitário
subtotalnumberquantity × unitCost (calculado)

Fluxo de status:

draft → sent → partial → received

cancelled

Recebimento de itens: POST /purchase-orders/:id/receive

Para cada item recebido:

  1. Cria InventoryMovement (type: entry, reason: purchase)
  2. Incrementa physicalQuantity e availableQuantity do InventoryItem
  3. Status muda para partial (se quantidade parcial) ou received

Auto-geração a partir de estoque baixo: POST /purchase-orders/generate-from-low-stock

Varre todos os itens ativos com availableQuantity ≤ minQuantity > 0 e gera um draft PO com quantidade sugerida = max(1, minQuantity × 2 − availableQuantity).

UI: PurchaseOrdersTab.tsx, PurchaseOrderModal.tsx, ReceiveItemsModal.tsx.

Acesso: Plan feature supplierManagement; role Admin ou Superadmin.

Ver API completa em API — Pedidos de Compra.


Alertas de Estoque (StockAlert)

Notificações persistidas quando availableQuantity ≤ minQuantity após qualquer mutação de estoque.

Schema: backend/src/inventory/schemas/stock-alert.schema.ts

CampoTipoDescrição
itemObjectId → InventoryItemItem com estoque baixo
pdvObjectId? → PdvPDV afetado (para alertas de filial)
availableQuantitynumberQuantidade disponível no momento do alerta
minQuantitynumberLimiar configurado
itemNamestring?Nome (denormalizado)
pdvNamestring?Nome do PDV (denormalizado)
readByObjectId[]Usuários que já viram o alerta
expiresAtDateTTL de 7 dias — MongoDB remove automaticamente

Dois gatilhos:

  1. InventoryService — após qualquer createMovement() no catálogo master
  2. BranchStockService — após qualquer upsert/adjust/transfer na filial

Leitura por usuário: GET /inventory/alerts retorna apenas alertas não lidos pelo usuário atual (não presente em readBy[]). O badge de contagem usa GET /inventory/alerts/count → { count }.

Marcar como lido: PUT /inventory/alerts/:id/read adiciona o userId ao array readBy[].


Previsão de Demanda (IA)

O módulo de Previsão de Demanda analisa o histórico de vendas (snapshots diários) e calcula a demanda esperada de ingredientes usando médias móveis ponderadas (WMA).

Resultados incluem:

  • Previsão de venda por item do cardápio com tendência e índice de confiança
  • Demanda de ingredientes com daysUntilStockout
  • Sugestão automática de Pedidos de Compra

Ver documentação completa em Previsão de Demanda (IA).


WebSocket — namespace /inventory

Gateway: backend/src/inventory/inventory.gateway.ts

Conexão:

ts
const socket = io(VITE_API_URL, { auth: { token: jwt } });
// namespace: /inventory

Auth: JWT obrigatório. Roles aceitas: staff, admin, superadmin. Conexões não autorizadas são desconectadas imediatamente.

Evento: joinInventory (cliente → servidor)

ts
socket.emit('joinInventory', tenantId);

Entra na sala inventory-<tenantId>. Superadmin pode passar qualquer tenantId.

Evento: stockUpdate (servidor → cliente)

Emitido após qualquer mutação de estoque (movimento, upsert de filial, transferência).

Catálogo master:

json
{
  "itemId": "64a1b2c3d4e5f6789",
  "itemName": "Pão de hambúrguer",
  "physicalQuantity": 45,
  "availableQuantity": 40
}

Estoque de filial:

json
{
  "type": "branchStock",
  "pdvId": "64a1b2c3d4e5f6001",
  "itemId": "64a1b2c3d4e5f6789",
  "physicalQuantity": 20,
  "availableQuantity": 18,
  "minQuantity": 10
}

Evento: lowStockAlert (servidor → cliente)

Emitido quando availableQuantity ≤ minQuantity após uma mutação.

Catálogo master:

json
{
  "_id": "64a1b2c3d4e5f6aaa",
  "itemId": "64a1b2c3d4e5f6789",
  "itemName": "Pão de hambúrguer",
  "availableQuantity": 8,
  "minQuantity": 10,
  "createdAt": "2026-04-10T14:30:00.000Z"
}

Estoque de filial: inclui adicionalmente type: "branchStock", pdvId, pdvName.


Vínculo com Cardápio — Controle Automático

Há duas formas de vincular itens de cardápio ao estoque. Ambas fazem a dedução no momento em que o pedido é marcado como entregue. A dedução é executada de forma assíncrona (best-effort) — se o estoque for insuficiente, o saldo fica negativo mas a entrega não é bloqueada.


Modo 1: Ficha Técnica (Bill-of-Materials)

Ideal para pratos compostos. Um hambúrguer artesanal pode consumir 0,2 kg de carne, 2 unidades de pão e 10 g de queijo simultaneamente.

Schema: ProductIngredient

CampoTipoDescrição
menuItemObjectIdItem de cardápio
inventoryItemObjectIdIngrediente do estoque
quantityPerUnitnumberQtd do ingrediente por 1 unidade do prato (mín. 0.001)
unitstring?Rótulo informativo (ex: "kg", "un")
tenantObjectIdTenant

Índice único: { menuItem, inventoryItem, tenant }

Gerenciamento via API:

GET    /t/:slug/inventory/ingredients?menuItemId=<id>
POST   /t/:slug/inventory/ingredients
DELETE /t/:slug/inventory/ingredients/:id

Modo 2: Vínculo Simples (1:1 — legado)

Para itens sem ficha técnica, o campo MenuItem.inventoryItem é usado como fallback. Deduz 1 unidade do item vinculado por unidade do prato.

Configurar:

  1. Acesse Cardápio → Itens
  2. Edite o item desejado
  3. Selecione o Item de Estoque no formulário
  4. Salve

Como a dedução funciona (processOrderInventory)

Chamado em OrdersService.updateStatus() / closeTab() quando o status muda para Delivered:

Para cada item do pedido:
  1. Busca ProductIngredient[] (ficha técnica)
     → Se existir: deduções = ingredientes × quantidade pedida
     → Caso contrário: usa MenuItem.inventoryItem (fallback)
     → Se nenhum: item sem rastreamento, prossegue normalmente

  2. Dedução best-effort (findOneAndUpdate):
     Cada deducão usa $inc { availableQuantity: -totalQty }
     Se availableQuantity ficar negativo, o saldo fica negativo — a entrega NÃO é bloqueada

  3. Trilha de auditoria:
     Cria InventoryMovement (type=exit, reason=sale) para cada deducão

Cancelamento após entrega: Se o status mudar para Cancelled após já ter sido Delivered, compensateOrderInventory() é chamado para restaurar os saldos.

Sem transações MongoDB: O projeto usa MongoDB standalone (sem replica set). A consistência é garantida por findOneAndUpdate atômico por documento + compensação de aplicação em caso de falha concorrente.


Prioridade de Resolução

SituaçãoComportamento
Tem ProductIngredientUsa ficha técnica (todos os ingredientes)
Sem ficha, mas tem MenuItem.inventoryItemDeduz 1 unidade do item vinculado
Nenhum vínculoSem rastreamento — pedido prossegue normalmente
availableQuantity < necessárioDedução best-effort; saldo pode ficar negativo

Componentes Relacionados

Relacionados

Lançado sob a licença MIT.