Pular para o conteúdo principal

Salvar e Enviar

Última atualização: 14 de julho de 2026

Visão Geral

"Salvar rascunho" e "Finalizar venda" são a mesma operação técnica com uma flag diferente (finalize: boolean). Ambas persistem local e tentam enviar ao servidor. A diferença de negócio é que finalize: false grava o pedido no ERP como rascunho, enquanto finalize: true, além de gravar, também dispara o envio para o ERP, o Sankhya. O app é offline-first: salvar ou enviar nunca bloqueia a navegação esperando resposta de rede. O envio real acontece em uma fila de fundo com retry automático.

Arquivos-chave

ArquivoResponsabilidade
services/sale/sale-draft/sale-draft-persistence/persistence.sale-draft.service.tssaveDraft, grava o rascunho localmente (SQLite) e decide se delega para split
services/sale/sale-draft/sale-draft-save/save.sale-draft.service.tssaveAndSend, fila de envio, retry, rollback em caso de falha parcial
services/sale/sale-draft/adapters/sale-draft-send.adapter.tsConverte SaleDraftRecord para o payload de OrderHeader esperado pela API
storage/sale/sale-draft-send-queue.storage.tsFila local (MMKV) de envios pendentes/com falha
services/sale/failed-sale-draft-auto-send/failed-sale-draft-auto-send.service.tsPoller em background que retenta envios que falharam por conexão
features/new-sale/new-sale.tsxhandleSaveDraft(finalize), ponto de entrada da UI

Salvar localmente (saveDraft)

Antes de qualquer envio, SaleDraftPersistenceService.saveDraft grava o estado atual no SQLite:

  1. startOrResumeDraft garante que a sessão existe e os defaults do cabeçalho foram aplicados.
  2. validateHeaderBeforeSave lança erro se algum campo obrigatório do cabeçalho estiver vazio, como visto em Cabeçalho da Venda. Esse erro sobe até new-sale.tsx, que mostra um toast e nem chega a abrir o bottom sheet de opções de envio.
  3. Exige cliente resolvido (getClientData), lançando "Toda venda precisa de cliente." se não houver.
  4. Se existirem tipos de operação separados, delega para saveDraftWithSplit (ver Separação por Tipo de Operação); senão grava um único SaleDraftRecord com os itens e totais atuais.

Salvar e enviar (saveAndSend)

Esse é o caminho usado pela UI. A tela chama saveSaleDraftService.saveAndSend, não saveDraft direto:

// new-sale.tsx
await saveSaleDraftService.saveAndSend({
saleDraftId,
finalize,
onNavigateToSales: () => controller.handleSubmitSaleOption("Rascunho"),
onSuccess: (message) => toast.show(message, "success", 2500, "top"),
});

Pontos importantes desse fluxo:

  • A navegação de volta para a lista de vendas acontece antes do envio de rede terminar. onNavigateToSales?.() é chamado logo depois de enfileirar, e o envio real roda em setTimeout(0). Por isso, salvar ou finalizar sempre parece instantâneo, mesmo sem internet.
  • resolvePersistedDraftGroup verifica se o rascunho salvo tem vinculoId. Se tiver, ou seja, se for um pedido dividido por tipo de operação, todos os pedaços vinculados são enviados juntos, na mesma operação de fila.
  • Cada pedaço do grupo é enviado como um OrderHeader próprio, mas se houver mais de um, linkRemoteOrderHeaders faz um PUT cruzado depois que todos foram criados, preenchendo idOrderHeaders de cada um com os ids dos outros. Assim, o ERP sabe que fazem parte do mesmo pedido original dividido.

Fila de envio e retry

A fila (storage/sale/sale-draft-send-queue.storage.ts) é persistida em MMKV, com cada item tendo status "pending" | "syncing" | "failed". processPendingQueue processa um item pendente por vez, em loop, até a fila não ter mais nenhum "pending":

async processPendingQueue(...): Promise<void> {
resetSyncingSaleDraftSendQueueItems(); // itens presos em "syncing" (ex.: app fechado no meio) voltam a "pending"

if (this.processingPromise) return this.processingPromise; // evita duas execuções concorrentes

this.processingPromise = (async () => {
while (true) {
const nextItem = getSaleDraftSendQueue().find((item) => item.status === "pending");
if (!nextItem) return;

const group = await this.resolvePersistedDraftGroup(nextItem.saleDraftId, nextItem.finalize);
markSaleDraftSendsSyncing(group.saleDraftIds);

const outcome = await this.sendPersistedDraftGroup(group.drafts, group.finalize);

if (!outcome.ok) {
markSaleDraftSendsFailed({ saleDraftIds: group.saleDraftIds, errorMessage: outcome.message });
continue; // segue para o próximo pendente — não trava a fila num item com falha
}

await this.saleDraftService.clearDraft(group.saleDraftIds[0] ?? nextItem.saleDraftId);
removeSaleDraftSendQueueItems(group.saleDraftIds);
}
})().finally(() => { this.processingPromise = null; });

return this.processingPromise;
}

Um item que falha não é reenviado automaticamente no mesmo ciclo: ele fica marcado "failed" e o loop segue para o próximo "pending". Só volta a ser tentado através do poller de retry, descrito a seguir, ou de uma nova chamada manual de saveAndSend/send.

Dois tipos de falha, dois comportamentos

function getSendFailureKind(error: unknown): SendFailureKind {
const apiError = toApiError(error);
if (!apiError.response || apiError.response.status === 0) {
return "connection"; // sem resposta HTTP nenhuma → problema de rede/conectividade
}
return "business"; // API respondeu com erro (validação, regra de negócio, etc.)
}
Tipo de falhadraft.log (mensagem visível ao usuário)Retentado automaticamente?
"connection"Limpo (""), já que não é um erro do pedido em siSim, pelo poller de auto-retry
"business"Preenchido com a mensagem de erro da APINão, precisa de ação do usuário

Isso é intencional: um erro de negócio (ex.: cliente bloqueado, campo obrigatório rejeitado pelo ERP) não deve ficar tentando de novo sem que o usuário veja e corrija algo; um erro de conectividade deve.

Rollback em falha parcial de um pedido dividido

Ao enviar um grupo com mais de um OrderHeader (pedido dividido), se algo falhar no meio:

  • Headers criados via POST nesse envio são deletados remotamente (rollbackRemoteHeaders), para evitar ficar com pedidos parciais órfãos no ERP.
  • Headers que já existiam e foram só atualizados via PUT (edição de pedido já enviado) nunca são deletados em rollback.
  • Se a falha foi especificamente no envio ao ERP (sendOrderHeaderToErp, ou seja o "completo"/cadastro já teve sucesso), os headers editados via PUT têm seu finalizado rebaixado para false remotamente, para permitir uma nova tentativa a partir do estado de rascunho.

Retry automático de falhas de conexão

FailedSaleDraftAutoSendService é um poller que roda em background (fora do fluxo de tela de venda) verificando periodicamente se há itens da fila que falharam por conexão:

async pollOnce(): Promise<number> {
if (this.isPolling || syncEntityMonitorServiceInstance.isSyncInProgress()) return 0;

const organizationId = getCurrentOrganizationId();
if (!organizationId) return 0;

const connection = await internetService.checkConnection();
if (!connection.apiAvailable) return 0;

return saveSaleDraftService.processFailedConnectionQueue({ organizationId, ... });
}

O intervalo é configurável pela setting cfg-tempvenauto (em segundos), com mínimo absoluto de 5 segundos e padrão de 30 segundos caso a setting não esteja definida:

const seconds = settings.get("cfg-tempvenauto");
const intervalMs = Math.max(MIN_INTERVAL_MS, (seconds || DEFAULT_INTERVAL_SECONDS) * 1000);

processFailedConnectionQueue só re-enfileira itens que pertencem à organização atual e têm log vazio, ou seja, cuja última falha foi de conexão, não de negócio:

const isCurrentOrganization = draft.organizationId === organizationId;
const isConnectionFailure = !String(draft.log ?? "").trim();
if (!isCurrentOrganization || !isConnectionFailure) continue;

Armadilhas conhecidas

  • Nunca assuma que "salvar/finalizar" bloqueou até confirmar no servidor. A UI navega de volta antes do envio de rede terminar, e o resultado real chega via toast (onSuccess/onError) ou pelo log do rascunho na listagem.
  • Um erro de negócio, como uma validação rejeitada pelo ERP, não é retentado automaticamente. Só o poller de falha de conexão faz retry, e só quando draft.log está vazio.
  • Em pedidos divididos, com múltiplos OrderHeader por vinculoId, nunca faça rollback de um header que foi só atualizado via PUT. Apagar um pedido pré-existente do cliente é um efeito muito mais destrutivo do que deixar o finalizado rebaixado.
  • processPendingQueue é serializado por this.processingPromise. Não chame em paralelo esperando comportamento concorrente: uma segunda chamada enquanto a primeira roda apenas reaproveita a mesma promise.