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.