Pular para o conteúdo principal

Lista, Busca e Filtros

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

Visão Geral

A carteira de clientes é totalmente offline-first: busca, letra, filtros e paginação são resolvidos direto no SQLite local, sem chamada de rede. A skill do projeto é explícita sobre isso e pede justificativa de produto antes de trocar esse caminho por busca remota. O motor de texto é uma tabela virtual FTS5, mantida sincronizada automaticamente por triggers, e a montagem da query combina até quatro fontes de filtro diferentes numa única consulta.

Arquivos-chave

ArquivoResponsabilidade
repositories/client/client.repository.tsMonta e executa a query de busca, FTS5, filtros e paginação
database/migrations/019_client_search_performance.tsCria a tabela virtual client_search e os triggers que a mantêm sincronizada
services/client/client.service.tssearchAndList/listAllWithCoordinates, ponte entre controller e repositório
features/client/client.controller.tsEstado de busca/filtros/paginação e chamada ao service

A tabela virtual de busca

client_search é criada como uma tabela FTS5, com uma coluna por escopo de busca, e um tokenizador que já remove acentos no nível do SQLite:

CREATE VIRTUAL TABLE client_search
USING fts5(
clientId UNINDEXED,
codigo,
fantasia,
razao,
geral,
tokenize = 'unicode61 remove_diacritics 2'
);

Ela nunca é preenchida manualmente pelo código da aplicação. Três triggers no SQLite cuidam disso automaticamente sempre que a tabela client é inserida, atualizada ou apagada:

CREATE TRIGGER IF NOT EXISTS trg_client_search_ai
AFTER INSERT ON client
BEGIN
INSERT INTO client_search (clientId, codigo, fantasia, razao, geral)
VALUES (NEW.id, /* ... campos concatenados ... */);
END;

CREATE TRIGGER IF NOT EXISTS trg_client_search_au
AFTER UPDATE ON client
BEGIN
DELETE FROM client_search WHERE clientId = OLD.id;
INSERT INTO client_search (clientId, codigo, fantasia, razao, geral)
VALUES (NEW.id, /* ... */);
END;

CREATE TRIGGER IF NOT EXISTS trg_client_search_ad
AFTER DELETE ON client
BEGIN
DELETE FROM client_search WHERE clientId = OLD.id;
END;

Isso significa que qualquer novo caminho de escrita na tabela client, incluindo o saveBatch usado pela sincronização, mantém a busca atualizada de graça, sem precisar reindexar nada manualmente.

Como um termo digitado se transforma numa consulta FTS5

Antes de montar a consulta, cada termo passa por uma normalização em JavaScript, que remove acentos e caracteres que não sejam letra ou número, e transforma em minúsculas:

// client.repository.ts
private normalizeFtsTerm(term: string): string {
return term
.normalize("NFD")
.replace(/[̀-ͯ]/g, "")
.replace(/[^\p{L}\p{N}]+/gu, " ")
.trim()
.toLocaleLowerCase();
}

Depois, cada termo normalizado recebe um * no final, transformando a busca numa busca por prefixo em cada palavra:

private buildFtsMatchQuery(rawSearch?: string): string | null {
const terms = (rawSearch ?? "")
.split(/\s+/)
.map((term) => this.normalizeFtsTerm(term))
.filter(Boolean);

if (terms.length === 0) return null;

return terms.map((term) => `${term}*`).join(" ");
}

Buscar "joao silv", por exemplo, se transforma em joao* silv*, que o FTS5 interpreta como "contém um termo que comece com joao E um termo que comece com silv".

Quatro modos de busca, escolhidos pelo searchFilter

O campo de busca pode operar em quatro escopos diferentes, escolhidos pelo usuário na UI através das opções "Todos", "Código", "Nome Fantasia" e "Razão Social", e cada um monta uma cláusula SQL diferente:

  • codigoCliente: casa o idExterno exato, por prefixo, e também via FTS5 na coluna codigo, tudo numa cláusula OR.
  • nomeFantasia: só consulta a coluna fantasia da tabela FTS5.
  • razaoSocial: só consulta a coluna razao.
  • todos (padrão): consulta a coluna geral, que reúne todos os campos buscáveis do cliente, e ainda tenta detectar se o termo digitado é um CPF, CNPJ ou código externo válido antes de cair na busca textual geral.

Detecção de CPF, CNPJ e código externo

Quando o modo de busca é o geral, o repositório tenta primeiro reconhecer o termo digitado como um identificador único, antes de tratá-lo como texto livre:

private buildUniqueIdentifierCondition(rawSearch?: string) {
const digitsOnly = normalizedSearch.replace(/\D/g, "");
// ...
if (digitsOnly.length === 11 && isValidCpf(digitsOnly)) {
const formattedCpf = formatDocumento(digitsOnly, "cpf");
conditions.push(`c.cpf = ?`, `c.cpf = ?`);
args.push(digitsOnly, formattedCpf);
}

if (digitsOnly.length === 14 && isValidCnpj(digitsOnly)) {
const formattedCnpj = formatDocumento(digitsOnly, "cnpj");
// ... mesma lógica para CNPJ
}
// ...
}

Isso permite que o vendedor digite um CPF ou CNPJ com ou sem máscara e ainda encontre o cliente, já que a busca compara contra as duas formas, com e sem formatação.

Ordenação por relevância

Quando o usuário escolhe ordenar por relevância, e nenhum identificador único foi detectado no termo digitado, o repositório faz um INNER JOIN contra uma subconsulta que usa a função bm25 do próprio FTS5 para ranquear os resultados:

if (params.sortBy === "relevancia" && !uniqueIdentifier) {
return {
kind: "join",
joinSql: `INNER JOIN (SELECT clientId, bm25(client_search) AS rank FROM client_search WHERE geral MATCH ?) AS search_match ON search_match.clientId = c.id`,
args: [ftsQuery],
relevanceOrderByClause: "search_match.rank ASC",
};
}

Se um identificador único foi detectado, a ordenação por relevância é ignorada silenciosamente, e a consulta cai de volta na ordenação padrão por nome. Não existe combinação de "busca por CPF" com "ordenar por relevância" no código atual.

Busca por letra

Independente do texto livre, a carteira também suporta filtrar pela primeira letra do nome (o carrossel de letras da UI). Essa busca é sempre um LIKE por prefixo, sensível ao mesmo searchFilter da busca textual, e não se aplica quando o filtro é codigoCliente:

if (params.searchFilter === "nomeFantasia") {
return { clause: `c.nomeFantasia LIKE ? COLLATE NOCASE`, args: [`${normalizedLetter}%`] };
}

Filtros de checkbox: status, vendas, financeiro e localização

Além da busca textual, a tela de filtros da carteira oferece checkboxes agrupados por categoria, que se traduzem em condições SQL sobre colunas booleanas do cliente:

// client.repository.ts
buildWhereFromCheckOptions(checkOptions: CheckOptionsState) {
const where: Where<ClientColumns> = {};

const ativo = checkOptions.status.Ativo;
const inativo = checkOptions.status.Inativo;
if (ativo && inativo) {
// ambos marcados = sem filtro de status
} else if (ativo) {
where.ativo = "S";
} else if (inativo) {
where.ativo = "N";
}

if (checkOptions.sale.NuncaComprou) where.nuncaComprou = "S";
if (checkOptions.sale.PositivadoHoje) where.positivadoHoje = "S";
if (checkOptions.financial.Inadimplente) where.inadimplente = "S";
if (checkOptions.financial.TitulosEmAberto) where.titulosEmAberto = { not: 0 };
// ...
}

Marcar "Ativo" e "Inativo" ao mesmo tempo não filtra nada, o que é o comportamento correto: mostrar os dois estados é equivalente a não restringir por status. Os checkboxes do grupo "Geral" (Todos, Código, Nome Fantasia, Razão Social) não entram nessa função, porque eles não são um filtro adicional, eles decidem o searchFilter usado na busca textual descrita acima.

Filtros de localização (cidade, bairro, região) são resolvidos separadamente, aceitando tanto o nome quanto o código numérico do local selecionado, e combinados com os demais filtros por AND. As listas de cidades, bairros e regiões disponíveis para esses filtros ficam em cache por 5 minutos (LOCATION_CACHE_TTL_MS), evitando reconsultar o banco a cada vez que o modal de filtros abre.

Paginação

O controller pede páginas de PAGE_SIZE = 10 itens, e o repositório aplica isso como LIMIT/OFFSET na query principal, junto com uma segunda query de contagem total executada em paralelo:

// client.controller.ts
const response = await service.searchAndList({
search, letterSearch: letter, page, size: PAGE_SIZE,
sortBy: "nomeFantasia", checkOptions: selectedFilters,
});

Cada chamada de busca carrega um número de geração (requestGeneration.current), e qualquer resposta cuja geração não seja mais a atual é descartada. Esse é o mesmo padrão de proteção contra corrida usado no carrinho de venda: se o usuário digitar rápido ou trocar de filtro antes de uma busca anterior responder, o resultado antigo não sobrescreve o novo por acidente.

A aba Mapa usa listAllWithCoordinates, que aplica os mesmos filtros de busca e checkbox, mas sem paginação, já que precisa de todos os clientes com coordenadas de uma vez para montar os clusters (ver Mapa de Clientes).

Armadilhas conhecidas

ClientService tem um conjunto de métodos privados de montagem de where (buildSearchWhere, buildLetterSearchWhere, buildLocationFiltersWhere, buildUniqueIdentifierWhere e afins) que não são chamados por nenhum outro método da classe. O caminho realmente usado por searchAndList/listAllWithCoordinates delega direto para os métodos equivalentes do ClientRepository, descritos neste documento. Antes de estender a lógica de busca, confirme em qual dos dois lugares a mudança precisa entrar, para não editar um caminho morto sem efeito nenhum.