Skip to content

NF-e Entrada — Manifestação e Importação para Estoque

Visão Geral

O módulo de NF-e Entrada cobre dois fluxos complementares:

  1. Manifestação do Destinatário — consulta e confirmação de NF-es recebidas via SEFAZ/PlugNotas/Arquivei (módulo inbound-nfe)
  2. 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:

  1. Registra a entrada de estoque convertendo unidades de compra → unidades de estoque
  2. Calcula o custo médio ponderado por unidade, incluindo rateio de frete
  3. Cria uma Conta a Pagar na data de competência correta (para o DRE)
  4. Aprende o mapeamento fornecedor→produto para importações futuras (sem intervenção)
  5. Dispara o recálculo das Fichas Técnicas (BOM) dos pratos afetados

Localização (Frontend)

TelaRotaComponente
Lista de NF-es/t/:slug/admin/inbound-nfefeatures/inbound-nfe/index.tsx
Importar NF-e/t/:slug/admin/inbound-nfe/:id/importviews/admin/NfeImportView.tsx
Ledger de Impacto/t/:slug/admin/inbound-nfe/sessions/:sessionId/ledgerviews/admin/NfeImportLedgerView.tsx

Parte 1 — Manifestação do Destinatário

Status das NF-es

StatusCódigo SEFAZDescrição
pendingBaixada, ainda não manifestada
ciencia210210Ciência da Operação — confirma recebimento
confirmada210200Confirmação — libera botão "Importar"
desconhecida210220Desconhecimento (com justificativa)
nao_realizada210240Operação Não Realizada (com justificativa)

Eventos de Manifestação

EventoQuando usarJustificativa
CiênciaNota recebida, mercadoria ainda não chegouNão obrigatória
ConfirmaçãoMercadoria chegou e confereNão obrigatória
DesconhecimentoNão reconhece a operaçãoObrigatória (≥ 15 chars)
Não RealizadaMercadoria recusada ou devolvidaObrigató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 recalculado

Mapeamento 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 vezcProd desconhecido → fuzzy matching (score 50–89% = Conflict UI; ≥90% = auto)
Após resolverSupplierProductMap criado — próxima NF-e: score=100, zero revisão
Variação de preçolastSeenPrice comparado — alerta se Δ > 5% no Ledger
Variação de conversãoNovo conversionFactor → needsReview=true para confirmação

Algoritmo Jaro-Winkler

Score composto de 3 sinais (total 0–100):

SinalPesoDescrição
NCM exato40 ptsPré-filtra candidatos pelo NCM da nota
Jaro-Winkler0–50 ptsSimilaridade textual do nome (melhor para prefixo — "COCA COLA 350ML")
Histórico fornecedor10 ptsFornecedor já importou esse item antes

Thresholds:

ScoreAção
≥ 90Auto-aceito silenciosamente
50–89Conflict Resolution UI (sugestão pré-selecionada)
< 50Conflict 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 / UN

Roteamento de Destino

Primário (por CNPJ):

XML cnpjDestinatario → Branch.cnpj
  Encontrado → stock vai para essa filial
  Não encontrado → filial HQ + routingWarning no Ledger

Secundário (por categoria):

InventoryItem.category → RoutingRule.targetLocation
  Ex: category='Perecíveis' → 'Câmara Fria'
      category='Bebidas Alcoólicas' → 'Adega'
Prioridade: lower = maior prioridade

Schema RoutingRule:

{ tenant, branch, category, targetLocation, priority }
Índice único: { tenant, branch, category }

Validação Fiscal

VerificaçãoCondição de alerta
Integridade do total`
CFOP de saída em entradaCFOP iniciado com 5 ou 6
CFOP de devoluçãoCFOP 1.201 ou 2.201 → inverte movimento para Exit/Return
NCM inválidoNã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.amount negativo (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) ou received (se completo)
  • LedgerEntry.poDelta exibe: PO #XXX — 7/10 itens recebidos

Schemas

ImportSession (TTL 24h)

typescript
{
  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):

typescript
{
  nfeItemIndex, supplierCode, supplierDescription, ncm, cfop,
  qtyInvoiced, supplierUnit, effectiveCost, costPerSupplierUnit,
  matchScore (0100), 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étodoRotaDescrição
GET/t/:slug/inbound-nfeListar NF-es paginado
POST/t/:slug/inbound-nfe/syncSincronizar com provider
POST/t/:slug/inbound-nfe/:id/manifestManifestar evento
GET/t/:slug/inbound-nfe/:id/xmlURL do XML no S3
GET/t/:slug/inbound-nfe/accounts-payableListar Contas a Pagar
POST/t/:slug/inbound-nfe/accounts-payableCriar AP manual
PUT/t/:slug/inbound-nfe/accounts-payable/:id/payRegistrar pagamento

Pipeline de Importação (módulo nfe-import)

MétodoRotaDescrição
POST/t/:slug/inbound-nfe/import-xmlUpload manual de XML → ImportSessionResult
GET/t/:slug/inbound-nfe/sessions/:idBuscar sessão de importação
PATCH/t/:slug/inbound-nfe/sessions/:id/resolveSubmeter resoluções de conflito
POST/t/:slug/inbound-nfe/sessions/:id/applyAplicar sessão pronta ao estoque
GET/t/:slug/inbound-nfe/sessions/:id/ledgerLedger de impacto pós-aplicação

Regras de Roteamento e Mapas de Fornecedor

MétodoRotaDescrição
GET/t/:slug/inventory/routing-rulesListar regras de roteamento
POST/t/:slug/inventory/routing-rulesCriar regra
PUT/t/:slug/inventory/routing-rules/:idAtualizar regra
DELETE/t/:slug/inventory/routing-rules/:idRemover regra
GET/t/:slug/inventory/supplier-mapsVer mapeamentos aprendidos
DELETE/t/:slug/inventory/supplier-maps/:idRemover mapeamento (força re-aprendizado)

Guards: JwtAuthGuard → TenantGuard → PermissionsGuard(inventory.*)


BullMQ — Fila Automática

Fila: nfe-import-auto

ConfiguraçãoValor
Concorrência2 workers
Tentativas3× exponencial (30s → 5min → 30min)
ExaustãoInboundNfe.status = 'desconhecida' + badge de revisão
ProducerChamado via confirmAndProcess() no InboundNfeService
WorkerNfeImportWorkerProcessorNfeImportPipelineService.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.ts
  • backend/src/inbound-nfe/inbound-nfe.service.ts
  • backend/src/inbound-nfe/inbound-nfe.controller.ts
  • backend/src/inbound-nfe/schemas/inbound-nfe.schema.ts ← +3 campos
  • backend/src/inbound-nfe/schemas/accounts-payable.schema.ts

Módulo nfe-import (novo)

  • backend/src/nfe-import/nfe-import.module.ts
  • backend/src/nfe-import/nfe-import-pipeline.service.ts
  • backend/src/nfe-import/nfe-import-fuzzy.service.ts
  • backend/src/nfe-import/nfe-import-validator.service.ts
  • backend/src/nfe-import/nfe-import-router.service.ts
  • backend/src/nfe-import/nfe-import-session.service.ts
  • backend/src/nfe-import/nfe-import-worker.processor.ts
  • backend/src/nfe-import/nfe-import.controller.ts
  • backend/src/nfe-import/schemas/supplier-product-map.schema.ts
  • backend/src/nfe-import/schemas/import-session.schema.ts
  • backend/src/nfe-import/schemas/routing-rule.schema.ts
  • backend/src/nfe-import/dto/resolve-conflicts.dto.ts
  • backend/src/nfe-import/dto/create-routing-rule.dto.ts

Riscos e Mitigações

RiscoMitigação
NF-e duplicada{ nfeKey, tenant } unique + inventoryProcessed flag
Falha parcial no applylastAppliedItemIndex → resume idempotente
CNPJ não encontrado em nenhuma filialFallback para HQ + routingWarning
conversionFactor desconhecidoDefault 1 + needsReview=true; min:1 via validação
Colisão cross-tenant de CNPJBranch query sempre com { tenant }
Upload simultâneo do mesmo XMLAtomic upsert no InboundNfe bloqueia o segundo
Nota de devolução (CFOP 1.201)Detectado no Passo 3; movimento Exit + AP negativo
NCM/CFOP inconsistenteBadge no Ledger — não bloqueia
Recebimento parcial de POreceiveItems() para itens entregues → PO→partial

Variáveis de Ambiente

env
INBOUND_NFE_PROVIDER=sefaz_direct   # plugnotas | arquivei | sefaz_direct
PLUGNOTAS_TOKEN=...
ARQUIVEI_CLIENT_ID=...
ARQUIVEI_API_KEY=...

Relacionados

Lançado sob a licença MIT.