Pular para o conteúdo principal

Separação por Tipo de Operação

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

Visão Geral

Um pedido pode precisar ser dividido em múltiplos pedidos por tipo de operação (codTipOper). Por exemplo, parte dos itens pode ir como venda normal e parte como bonificação ou troca, com cada tipo virando um pedido separado no fim. Essa é considerada, na skill do projeto, uma das regras de negócio mais importantes e mais fáceis de quebrar por acidente.

O mecanismo funciona em duas fases distintas, que é o ponto que mais gera confusão:

  1. Durante a montagem do pedido (antes de salvar): não existem múltiplos saleDraftId ainda. É um único rascunho, com itens marcados internamente por codTipOper, e o app alterna qual "sub-carrinho" está sendo editado.
  2. Ao salvar (saveDraftsaveDraftWithSplit): o rascunho único é materializado em múltiplos registros (SaleDraftRecord), um principal e um por tipo separado, todos ligados por um vinculoId compartilhado. Só depois desse ponto a propagação entre "drafts vinculados" passa a fazer sentido.

Arquivos-chave

ArquivoResponsabilidade
services/sale/sale-draft/sale-draft-split/split.sale-draft.service.tsToda a lógica de separação: ativação de tipo, split ao salvar, propagação entre drafts vinculados
services/sale/sale-draft/sale-draft-persistence/persistence.sale-draft.service.tssaveDraft decide entre salvar normal ou delegar para saveDraftWithSplit
storage/sale/sale-draft.storage.tsPersistência local (fora do SQLite) do tipo ativo e da lista de tipos separados por rascunho
features/new-sale/components/new-sale-movement-type-bottom-sheet/new-sale-movement-type-bottom-sheet.controller.tsUI: escolher/trocar/remover tipo de operação, decidir se separa

Fase 1 — Enquanto o pedido está sendo montado

Cada rascunho tem, em storage local (não SQLite), dois estados:

  • Tipo de operação ativo (getActiveDraftOpType/setActiveDraftOpType): qual "sub-carrinho" está sendo visualizado/editado agora. null = carrinho principal.
  • Tipos separados (getSeparatedDraftOpTypes/setSeparatedDraftOpTypes): lista de tipos que o usuário decidiu tratar como pedidos separados.

Itens do carrinho já carregam codTipOper desde a inclusão, resolvido em addOrUpdateItemByQuantity e detalhado em Carrinho e Itens. É esse campo, junto com produto e sequência, que forma a chave de negócio do item. Trocar o tipo de operação ativo não move itens entre carrinhos: cada item já "pertence" ao tipo com que foi criado, e a tela de carrinho filtra por codTipOper ativo.

Perguntar antes de separar

A troca de tipo de operação no cabeçalho só pergunta "Deseja separar o carrinho desta venda por tipo de operação?" quando o usuário está trocando um tipo já preenchido por outro diferente. Seleção inicial ou reseleção do mesmo tipo já separado não pergunta nada:

// new-sale-movement-type-bottom-sheet.controller.ts
const isChangingExistingValue =
movementTypeValue.trim() !== "" &&
normalized !== movementTypeValue.trim();

if (!isChangingExistingValue) {
applyChange(false); // aplica sem separar
return;
}

const alreadySeparated = separatedOpTypes.some((op) => op.value === normalized);
if (alreadySeparated) {
applyChange(true); // já é um sub-carrinho conhecido, só troca o ativo
return;
}

appAlert.show({
title: "Separar venda por tipo de operação?",
options: [
{ label: "Não", onPress: () => applyChange(false) },
{ label: "Sim", onPress: () => applyChange(true) },
],
});

Respondendo "Sim", o tipo é adicionado à lista de separados e passa a ser o ativo:

saleDraftService.setActiveOpType(saleDraftId, normalized);
saleDraftService.addToSeparatedOpTypes(saleDraftId, {
id: String(selected.raw.id ?? normalized),
label: selected.label,
value: normalized,
});

Removendo um tipo separado

removeSeparatedOpType tem dois caminhos: se o tipo ainda não tem itens persistidos como número válido de codTipOper, só remove da lista local; se já tem, limpa de fato os itens daquele tipo no carrinho antes de remover:

async removeSeparatedOpType(saleDraftId: string, value: string): Promise<void> {
const numericValue = Number(value);

if (!Number.isFinite(numericValue)) {
removeSeparatedDraftOpType(saleDraftId, value);
if (getActiveDraftOpType(saleDraftId) === value) {
setActiveDraftOpType(saleDraftId, null);
}
notifySaleDraftCartUpdated({ saleDraftId, type: "clear" });
return;
}

await this.cartRepo.clearItemsByDraftAndOpType(saleDraftId, numericValue);
removeSeparatedDraftOpType(saleDraftId, value);
if (getActiveDraftOpType(saleDraftId) === value) {
setActiveDraftOpType(saleDraftId, null);
}
await this.syncDraftCartSnapshot(saleDraftId);
notifySaleDraftCartUpdated({ saleDraftId, type: "clear" });
}

A UI (new-sale-movement-type-bottom-sheet.controller.ts) sempre confirma com o usuário antes de chamar isso, avisando que os itens daquele tipo serão removidos da venda.

Fase 2 — Split ao salvar (saveDraftWithSplit)

SaleDraftPersistenceService.saveDraft decide o caminho olhando a lista de tipos separados:

// persistence.sale-draft.service.ts
const separatedOpTypes = getSeparatedDraftOpTypes(saleDraftId);
const existing = await this.draftRepo.findById(saleDraftId);

if (separatedOpTypes.length > 0) {
return this.saveDraftWithSplit({
saleDraftId,
headerData,
client,
separatedOpTypes,
existingRecord: existing,
createdAt: existing?.createdAt ?? session.createdAt,
});
}
// ... caminho normal (sem split) segue abaixo

saveDraftWithSplit (em split.sale-draft.service.ts):

  1. Reaproveita o vinculoId existente se já houver um (edição de um pedido já separado antes), ou cria um novo.
  2. Apaga sub-rascunhos "órfãos" de um split anterior que não correspondam mais aos tipos atuais.
  3. Monta o registro principal com os itens de codTipOper IS NULL (o carrinho "normal").
  4. Para cada tipo separado com itens, cria um SaleDraftRecord novo (saleDraftId próprio, gerado por createSaleDraftId()), com seus próprios itens (ids recalculados para o novo saleDraftId), todos compartilhando o mesmo vinculoId.
const vinculoId = params.existingRecord?.vinculoId ?? createSaleDraftId();

// ...um sub-registro por tipo de operação separado:
for (const opType of params.separatedOpTypes) {
const codTipOperNum = Number(opType.value);
if (Number.isNaN(codTipOperNum)) continue;

const subItemsPage = await this.cartRepo.findManyByDraft({
saleDraftId: params.saleDraftId,
page: 1,
size: 999999,
codTipOper: codTipOperNum,
});
if (subItemsPage.total === 0) continue;

const subSaleDraftId = createSaleDraftId();
// ... monta subRecord com vinculoId igual ao principal
await this.draftRepo.upsert(subRecord);
await this.cartRepo.upsertItemsBatch(subItems);
}

Se um tipo separado não tiver nenhum item (subItemsPage.total === 0), ele simplesmente não gera sub-registro: não fica um pedido vazio salvo.

Propagação entre drafts vinculados (depois do split)

Uma vez que existam múltiplos SaleDraftRecord compartilhando vinculoId, qualquer alteração de item em um deles precisa refletir nos outros que também tenham aquele mesmo item, ou seja, a mesma chave de negócio de produto e sequência. É isso que propagateItemUpsertToLinkedDrafts e propagateItemRemovalToLinkedDrafts fazem, chamados automaticamente pelo núcleo do carrinho (SaleDraftCartCoreService, como visto em Carrinho e Itens) a cada upsert ou remoção:

async propagateItemUpsertToLinkedDrafts(
saleDraftId: string,
updatedItem: SaleDraftItem,
): Promise<void> {
const draft = await this.draftRepo.findById(saleDraftId);
if (!draft?.vinculoId) return; // sem vinculoId, não há nada a propagar

const linkedDrafts = await this.draftRepo.findByVinculoId(draft.vinculoId);

for (const linked of linkedDrafts) {
if (linked.saleDraftId === saleDraftId) continue;

const existingItem = await this.cartRepo.findByBusinessKey({
saleDraftId: linked.saleDraftId,
produtoId: updatedItem.produtoId,
produtoIdExterno: updatedItem.produtoIdExterno,
sequencia: updatedItem.sequencia,
codTipOper: updatedItem.codTipOper,
});

if (!existingItem) continue; // só propaga se o item já existir no draft vinculado

// ... upsert no draft vinculado + sync snapshot + notify
}
}

Ponto importante: a propagação só atualiza itens que já existem no draft vinculado (if (!existingItem) continue). Ela não adiciona um item novo a um sub-draft que nunca teve aquele produto. Isso significa que a propagação serve para manter dados como preço e quantidade sincronizados entre os pedaços de um pedido já dividido, não para "espalhar" um item novo para todos os tipos automaticamente.

Como este método só age quando draft.vinculoId existe, durante a Fase 1, antes do primeiro save com split, a propagação é essencialmente um no-op, já que o rascunho ainda não tem vinculoId. Cada upsert de item permanece isolado no único rascunho até o split acontecer no save.

Grupo de drafts vinculados

getLinkedDraftGroup retorna todos os registros de um mesmo vinculoId, com o rascunho consultado sempre priorizado no início da lista. É usado por telas que precisam listar ou exportar todos os pedaços de um pedido dividido, como o compartilhamento, detalhado em Compartilhamento e Importação:

async getLinkedDraftGroup(saleDraftId: string): Promise<SaleDraftRecord[]> {
const draft = await this.draftRepo.findById(saleDraftId);
if (!draft) throw new Error("Rascunho não encontrado.");
if (!draft.vinculoId) return [draft];
const linkedDrafts = await this.draftRepo.findByVinculoId(draft.vinculoId);
return sortDraftGroup(
linkedDrafts.length > 0 ? linkedDrafts : [draft],
saleDraftId,
);
}

Armadilhas conhecidas (checklist da skill do projeto)

  • Preservar vinculoId ao editar um pedido já dividido. Reaproveite o existente (params.existingRecord?.vinculoId) e nunca gere um novo por engano.
  • Preservar a propagação entre drafts vinculados. Qualquer novo caminho de alteração de item precisa continuar chamando propagateItemUpsertToLinkedDrafts/propagateItemRemovalToLinkedDrafts, via SaleDraftCartCoreService, nunca direto.
  • Preservar ids determinísticos dos itens. Ao recriar itens em um sub-registro, o id é recalculado com toDeterministicItemId usando o saleDraftId do sub-registro, nunca reaproveitado do rascunho original.
  • Um tipo de operação separado sem itens não gera sub-registro. Não assuma que toda entrada em "tipos separados" corresponde a um SaleDraftRecord salvo.
  • Propagação não cria itens em drafts vinculados. Só atualiza os que já existem lá.