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
| Arquivo | Responsabilidade |
|---|---|
services/sale/sale-draft/sale-draft-persistence/persistence.sale-draft.service.ts | saveDraft, grava o rascunho localmente (SQLite) e decide se delega para split |
services/sale/sale-draft/sale-draft-save/save.sale-draft.service.ts | saveAndSend, fila de envio, retry, rollback em caso de falha parcial |
services/sale/sale-draft/adapters/sale-draft-send.adapter.ts | Converte SaleDraftRecord para o payload de OrderHeader esperado pela API |
storage/sale/sale-draft-send-queue.storage.ts | Fila local (MMKV) de envios pendentes/com falha |
services/sale/failed-sale-draft-auto-send/failed-sale-draft-auto-send.service.ts | Poller em background que retenta envios que falharam por conexão |
features/new-sale/new-sale.tsx | handleSaveDraft(finalize), ponto de entrada da UI |
Salvar localmente (saveDraft)
Antes de qualquer envio, SaleDraftPersistenceService.saveDraft grava o estado atual no SQLite:
startOrResumeDraftgarante que a sessão existe e os defaults do cabeçalho foram aplicados.validateHeaderBeforeSavelanç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.- Exige cliente resolvido (
getClientData), lançando"Toda venda precisa de cliente."se não houver. - Se existirem tipos de operação separados, delega para
saveDraftWithSplit(ver Separação por Tipo de Operação); senão grava um únicoSaleDraftRecordcom 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 emsetTimeout(0). Por isso, salvar ou finalizar sempre parece instantâneo, mesmo sem internet. resolvePersistedDraftGroupverifica se o rascunho salvo temvinculoId. 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
OrderHeaderpróprio, mas se houver mais de um,linkRemoteOrderHeadersfaz um PUT cruzado depois que todos foram criados, preenchendoidOrderHeadersde 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 falha | draft.log (mensagem visível ao usuário) | Retentado automaticamente? |
|---|---|---|
"connection" | Limpo (""), já que não é um erro do pedido em si | Sim, pelo poller de auto-retry |
"business" | Preenchido com a mensagem de erro da API | Nã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 seufinalizadorebaixado parafalseremotamente, 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.logestá vazio. - Em pedidos divididos, com múltiplos
OrderHeaderporvinculoId, 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 ofinalizadorebaixado. processPendingQueueé serializado porthis.processingPromise. Não chame em paralelo esperando comportamento concorrente: uma segunda chamada enquanto a primeira roda apenas reaproveita a mesma promise.