feat: implement advanced filtering and reporting features for pedidos, including status selection, date range filtering, and export options for PDF and XLSX formats

This commit is contained in:
2025-12-15 15:37:57 -03:00
parent c7b4ea15bd
commit fd2669aa4f
5 changed files with 1342 additions and 44 deletions

View File

@@ -7,6 +7,151 @@ import { getCurrentUserFunction } from './auth';
// ========== HELPERS ==========
const pedidoStatusValidator = v.union(
v.literal('em_rascunho'),
v.literal('aguardando_aceite'),
v.literal('em_analise'),
v.literal('precisa_ajustes'),
v.literal('cancelado'),
v.literal('concluido')
);
type PedidoStatus = Doc<'pedidos'>['status'];
type PedidoListFilters = {
statuses?: PedidoStatus[];
numeroSei?: string;
criadoPor?: Id<'usuarios'>;
aceitoPor?: Id<'funcionarios'>;
periodoInicio?: number;
periodoFim?: number;
};
function inRange(ts: number, inicio?: number, fim?: number): boolean {
if (inicio !== undefined && ts < inicio) return false;
if (fim !== undefined && ts > fim) return false;
return true;
}
function matchesPeriodo(p: Doc<'pedidos'>, inicio?: number, fim?: number): boolean {
if (inicio === undefined && fim === undefined) return true;
if (inRange(p.criadoEm, inicio, fim)) return true;
if (p.concluidoEm !== undefined && inRange(p.concluidoEm, inicio, fim)) return true;
return false;
}
function normalizeNumeroSeiQuery(q: string | undefined): string | undefined {
const trimmed = q?.trim();
return trimmed ? trimmed.toLowerCase() : undefined;
}
function applyPedidoFilters(pedidos: Doc<'pedidos'>[], args: PedidoListFilters): Doc<'pedidos'>[] {
const numeroSeiQuery = normalizeNumeroSeiQuery(args.numeroSei);
const statusesSet =
args.statuses && args.statuses.length > 0 ? new Set<PedidoStatus>(args.statuses) : null;
const filtered = pedidos.filter((p) => {
if (statusesSet && !statusesSet.has(p.status)) return false;
if (args.criadoPor && p.criadoPor !== args.criadoPor) return false;
if (args.aceitoPor && p.aceitoPor !== args.aceitoPor) return false;
if (!matchesPeriodo(p, args.periodoInicio, args.periodoFim)) return false;
if (numeroSeiQuery) {
const sei = (p.numeroSei ?? '').toLowerCase();
if (!sei.includes(numeroSeiQuery)) return false;
}
return true;
});
// Ordem mais útil para listagem: mais recentes primeiro
filtered.sort((a, b) => b.criadoEm - a.criadoEm);
return filtered;
}
function dedupePedidos(pedidos: Doc<'pedidos'>[]): Doc<'pedidos'>[] {
const map = new Map<string, Doc<'pedidos'>>();
for (const p of pedidos) map.set(String(p._id), p);
return [...map.values()];
}
async function fetchPedidosBase(ctx: QueryCtx, args: PedidoListFilters): Promise<Doc<'pedidos'>[]> {
// 1) Se há período, buscamos por índices de criadoEm e concluidoEm e unimos (OR)
if (args.periodoInicio !== undefined || args.periodoFim !== undefined) {
const inicio = args.periodoInicio;
const fim = args.periodoFim;
const byCriado =
inicio !== undefined && fim !== undefined
? await ctx.db
.query('pedidos')
.withIndex('by_criadoEm', (q) => q.gte('criadoEm', inicio).lte('criadoEm', fim))
.collect()
: inicio !== undefined
? await ctx.db
.query('pedidos')
.withIndex('by_criadoEm', (q) => q.gte('criadoEm', inicio))
.collect()
: await ctx.db
.query('pedidos')
.withIndex('by_criadoEm', (q) => q.lte('criadoEm', fim as number))
.collect();
const byConcluido =
inicio !== undefined && fim !== undefined
? await ctx.db
.query('pedidos')
.withIndex('by_concluidoEm', (q) =>
q.gte('concluidoEm', inicio).lte('concluidoEm', fim)
)
.collect()
: inicio !== undefined
? await ctx.db
.query('pedidos')
.withIndex('by_concluidoEm', (q) => q.gte('concluidoEm', inicio))
.collect()
: await ctx.db
.query('pedidos')
.withIndex('by_concluidoEm', (q) => q.lte('concluidoEm', fim as number))
.collect();
return dedupePedidos(byCriado.concat(byConcluido));
}
// 2) Se há statuses selecionados, usar o índice by_status
if (args.statuses && args.statuses.length > 0) {
let out: Doc<'pedidos'>[] = [];
for (const status of args.statuses) {
const part = await ctx.db
.query('pedidos')
.withIndex('by_status', (q) => q.eq('status', status))
.collect();
out = out.concat(part);
}
return dedupePedidos(out);
}
// 3) Se há criador, usar by_criadoPor
if (args.criadoPor) {
return await ctx.db
.query('pedidos')
.withIndex('by_criadoPor', (q) => q.eq('criadoPor', args.criadoPor!))
.collect();
}
// 4) Se há aceitoPor, usar by_aceitoPor
if (args.aceitoPor) {
return await ctx.db
.query('pedidos')
.withIndex('by_aceitoPor', (q) => q.eq('aceitoPor', args.aceitoPor!))
.collect();
}
// 5) fallback: varrer (caso sem índices úteis)
return await ctx.db.query('pedidos').collect();
}
async function getUsuarioAutenticado(ctx: QueryCtx | MutationCtx) {
const user = await getCurrentUserFunction(ctx);
if (!user) throw new Error('Unauthorized');
@@ -105,30 +250,55 @@ async function assertPodeGerenciarDocumentosDoPedido(
// ========== QUERIES ==========
export const list = query({
args: {},
args: {
statuses: v.optional(v.array(pedidoStatusValidator)),
numeroSei: v.optional(v.string()),
criadoPor: v.optional(v.id('usuarios')),
aceitoPor: v.optional(v.id('funcionarios')),
periodoInicio: v.optional(v.number()),
periodoFim: v.optional(v.number())
},
returns: v.array(
v.object({
_id: v.id('pedidos'),
_creationTime: v.number(),
numeroSei: v.optional(v.string()),
status: v.union(
v.literal('em_rascunho'),
v.literal('aguardando_aceite'),
v.literal('em_analise'),
v.literal('precisa_ajustes'),
v.literal('cancelado'),
v.literal('concluido')
),
status: pedidoStatusValidator,
// acaoId removed from return
criadoPor: v.id('usuarios'),
criadoPorNome: v.string(),
aceitoPor: v.optional(v.id('funcionarios')),
aceitoPorNome: v.optional(v.string()),
descricaoAjuste: v.optional(v.string()),
concluidoEm: v.optional(v.number()),
criadoEm: v.number(),
atualizadoEm: v.number()
})
),
handler: async (ctx) => {
return await ctx.db.query('pedidos').collect();
handler: async (ctx, args) => {
const base = await fetchPedidosBase(ctx, args);
const pedidos = applyPedidoFilters(base, args);
return await Promise.all(
pedidos.map(async (p) => {
const creator = await ctx.db.get(p.criadoPor);
const aceito = p.aceitoPor ? await ctx.db.get(p.aceitoPor) : null;
return {
_id: p._id,
_creationTime: p._creationTime,
numeroSei: p.numeroSei,
status: p.status,
criadoPor: p.criadoPor,
criadoPorNome: creator?.nome || 'Desconhecido',
aceitoPor: p.aceitoPor,
aceitoPorNome: aceito?.nome || undefined,
descricaoAjuste: p.descricaoAjuste,
concluidoEm: p.concluidoEm,
criadoEm: p.criadoEm,
atualizadoEm: p.atualizadoEm
};
})
);
}
});
@@ -139,18 +309,12 @@ export const get = query({
_id: v.id('pedidos'),
_creationTime: v.number(),
numeroSei: v.optional(v.string()),
status: v.union(
v.literal('em_rascunho'),
v.literal('aguardando_aceite'),
v.literal('em_analise'),
v.literal('precisa_ajustes'),
v.literal('cancelado'),
v.literal('concluido')
),
status: pedidoStatusValidator,
acaoId: v.optional(v.id('acoes')),
criadoPor: v.id('usuarios'),
aceitoPor: v.optional(v.id('funcionarios')),
descricaoAjuste: v.optional(v.string()),
concluidoEm: v.optional(v.number()),
criadoEm: v.number(),
atualizadoEm: v.number()
}),
@@ -493,21 +657,31 @@ export const listMyAnalysis = query({
});
export const listByItemCreator = query({
args: {},
args: {
statuses: v.optional(v.array(pedidoStatusValidator)),
numeroSei: v.optional(v.string()),
criadoPor: v.optional(v.id('usuarios')),
aceitoPor: v.optional(v.id('funcionarios')),
periodoInicio: v.optional(v.number()),
periodoFim: v.optional(v.number())
},
returns: v.array(
v.object({
_id: v.id('pedidos'),
_creationTime: v.number(),
numeroSei: v.optional(v.string()),
status: v.string(),
status: pedidoStatusValidator,
aceitoPor: v.optional(v.id('funcionarios')),
criadoPor: v.id('usuarios'),
criadoPorNome: v.string(),
aceitoPorNome: v.optional(v.string()),
descricaoAjuste: v.optional(v.string()),
concluidoEm: v.optional(v.number()),
criadoEm: v.number(),
atualizadoEm: v.number()
})
),
handler: async (ctx) => {
handler: async (ctx, args) => {
const user = await getUsuarioAutenticado(ctx);
if (!user.funcionarioId) return [];
@@ -525,27 +699,320 @@ export const listByItemCreator = query({
// Filter out nulls and enrich
const validOrders = orders.filter((o) => o !== null);
const filtered = applyPedidoFilters(validOrders as Doc<'pedidos'>[], args);
return await Promise.all(
validOrders.map(async (o) => {
const creator = await ctx.db.get(o!.criadoPor);
filtered.map(async (o) => {
const creator = await ctx.db.get(o.criadoPor);
const aceito = o.aceitoPor ? await ctx.db.get(o.aceitoPor) : null;
return {
_id: o!._id,
_creationTime: o!._creationTime,
numeroSei: o!.numeroSei,
status: o!.status,
criadoPor: o!.criadoPor,
_id: o._id,
_creationTime: o._creationTime,
numeroSei: o.numeroSei,
status: o.status,
criadoPor: o.criadoPor,
criadoPorNome: creator?.nome || 'Desconhecido',
aceitoPor: o!.aceitoPor,
descricaoAjuste: o!.descricaoAjuste,
criadoEm: o!.criadoEm,
atualizadoEm: o!.atualizadoEm
aceitoPor: o.aceitoPor,
aceitoPorNome: aceito?.nome || undefined,
descricaoAjuste: o.descricaoAjuste,
concluidoEm: o.concluidoEm,
criadoEm: o.criadoEm,
atualizadoEm: o.atualizadoEm
};
})
);
}
});
export const gerarRelatorio = query({
args: {
statuses: v.optional(v.array(pedidoStatusValidator)),
numeroSei: v.optional(v.string()),
criadoPor: v.optional(v.id('usuarios')),
aceitoPor: v.optional(v.id('funcionarios')),
periodoInicio: v.optional(v.number()),
periodoFim: v.optional(v.number())
},
returns: v.object({
filtros: v.object({
statuses: v.optional(v.array(pedidoStatusValidator)),
numeroSei: v.optional(v.string()),
criadoPor: v.optional(v.id('usuarios')),
aceitoPor: v.optional(v.id('funcionarios')),
periodoInicio: v.optional(v.number()),
periodoFim: v.optional(v.number())
}),
resumo: v.object({
totalPedidos: v.number(),
totalItens: v.number(),
totalDocumentos: v.number(),
totalPorStatus: v.array(v.object({ status: pedidoStatusValidator, count: v.number() })),
totalValorEstimado: v.number(),
totalValorReal: v.number()
}),
pedidos: v.array(
v.object({
_id: v.id('pedidos'),
_creationTime: v.number(),
numeroSei: v.optional(v.string()),
status: pedidoStatusValidator,
criadoPor: v.id('usuarios'),
criadoPorNome: v.string(),
aceitoPor: v.optional(v.id('funcionarios')),
aceitoPorNome: v.optional(v.string()),
descricaoAjuste: v.optional(v.string()),
criadoEm: v.number(),
concluidoEm: v.optional(v.number()),
atualizadoEm: v.number(),
itensCount: v.number(),
documentosCount: v.number(),
valorEstimadoTotal: v.number(),
valorRealTotal: v.number()
})
),
itens: v.array(
v.object({
_id: v.id('objetoItems'),
pedidoId: v.id('pedidos'),
pedidoNumeroSei: v.optional(v.string()),
pedidoStatus: pedidoStatusValidator,
objetoId: v.id('objetos'),
objetoNome: v.optional(v.string()),
ataId: v.optional(v.id('atas')),
ataNumero: v.optional(v.string()),
acaoId: v.optional(v.id('acoes')),
acaoNome: v.optional(v.string()),
modalidade: v.union(
v.literal('dispensa'),
v.literal('inexgibilidade'),
v.literal('adesao'),
v.literal('consumo')
),
quantidade: v.number(),
valorEstimado: v.string(),
valorReal: v.optional(v.string()),
adicionadoPor: v.id('funcionarios'),
adicionadoPorNome: v.string(),
criadoEm: v.number()
})
)
}),
handler: async (ctx, args) => {
// Para relatório "por período", exigimos pelo menos um limite (início ou fim)
if (args.periodoInicio === undefined && args.periodoFim === undefined) {
throw new Error('Informe um período (início e/ou fim) para gerar o relatório.');
}
const base = await fetchPedidosBase(ctx, args);
const pedidosFiltrados = applyPedidoFilters(base, args);
// Guardrail para evitar timeouts/relatórios gigantescos
if (pedidosFiltrados.length > 500) {
throw new Error(
`Relatório muito grande (${pedidosFiltrados.length} pedidos). Reduza o período/filtros e tente novamente.`
);
}
// Cache para evitar múltiplos gets repetidos
const cacheUsuarios = new Map<string, Doc<'usuarios'> | null>();
const cacheFuncionarios = new Map<string, Doc<'funcionarios'> | null>();
const cacheObjetos = new Map<string, Doc<'objetos'> | null>();
const cacheAtas = new Map<string, Doc<'atas'> | null>();
const cacheAcoes = new Map<string, Doc<'acoes'> | null>();
async function getUsuario(id: Id<'usuarios'>): Promise<Doc<'usuarios'> | null> {
const k = String(id);
if (cacheUsuarios.has(k)) return cacheUsuarios.get(k) ?? null;
const doc = await ctx.db.get(id);
cacheUsuarios.set(k, doc);
return doc;
}
async function getFuncionario(id: Id<'funcionarios'>): Promise<Doc<'funcionarios'> | null> {
const k = String(id);
if (cacheFuncionarios.has(k)) return cacheFuncionarios.get(k) ?? null;
const doc = await ctx.db.get(id);
cacheFuncionarios.set(k, doc);
return doc;
}
async function getObjeto(id: Id<'objetos'>): Promise<Doc<'objetos'> | null> {
const k = String(id);
if (cacheObjetos.has(k)) return cacheObjetos.get(k) ?? null;
const doc = await ctx.db.get(id);
cacheObjetos.set(k, doc);
return doc;
}
async function getAta(id: Id<'atas'>): Promise<Doc<'atas'> | null> {
const k = String(id);
if (cacheAtas.has(k)) return cacheAtas.get(k) ?? null;
const doc = await ctx.db.get(id);
cacheAtas.set(k, doc);
return doc;
}
async function getAcao(id: Id<'acoes'>): Promise<Doc<'acoes'> | null> {
const k = String(id);
if (cacheAcoes.has(k)) return cacheAcoes.get(k) ?? null;
const doc = await ctx.db.get(id);
cacheAcoes.set(k, doc);
return doc;
}
function parseValorMoeda(input: string | undefined): number {
if (!input) return 0;
const s = input
.replace(/\s/g, '')
.replace(/[Rr]\$?/g, '')
.replace(/\./g, '')
.replace(',', '.')
.replace(/[^0-9.-]/g, '');
const n = Number(s);
return Number.isFinite(n) ? n : 0;
}
const itensOut: Array<{
_id: Id<'objetoItems'>;
pedidoId: Id<'pedidos'>;
pedidoNumeroSei?: string;
pedidoStatus: PedidoStatus;
objetoId: Id<'objetos'>;
objetoNome?: string;
ataId?: Id<'atas'>;
ataNumero?: string;
acaoId?: Id<'acoes'>;
acaoNome?: string;
modalidade: Doc<'objetoItems'>['modalidade'];
quantidade: number;
valorEstimado: string;
valorReal?: string;
adicionadoPor: Id<'funcionarios'>;
adicionadoPorNome: string;
criadoEm: number;
}> = [];
let totalItens = 0;
let totalDocumentos = 0;
let totalValorEstimado = 0;
let totalValorReal = 0;
const statusCounts = new Map<PedidoStatus, number>();
for (const s of [
'em_rascunho',
'aguardando_aceite',
'em_analise',
'precisa_ajustes',
'cancelado',
'concluido'
] as PedidoStatus[]) {
statusCounts.set(s, 0);
}
const pedidosOut = await Promise.all(
pedidosFiltrados.map(async (p) => {
statusCounts.set(p.status, (statusCounts.get(p.status) ?? 0) + 1);
const creator = await getUsuario(p.criadoPor);
const aceito = p.aceitoPor ? await getFuncionario(p.aceitoPor) : null;
const itens = await ctx.db
.query('objetoItems')
.withIndex('by_pedidoId', (q) => q.eq('pedidoId', p._id))
.collect();
const docs = await ctx.db
.query('pedidoDocumentos')
.withIndex('by_pedidoId', (q) => q.eq('pedidoId', p._id))
.collect();
totalItens += itens.length;
totalDocumentos += docs.length;
let valorEstimadoTotal = 0;
let valorRealTotal = 0;
for (const it of itens) {
valorEstimadoTotal += parseValorMoeda(it.valorEstimado);
valorRealTotal += parseValorMoeda(it.valorReal);
const funcionario = await getFuncionario(it.adicionadoPor);
const objeto = await getObjeto(it.objetoId);
const ata = it.ataId ? await getAta(it.ataId) : null;
const acao = it.acaoId ? await getAcao(it.acaoId) : null;
itensOut.push({
_id: it._id,
pedidoId: it.pedidoId,
pedidoNumeroSei: p.numeroSei,
pedidoStatus: p.status,
objetoId: it.objetoId,
objetoNome: objeto?.nome ?? undefined,
ataId: it.ataId,
ataNumero: ata?.numero ?? undefined,
acaoId: it.acaoId,
acaoNome: acao?.nome ?? undefined,
modalidade: it.modalidade,
quantidade: it.quantidade,
valorEstimado: it.valorEstimado,
valorReal: it.valorReal,
adicionadoPor: it.adicionadoPor,
adicionadoPorNome: funcionario?.nome || 'Desconhecido',
criadoEm: it.criadoEm
});
}
totalValorEstimado += valorEstimadoTotal;
totalValorReal += valorRealTotal;
return {
_id: p._id,
_creationTime: p._creationTime,
numeroSei: p.numeroSei,
status: p.status,
criadoPor: p.criadoPor,
criadoPorNome: creator?.nome || 'Desconhecido',
aceitoPor: p.aceitoPor,
aceitoPorNome: aceito?.nome || undefined,
descricaoAjuste: p.descricaoAjuste,
criadoEm: p.criadoEm,
concluidoEm: p.concluidoEm,
atualizadoEm: p.atualizadoEm,
itensCount: itens.length,
documentosCount: docs.length,
valorEstimadoTotal,
valorRealTotal
};
})
);
// Ordenar itens por data (mais recente primeiro)
itensOut.sort((a, b) => b.criadoEm - a.criadoEm);
const totalPorStatus = [...statusCounts.entries()].map(([status, count]) => ({
status,
count
}));
return {
filtros: {
statuses: args.statuses,
numeroSei: args.numeroSei,
criadoPor: args.criadoPor,
aceitoPor: args.aceitoPor,
periodoInicio: args.periodoInicio,
periodoFim: args.periodoFim
},
resumo: {
totalPedidos: pedidosFiltrados.length,
totalItens,
totalDocumentos,
totalPorStatus,
totalValorEstimado,
totalValorReal
},
pedidos: pedidosOut,
itens: itensOut
};
}
});
export const acceptOrder = mutation({
args: {
pedidoId: v.id('pedidos')
@@ -1380,6 +1847,7 @@ export const concluirPedido = mutation({
await ctx.db.patch(args.pedidoId, {
status: newStatus,
concluidoEm: Date.now(),
atualizadoEm: Date.now()
});