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
| Arquivo | Responsabilidade |
|---|---|
services/sale/sale-draft/sale-draft-cart-core/cart-core.sale-draft.service.ts | Núcleo: addOrUpdateItemByQuantity, removeItem, clearCart, syncDraftCartSnapshot |
services/sale/sale-draft/sale-draft-quantity/quantity.sale-draft.service.ts | setQuantityInProduct (com validação), bulkAdjustCartItemQuantities |
services/sale/sale-draft/sale-draft-shared/sale-draft-cart-events.ts | Pub/sub: notifySaleDraftCartUpdated / subscribeSaleDraftCartUpdates |
services/sale/sale-draft/sale-draft-shared/sale-draft.helpers.ts | toDeterministicItemId, chave determinística do item |
repositories/sale-draft-cart/sale-draft-cart.repository.ts | Persistência SQLite dos itens |
hooks/new-sale/useSaleDraftCart.ts | Hook que lista/pagina o carrinho e se inscreve nos eventos de atualização |
features/new-sale/new-sale-cart/new-sale-cart.tsx + .controller.ts | Tela 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 viacartCore. 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
SaleDraftCartRepositoryfora deSaleDraftCartCoreService/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 comquantidade: 0persistido. bulkAdjustCartItemQuantitiesassume que o preço unitário não muda. Não é substituto deaddOrUpdateItemByQuantityquando o preço também precisa ser recalculado.- Eventos de carrinho são filtrados por
saleDraftIdno lado do assinante. Ao criar um novo listener, sempre compareevent.saleDraftIdantes de reagir.