Pular para o conteúdo principal

Carrinho e Itens

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

Visão Geral

O carrinho é a lista de itens (SaleDraftItem) de um rascunho de venda, persistida em SQLite. Toda a lógica de adicionar, atualizar quantidade/preço e remover item passa por SaleDraftCartCoreService (services/sale/sale-draft/sale-draft-cart-core/cart-core.sale-draft.service.ts). Nenhuma tela ou hook escreve diretamente no repositório do carrinho.

Esta é a regra de negócio mais citada na skill do projeto: toda alteração de item deve terminar sincronizando o snapshot do carrinho e notificando os ouvintes, senão totais, badges e validações ficam desatualizados sem erro visível.

Arquivos-chave

ArquivoResponsabilidade
services/sale/sale-draft/sale-draft-cart-core/cart-core.sale-draft.service.tsNúcleo: addOrUpdateItemByQuantity, removeItem, clearCart, syncDraftCartSnapshot
services/sale/sale-draft/sale-draft-quantity/quantity.sale-draft.service.tssetQuantityInProduct (com validação), bulkAdjustCartItemQuantities
services/sale/sale-draft/sale-draft-shared/sale-draft-cart-events.tsPub/sub: notifySaleDraftCartUpdated / subscribeSaleDraftCartUpdates
services/sale/sale-draft/sale-draft-shared/sale-draft.helpers.tstoDeterministicItemId, chave determinística do item
repositories/sale-draft-cart/sale-draft-cart.repository.tsPersistência SQLite dos itens
hooks/new-sale/useSaleDraftCart.tsHook que lista/pagina o carrinho e se inscreve nos eventos de atualização
features/new-sale/new-sale-cart/new-sale-cart.tsx + .controller.tsTela e controller da aba Carrinho

Identidade de um item

Um item do carrinho não é identificado só pelo produto: a chave de negócio completa é produto, sequencia e tipo de operação ativo (codTipOper). Isso é o que permite o mesmo produto aparecer em "sub-carrinhos" diferentes quando o pedido está separado por tipo de operação (ver Separação por Tipo de Operação).

O id do item é determinístico, não um UUID aleatório. Ele é derivado da própria chave de negócio:

// sale-draft.helpers.ts
export function toDeterministicItemId(params: {
saleDraftId: string;
produtoId: string;
produtoIdExterno: string;
sequencia: number;
codTipOper?: number | null;
}): string {
return [
"sale_item",
params.saleDraftId,
params.produtoId,
params.produtoIdExterno,
String(params.sequencia),
String(params.codTipOper ?? "null"),
].join(":");
}

Isso garante que adicionar o mesmo produto duas vezes no mesmo contexto sempre resulte num upsert, que atualiza o item existente, nunca em item duplicado, mesmo sem round-trip a um servidor para gerar ID.

Fluxo de adicionar/atualizar item

Trecho real do núcleo: a sequência upsert → sync snapshot → notify → propagate não pode ser reordenada nem ter passos pulados:

// cart-core.sale-draft.service.ts
if (!removedProduct) {
await this.cartRepo.upsertItem(nextItem);
await this.syncDraftCartSnapshot(nextItem.saleDraftId);
notifySaleDraftCartUpdated({
saleDraftId: nextItem.saleDraftId,
type: "upsert",
item: nextItem,
});
await this.propagateItemUpsert(nextItem.saleDraftId, nextItem);
}

Quantidade zero remove o item

addOrUpdateItemByQuantity não tem um caminho separado para "remover" vindo da tela de quantidade. Setar a quantidade para 0 ou menos já é tratado como remoção, com a mesma sequência de sync, notify e propagação, só que com type: "remove":

if (quantidade <= 0) {
await this.cartRepo.deleteItemByBusinessKey({ ...chaveDoItem });
await this.syncDraftCartSnapshot(input.saleDraftId);
notifySaleDraftCartUpdated({
saleDraftId: input.saleDraftId,
type: "remove",
produtoId: input.produtoId,
produtoIdExterno: input.produtoIdExterno,
sequencia: input.sequencia,
});
await this.propagateItemRemoval(input.saleDraftId, { ... });
removedProduct = true;
}

Para remoção explícita (ex.: botão de excluir no card do item), existe removeItem, que segue o mesmo padrão.

Snapshot do rascunho

syncDraftCartSnapshot recalcula e grava no registro do rascunho, não no item, a contagem de itens e o valor total. É esse snapshot que a lista de rascunhos salvos (SaleDraftRecord.itemsCount / totalValue) exibe, sem precisar reler todos os itens:

async syncDraftCartSnapshot(saleDraftId: string): Promise<void> {
await this.draftRepo.ensureDraftExists(saleDraftId);
const [itemsCount, totals] = await Promise.all([
this.cartRepo.countByDraft(saleDraftId),
this.cartRepo.getTotalsByDraft(saleDraftId),
]);
await this.draftRepo.updateCartSnapshot({
saleDraftId,
itemsCount,
totalValue: totals.totalLiquido,
});
}

Eventos de carrinho (pub/sub)

sale-draft-cart-events.ts implementa um pub/sub simples em memória, sem Redux e sem Context API, para desacoplar quem muda o carrinho de quem precisa reagir à mudança, seja a lista do carrinho, os badges de produto, os totais ou a validação:

export type SaleDraftCartEvent =
| { saleDraftId: string; type: "upsert"; item: SaleDraftItem }
| { saleDraftId: string; type: "remove"; produtoId: string; produtoIdExterno: string; sequencia: number }
| { saleDraftId: string; type: "clear" };

const saleDraftCartListeners = new Set<SaleDraftCartListener>();

export function notifySaleDraftCartUpdated(event: SaleDraftCartEvent): void {
saleDraftCartListeners.forEach((listener) => listener(event));
}

export function subscribeSaleDraftCartUpdates(
listener: SaleDraftCartListener,
): () => void {
saleDraftCartListeners.add(listener);
return () => saleDraftCartListeners.delete(listener);
}

useSaleDraftCart (hook usado pela tela do carrinho) se inscreve nesse evento e recarrega a página atual com debounce, filtrando pelo saleDraftId do próprio hook. Assim, múltiplos rascunhos abertos em paralelo, como os separados por tipo de operação, não disparam refresh cruzado entre si:

useEffect(() => {
return subscribeSaleDraftCartUpdates((event) => {
if (event.saleDraftId !== saleDraftId) return;
refreshDebounced();
});
}, [refreshDebounced, saleDraftId]);

Se você adicionar um novo jeito de alterar um item do carrinho, ele precisa terminar chamando notifySaleDraftCartUpdated, direta ou indiretamente via cartCore. Sem isso, a UI mostra dados desatualizados sem lançar nenhum erro.

Ajuste de quantidade em massa

bulkAdjustCartItemQuantities é um caminho separado, usado quando várias quantidades mudam de uma vez, como numa atualização de estoque do carrinho. Ele não recalcula preço do zero: mantém o preço unitário fixo e só re-escala vlrTotal/vlrDesc/vlrTotTab proporcionalmente à nova quantidade, disparando um único evento de atualização no final, não um por item:

/**
* Adjusts the quantities of multiple cart items at once without a full
* price recalculation. Unit prices are kept fixed; vlrTotal, vlrDesc and
* vlrTotTab are rescaled proportionally to the new quantity.
*
* After all upserts the cart snapshot is synced and a single cart-updated
* event is fired so all subscribers (totals, validation badge, etc.) refresh.
*/
async bulkAdjustCartItemQuantities(params: {
saleDraftId: string;
adjustments: Array<{ item: SaleDraftItem; newQuantity: number }>;
}): Promise<void> {
// ...
await this.syncDraftCartSnapshot(params.saleDraftId);
notifySaleDraftCartUpdated({
saleDraftId: params.saleDraftId,
type: "upsert",
item: params.adjustments[params.adjustments.length - 1].item,
});
}

Validação ao alterar quantidade

setQuantityInProduct valida o produto, verificando estoque e quantidade mínima, antes de chamar o núcleo do carrinho, a menos que disabledValidated seja passado. Esse parâmetro é usado em fluxos que já validaram antes, como a importação em lote:

if (!params?.disabledValidated) {
const productValidate = await validationSaleDraftService.validateProduct({
product: params.product,
validations: {
validateMinQuantity: true,
validateStock: true,
validateAddition: false,
validateDiscount: false,
},
});
params.product = productValidate;
}

Detalhes de validação de estoque/desconto estão em Validação Comercial.

Armadilhas conhecidas

  • Nunca escreva direto em SaleDraftCartRepository fora de SaleDraftCartCoreService/SaleDraftQuantityService. A sequência upsert, sync, notify e propagação para drafts vinculados precisa acontecer sempre junto.
  • Quantidade <= 0 é remoção, não um estado de item "zerado". Não existe item com quantidade: 0 persistido.
  • bulkAdjustCartItemQuantities assume que o preço unitário não muda. Não é substituto de addOrUpdateItemByQuantity quando o preço também precisa ser recalculado.
  • Eventos de carrinho são filtrados por saleDraftId no lado do assinante. Ao criar um novo listener, sempre compare event.saleDraftId antes de reagir.