NF-e Entrada — Manifestação e Importação para Estoque
Visão Geral
O módulo de NF-e Entrada cobre dois fluxos complementares:
- Manifestação do Destinatário — consulta e confirmação de NF-es recebidas via SEFAZ/PlugNotas/Arquivei (módulo
inbound-nfe) - Pipeline de Importação — processamento inteligente da NF-e confirmada para o estoque, com mapeamento de fornecedor aprendido, conversão de UoM e roteamento por filial (módulo
nfe-import)
Ao confirmar uma NF-e, o sistema automaticamente:
- Registra a entrada de estoque convertendo unidades de compra → unidades de estoque
- Calcula o custo médio ponderado por unidade, incluindo rateio de frete
- Cria uma Conta a Pagar na data de competência correta (para o DRE)
- Aprende o mapeamento fornecedor→produto para importações futuras (sem intervenção)
- Dispara o recálculo das Fichas Técnicas (BOM) dos pratos afetados
Localização (Frontend)
| Tela | Rota | Componente |
|---|---|---|
| Lista de NF-es | /t/:slug/admin/inbound-nfe | features/inbound-nfe/index.tsx |
| Importar NF-e | /t/:slug/admin/inbound-nfe/:id/import | views/admin/NfeImportView.tsx |
| Ledger de Impacto | /t/:slug/admin/inbound-nfe/sessions/:sessionId/ledger | views/admin/NfeImportLedgerView.tsx |
Parte 1 — Manifestação do Destinatário
Status das NF-es
| Status | Código SEFAZ | Descrição |
|---|---|---|
pending | — | Baixada, ainda não manifestada |
ciencia | 210210 | Ciência da Operação — confirma recebimento |
confirmada | 210200 | Confirmação — libera botão "Importar" |
desconhecida | 210220 | Desconhecimento (com justificativa) |
nao_realizada | 210240 | Operação Não Realizada (com justificativa) |
Eventos de Manifestação
| Evento | Quando usar | Justificativa |
|---|---|---|
| Ciência | Nota recebida, mercadoria ainda não chegou | Não obrigatória |
| Confirmação | Mercadoria chegou e confere | Não obrigatória |
| Desconhecimento | Não reconhece a operação | Obrigatória (≥ 15 chars) |
| Não Realizada | Mercadoria recusada ou devolvida | Obrigatória (≥ 15 chars) |
Somente a Confirmação libera o botão "Importar". A importação para estoque é um passo separado e deliberado.
Parte 2 — Pipeline de Importação (nfe-import)
Fluxo de 11 Passos
Todos os passos 1–8 são somente-leitura (sem escrita no banco). As escritas ocorrem apenas no Passo 10.
XML / InboundNfe confirmada
↓
[1] PARSE XML
Extrai: nfeKey, emitterCnpj, cnpjDestinatario,
vTotal, vFrete, vDesc, vOutro
Por item: cProd, description, ncm, cfop, unit, qty, vTotal
↓
[2] DUPLICATE CHECK
InboundNfe { nfeKey, tenant }
inventoryProcessed=true → ABORT "já importada"
status=nao_realizada → ABORT "nota cancelada"
↓
[3] FISCAL VALIDATION
calculado = Σ(vItem) + vFrete + vOutro - vDesc
|calculado - vTotalXml| > R$ 0,01 → WARNING
CFOP 5.xxx/6.xxx em nota de entrada → FLAG
CFOP 1.201/2.201 → marca item.isReturn = true (nota de devolução)
NCM: deve ter 8 dígitos não-zeros
↓
[4] BRANCH ROUTING
Branch { cnpj: cnpjDestinatario, tenant }
Não encontrado → Filial HQ + routingWarning
↓
[5] SUPPLIER RESOLUTION
findOrCreate Supplier { cnpj: emitterCnpj, tenant }
↓
[6] COST DISTRIBUTION (rateio de frete proporcional)
freightRatio = vFrete / Σ(vItem)
por item: effectiveCost = vItem × (1 + freightRatio)
costPerSupplierUnit = effectiveCost / qty
↓
[7] PRODUCT MATCHING (em paralelo por item)
A. EXATO: SupplierProductMap { tenant, supplier, supplierCode: cProd }
→ score=100, needsReview=false
B. FUZZY: candidatos = InventoryItem { ncm: item.ncm, tenant }
score = ncmBonus(40)
+ jaroWinkler(name, description) × 50
+ supplierHistory(10)
≥90 → fuzzy-auto, needsReview=false
50–89 → fuzzy, needsReview=true (top 3 sugestões)
<50 → new, needsReview=true (em branco)
PO MATCH: tenta vincular ao PurchaseOrder aberto do fornecedor
↓
[8] WAREHOUSE SUB-ROUTING
RoutingRule { tenant, branch, category: item.category }
→ item.targetLocation
↓
[9] CREATE ImportSession (TTL 24h)
ANY needsReview=true → status='conflict'
senão → status='ready'
modo=auto + conflito → marca InboundNfe, para; notifica via badge
modo=manual → retorna para o frontend
↓
[USUÁRIO RESOLVE CONFLITOS — se houver]
PATCH /sessions/:id/resolve → status='ready'
↓
[10] APPLY IMPORT (registra lastAppliedItemIndex para resume idempotente)
Por item (não pulado):
isReturn=false → movimento Entry/Purchase
isReturn=true → movimento Exit/Return
newAvgCost = weightedAverage(qty×custo_atual, qty_entrada×custo_unitario)
InventoryItem $inc physicalQuantity + availableQuantity
InventoryItem $set costPrice = newAvgCost
upsert SupplierProductMap (aprende ou atualiza lastSeenPrice)
se PO vinculado → receiveItems() → PO status→partial/received
Cria AccountsPayable (isReturn → amount negativo)
InboundNfe.inventoryProcessed = true
BomService.recalculateAllForIngredients() [async, fire-and-forget]
Popula ImportSession.ledger[]
↓
[11] REVIEW LEDGER
Retorna LedgerEntry[] com qty antes/depois, ΔcustoMédio,
alertas de variação de preço (>5%), badges de BOM recalculadoMapeamento Inteligente de Produtos (SupplierProductMap)
O sistema aprende e evolui a cada importação, eliminando revisões manuais repetidas:
SupplierProductMap {
tenant, supplier, inventoryItem,
supplierCode: string // cProd da NF-e — chave do aprendizado
supplierUnit: string // ex. 'CX', 'DZ', 'UN'
conversionFactor: number // 1 supplierUnit = N unidades de estoque (min: 1)
lastSeenPrice: number // custo por supplierUnit na última NF-e
lastSeenPriceAt: Date
confirmedAt: Date | null // null = auto-aprendido (≥90%); Date = confirmado pelo usuário
}
Índice único: { tenant, supplier, supplierCode }Ciclo de aprendizado:
| Primeira vez | cProd desconhecido → fuzzy matching (score 50–89% = Conflict UI; ≥90% = auto) |
|---|---|
| Após resolver | SupplierProductMap criado — próxima NF-e: score=100, zero revisão |
| Variação de preço | lastSeenPrice comparado — alerta se Δ > 5% no Ledger |
| Variação de conversão | Novo conversionFactor → needsReview=true para confirmação |
Algoritmo Jaro-Winkler
Score composto de 3 sinais (total 0–100):
| Sinal | Peso | Descrição |
|---|---|---|
| NCM exato | 40 pts | Pré-filtra candidatos pelo NCM da nota |
| Jaro-Winkler | 0–50 pts | Similaridade textual do nome (melhor para prefixo — "COCA COLA 350ML") |
| Histórico fornecedor | 10 pts | Fornecedor já importou esse item antes |
Thresholds:
| Score | Ação |
|---|---|
| ≥ 90 | Auto-aceito silenciosamente |
| 50–89 | Conflict Resolution UI (sugestão pré-selecionada) |
| < 50 | Conflict Resolution UI (em branco — usuário mapeia) |
Conversão de UoM
O sistema resolve o problema "Compra em Caixas, Vende em Unidades":
SupplierProductMap.conversionFactor = 24
↓
NF-e: 10 CX × R$ 48,00 → efetiveCost = R$ 480,00
qtyToStock = 10 × 24 = 240 UN
costPerStockUnit = R$ 480,00 / 240 = R$ 2,00 / UN
Rateio de frete (ex: vFrete = R$ 48,00, Σitens = R$ 480,00):
freightRatio = 48/480 = 0,10
effectiveCost item = R$ 480,00 × 1,10 = R$ 528,00
costPerStockUnit real = R$ 528,00 / 240 = R$ 2,20 / UNRoteamento de Destino
Primário (por CNPJ):
XML cnpjDestinatario → Branch.cnpj
Encontrado → stock vai para essa filial
Não encontrado → filial HQ + routingWarning no LedgerSecundário (por categoria):
InventoryItem.category → RoutingRule.targetLocation
Ex: category='Perecíveis' → 'Câmara Fria'
category='Bebidas Alcoólicas' → 'Adega'
Prioridade: lower = maior prioridadeSchema RoutingRule:
{ tenant, branch, category, targetLocation, priority }
Índice único: { tenant, branch, category }Validação Fiscal
| Verificação | Condição de alerta |
|---|---|
| Integridade do total | ` |
| CFOP de saída em entrada | CFOP iniciado com 5 ou 6 |
| CFOP de devolução | CFOP 1.201 ou 2.201 → inverte movimento para Exit/Return |
| NCM inválido | Não-numérico, ≠ 8 dígitos, ou 00000000 |
Alertas aparecem no banner da tela de importação. Nenhum bloqueia a importação — todos são avisos com badge âmbar no Ledger.
Notas de Devolução (CFOP 1.201/2.201)
Detectadas no Passo 3. Para cada item marcado como devolução:
- Movimento criado com
type: 'exit', reason: 'return'(reduz estoque) AccountsPayable.amountnegativo (nota de débito)
Recebimento Parcial de Pedido de Compra
O Passo 7 tenta vincular a NF-e a um PurchaseOrder aberto do mesmo fornecedor:
- Items recebidos →
PurchaseOrderService.receiveItems()para esses itens - PO transita para
partial(se restam itens) oureceived(se completo) LedgerEntry.poDeltaexibe:PO #XXX — 7/10 itens recebidos
Schemas
ImportSession (TTL 24h)
{
tenant, inboundNfe, branch?,
mode: 'manual' | 'auto',
status: 'pending' | 'conflict' | 'ready' | 'applied' | 'failed',
fiscalValidation: {
totalMatch, totalXml, totalCalculated, variance,
cfopIssues: [{ itemIndex, cfop, message }],
ncmIssues: [{ itemIndex, ncm, message }]
},
routingWarnings: string[],
items: [ImportSessionItem],
ledger: [LedgerEntry],
lastAppliedItemIndex: number?, // para resume idempotente
expiresAt: Date // TTL 24h via index MongoDB
}ImportSessionItem (embedded):
{
nfeItemIndex, supplierCode, supplierDescription, ncm, cfop,
qtyInvoiced, supplierUnit, effectiveCost, costPerSupplierUnit,
matchScore (0–100), matchStrategy ('exact'|'learned'|'fuzzy-auto'|'fuzzy'|'new'),
inventoryItem?, conversionFactor, qtyToStock, stockUnit, costPerStockUnit,
targetLocation?,
needsReview: boolean,
suggestedItems: [{ inventoryItemId, score, name, ncm }],
userResolution: 'accept'|'remap'|'skip'|null,
isReturn: boolean
}API Endpoints
Manifestação (módulo inbound-nfe)
| Método | Rota | Descrição |
|---|---|---|
GET | /t/:slug/inbound-nfe | Listar NF-es paginado |
POST | /t/:slug/inbound-nfe/sync | Sincronizar com provider |
POST | /t/:slug/inbound-nfe/:id/manifest | Manifestar evento |
GET | /t/:slug/inbound-nfe/:id/xml | URL do XML no S3 |
GET | /t/:slug/inbound-nfe/accounts-payable | Listar Contas a Pagar |
POST | /t/:slug/inbound-nfe/accounts-payable | Criar AP manual |
PUT | /t/:slug/inbound-nfe/accounts-payable/:id/pay | Registrar pagamento |
Pipeline de Importação (módulo nfe-import)
| Método | Rota | Descrição |
|---|---|---|
POST | /t/:slug/inbound-nfe/import-xml | Upload manual de XML → ImportSessionResult |
GET | /t/:slug/inbound-nfe/sessions/:id | Buscar sessão de importação |
PATCH | /t/:slug/inbound-nfe/sessions/:id/resolve | Submeter resoluções de conflito |
POST | /t/:slug/inbound-nfe/sessions/:id/apply | Aplicar sessão pronta ao estoque |
GET | /t/:slug/inbound-nfe/sessions/:id/ledger | Ledger de impacto pós-aplicação |
Regras de Roteamento e Mapas de Fornecedor
| Método | Rota | Descrição |
|---|---|---|
GET | /t/:slug/inventory/routing-rules | Listar regras de roteamento |
POST | /t/:slug/inventory/routing-rules | Criar regra |
PUT | /t/:slug/inventory/routing-rules/:id | Atualizar regra |
DELETE | /t/:slug/inventory/routing-rules/:id | Remover regra |
GET | /t/:slug/inventory/supplier-maps | Ver mapeamentos aprendidos |
DELETE | /t/:slug/inventory/supplier-maps/:id | Remover mapeamento (força re-aprendizado) |
Guards: JwtAuthGuard → TenantGuard → PermissionsGuard(inventory.*)
BullMQ — Fila Automática
Fila: nfe-import-auto
| Configuração | Valor |
|---|---|
| Concorrência | 2 workers |
| Tentativas | 3× exponencial (30s → 5min → 30min) |
| Exaustão | InboundNfe.status = 'desconhecida' + badge de revisão |
| Producer | Chamado via confirmAndProcess() no InboundNfeService |
| Worker | NfeImportWorkerProcessor → NfeImportPipelineService.run(nfeId, 'auto') |
Migração segura: confirmAndProcess() injeta NfeImportPipelineService via @Optional(). Se o módulo não estiver disponível, o fluxo legado continua como fallback.
Backend — Arquivos
Módulo inbound-nfe (existente)
backend/src/inbound-nfe/inbound-nfe.module.tsbackend/src/inbound-nfe/inbound-nfe.service.tsbackend/src/inbound-nfe/inbound-nfe.controller.tsbackend/src/inbound-nfe/schemas/inbound-nfe.schema.ts← +3 camposbackend/src/inbound-nfe/schemas/accounts-payable.schema.ts
Módulo nfe-import (novo)
backend/src/nfe-import/nfe-import.module.tsbackend/src/nfe-import/nfe-import-pipeline.service.tsbackend/src/nfe-import/nfe-import-fuzzy.service.tsbackend/src/nfe-import/nfe-import-validator.service.tsbackend/src/nfe-import/nfe-import-router.service.tsbackend/src/nfe-import/nfe-import-session.service.tsbackend/src/nfe-import/nfe-import-worker.processor.tsbackend/src/nfe-import/nfe-import.controller.tsbackend/src/nfe-import/schemas/supplier-product-map.schema.tsbackend/src/nfe-import/schemas/import-session.schema.tsbackend/src/nfe-import/schemas/routing-rule.schema.tsbackend/src/nfe-import/dto/resolve-conflicts.dto.tsbackend/src/nfe-import/dto/create-routing-rule.dto.ts
Riscos e Mitigações
| Risco | Mitigação |
|---|---|
| NF-e duplicada | { nfeKey, tenant } unique + inventoryProcessed flag |
| Falha parcial no apply | lastAppliedItemIndex → resume idempotente |
| CNPJ não encontrado em nenhuma filial | Fallback para HQ + routingWarning |
conversionFactor desconhecido | Default 1 + needsReview=true; min:1 via validação |
| Colisão cross-tenant de CNPJ | Branch query sempre com { tenant } |
| Upload simultâneo do mesmo XML | Atomic upsert no InboundNfe bloqueia o segundo |
| Nota de devolução (CFOP 1.201) | Detectado no Passo 3; movimento Exit + AP negativo |
| NCM/CFOP inconsistente | Badge no Ledger — não bloqueia |
| Recebimento parcial de PO | receiveItems() para itens entregues → PO→partial |
Variáveis de Ambiente
INBOUND_NFE_PROVIDER=sefaz_direct # plugnotas | arquivei | sefaz_direct
PLUGNOTAS_TOKEN=...
ARQUIVEI_CLIENT_ID=...
ARQUIVEI_API_KEY=...