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, motivopurchase)
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 registraInventoryMovement - Token single-use — segundo scan retorna 404
Campos do Item (Schema Completo)
Schema: backend/src/inventory/schemas/inventory.schema.ts
Identidade
| Campo | Tipo | Descrição |
|---|---|---|
sku | string (required, unique/tenant) | Código único do produto no tenant |
name | string (required) | Nome do produto |
description | string? | Descrição livre |
barcode | string? | Código de barras (EAN-13 etc.) |
ncm | string? | Nomenclatura Comum do Mercosul (fiscal) |
category | string? | Categoria livre |
unit | enum (required) | Unidade base: un kg g l ml cx pc kt m pct |
unitPurchase | string? | Unidade de compra (ex: "fardo", "dz") |
conversionFactor | number? | Fator de conversão unitPurchase → unit |
Níveis de Estoque
| Campo | Tipo | Descrição |
|---|---|---|
physicalQuantity | number (default 0) | Quantidade física total |
availableQuantity | number (default 0) | Disponível para venda |
reservedQuantity | number (default 0) | Reservado para pedidos em aberto |
location | string? | Localização geral no armazém |
warehouse | string? | Armazém/depósito |
aisle | string? | Corredor |
shelf | string? | Prateleira |
level | string? | Nível/andar |
Financeiro
| Campo | Tipo | Descrição |
|---|---|---|
costPrice | number? | Preço de aquisição (R$) |
salePrice | number? | Preço de venda (R$) |
wholesalePrice | number? | Preço atacado (R$) |
icmsRate | number? | Alíquota ICMS (%) |
ipiRate | number? | Alíquota IPI (%) |
fiscalGroup | ObjectId? → FiscalGroup | Grupo fiscal para NF-e automática |
Ponto de Reposição
| Campo | Tipo | Descrição |
|---|---|---|
minQuantity | number? | Estoque mínimo — dispara alerta |
maxQuantity | number? | Estoque máximo desejado |
reorderQuantity | number? | Quantidade padrão de reposição |
Status e Datas
| Campo | Tipo | Descrição |
|---|---|---|
status | enum | active | blocked | expired | discontinued |
expirationDate | Date? | Data de validade do lote |
manufactureDate | Date? | Data de fabricação |
validUntil | Date? | Válido até (uso alternativo) |
imageUrl | string? | URL da imagem do produto |
Fornecedor
| Campo | Tipo | Descrição |
|---|---|---|
supplier | string? | Nome do fornecedor (legacy — use schema Supplier) |
supplierCode | string? | Código do produto no fornecedor (cProd da NF-e) |
notes | string? | Observações livres |
Índices:
{ sku, tenant }— unique{ barcode, tenant }{ tenant, status }
Importação de NFS (XML de Fornecedor)
Como usar
- Acesse Estoque no menu lateral
- Clique em Importar NFS (canto superior direito)
- Selecione o arquivo
.xmlda nota fiscal do fornecedor - 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: 12O que acontece por trás
Para cada <det> (produto) na NF-e:
- Busca
InventoryItemporcProd(código do produto) ouxProd(nome) - Se não encontrado → cria novo item com NCM, unidade e preço do XML
- Registra
InventoryMovement(type=entry, reason=purchase, quantity=qCom, unitCost=vUnCom) - Atualiza
physicalQuantityeavailableQuantity
Backend
backend/src/inventory/nfs-import.service.ts
Alertas de Validade
A aba Alertas exibe itens com vencimento próximo ou vencidos:
| Cor | Significado |
|---|---|
| Vermelho | Vencidos (já passaram da expirationDate) |
| Laranja | Vencem em até 7 dias |
| Amarelo | Vencem 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
| Campo | Tipo | Descrição |
|---|---|---|
token | string (unique) | UUID gerado por crypto.randomUUID() |
item | ObjectId → InventoryItem | Item que será baixado |
quantity | number (min 0.001) | Quantidade a deduzir |
reason | string | loss | theft | expiration | adjustment |
notes | string? | Observação livre |
status | string | pending (inicial) | used (após scan) |
usedAt | Date? | Momento do consumo |
tenant | ObjectId → Tenant | Isolamento 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
| Arquivo | Papel |
|---|---|
frontend-react/src/components/inventory/LabelGeneratorModal.tsx | Modal: form + preview com react-qr-code + print via @media print |
frontend-react/src/components/inventory/InventoryItemsPanel.tsx | Botão "Etiqueta" por linha (mobile + desktop) |
frontend-react/src/views/ScanLabelView.tsx | Pá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/*).
// 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
| Campo | Tipo | Descrição |
|---|---|---|
item | ObjectId → InventoryItem | Item do catálogo central |
pdv | ObjectId → Pdv | Terminal/filial |
physicalQuantity | number (default 0) | Quantidade física no PDV |
availableQuantity | number (default 0) | Disponível para venda no PDV |
minQuantity | number? | 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
| Campo | Tipo | Descrição |
|---|---|---|
name | string (required) | Nome/razão social |
cnpj | string? | CNPJ (único sparse por tenant) |
email | string? | E-mail de contato |
phone | string? | Telefone |
contactName | string? | Nome do contato |
address | string? | Endereço completo |
categories | string[] | Categorias de produtos fornecidos |
notes | string? | Observações |
active | boolean (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étodo | Rota | Descrição |
|---|---|---|
GET | /t/:slug/inventory/suppliers?active=true | Listar fornecedores |
GET | /t/:slug/inventory/suppliers/:id | Detalhe |
POST | /t/:slug/inventory/suppliers | Criar |
PUT | /t/:slug/inventory/suppliers/:id | Atualizar |
DELETE | /t/:slug/inventory/suppliers/:id | Remover |
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
| Campo | Tipo | Descrição |
|---|---|---|
orderNumber | string (auto) | Formato PO-YYYYMMDD-NNNN |
supplier | ObjectId → Supplier | Fornecedor |
items | PurchaseOrderItem[] | Itens do pedido |
totalCost | number | Total calculado pelo servidor |
status | enum | draft | sent | partial | received | cancelled |
expectedDelivery | Date? | Previsão de entrega |
receivedAt | Date? | Data de recebimento efetivo |
receivedBy | ObjectId? → User | Usuário que recebeu |
Sub-documento PurchaseOrderItem:
| Campo | Tipo | Descrição |
|---|---|---|
inventoryItem | ObjectId → InventoryItem | Item |
itemName | string | Nome (denormalizado) |
quantity | number | Quantidade solicitada |
unitCost | number | Custo unitário |
subtotal | number | quantity × unitCost (calculado) |
Fluxo de status:
draft → sent → partial → received
↓
cancelledRecebimento de itens: POST /purchase-orders/:id/receive
Para cada item recebido:
- Cria
InventoryMovement(type:entry, reason:purchase) - Incrementa
physicalQuantityeavailableQuantitydoInventoryItem - Status muda para
partial(se quantidade parcial) oureceived
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
| Campo | Tipo | Descrição |
|---|---|---|
item | ObjectId → InventoryItem | Item com estoque baixo |
pdv | ObjectId? → Pdv | PDV afetado (para alertas de filial) |
availableQuantity | number | Quantidade disponível no momento do alerta |
minQuantity | number | Limiar configurado |
itemName | string? | Nome (denormalizado) |
pdvName | string? | Nome do PDV (denormalizado) |
readBy | ObjectId[] | Usuários que já viram o alerta |
expiresAt | Date | TTL de 7 dias — MongoDB remove automaticamente |
Dois gatilhos:
InventoryService— após qualquercreateMovement()no catálogo masterBranchStockService— 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:
const socket = io(VITE_API_URL, { auth: { token: jwt } });
// namespace: /inventoryAuth: JWT obrigatório. Roles aceitas: staff, admin, superadmin. Conexões não autorizadas são desconectadas imediatamente.
Evento: joinInventory (cliente → servidor)
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:
{
"itemId": "64a1b2c3d4e5f6789",
"itemName": "Pão de hambúrguer",
"physicalQuantity": 45,
"availableQuantity": 40
}Estoque de filial:
{
"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:
{
"_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
| Campo | Tipo | Descrição |
|---|---|---|
menuItem | ObjectId | Item de cardápio |
inventoryItem | ObjectId | Ingrediente do estoque |
quantityPerUnit | number | Qtd do ingrediente por 1 unidade do prato (mín. 0.001) |
unit | string? | Rótulo informativo (ex: "kg", "un") |
tenant | ObjectId | Tenant |
Í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/:idModo 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:
- Acesse Cardápio → Itens
- Edite o item desejado
- Selecione o Item de Estoque no formulário
- 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ãoCancelamento 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
findOneAndUpdateatômico por documento + compensação de aplicação em caso de falha concorrente.
Prioridade de Resolução
| Situação | Comportamento |
|---|---|
Tem ProductIngredient | Usa ficha técnica (todos os ingredientes) |
Sem ficha, mas tem MenuItem.inventoryItem | Deduz 1 unidade do item vinculado |
| Nenhum vínculo | Sem rastreamento — pedido prossegue normalmente |
availableQuantity < necessário | Dedução best-effort; saldo pode ficar negativo |