import { v } from 'convex/values'; import { api, internal } from './_generated/api'; import type { Doc, Id } from './_generated/dataModel'; import type { MutationCtx, QueryCtx } from './_generated/server'; import { internalMutation, mutation, query } from './_generated/server'; 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(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>(); for (const p of pedidos) map.set(String(p._id), p); return [...map.values()]; } async function fetchPedidosBase(ctx: QueryCtx, args: PedidoListFilters): Promise[]> { // 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'); return user; } // Garante que todos os itens de um pedido utilizem a mesma // combinação de modalidade e ata (quando houver). async function ensurePedidoModalidadeAtaConsistency( ctx: MutationCtx, pedidoId: Id<'pedidos'>, modalidade: Doc<'objetoItems'>['modalidade'], ataId: Id<'atas'> | undefined, ignoreItemId?: Id<'objetoItems'> ) { const normalizedNewAtaId = ataId ?? null; const items = await ctx.db .query('objetoItems') .withIndex('by_pedidoId', (q) => q.eq('pedidoId', pedidoId)) .collect(); if (items.length === 0) { return; } for (const item of items) { if (ignoreItemId && item._id === ignoreItemId) continue; const normalizedItemAtaId = (('ataId' in item ? item.ataId : undefined) ?? null) as Id<'atas'> | null; if (item.modalidade !== modalidade || normalizedItemAtaId !== normalizedNewAtaId) { throw new Error( 'Todos os itens do pedido devem usar a mesma modalidade e a mesma ata (quando houver). Ajuste os itens existentes ou crie um novo pedido para a nova combinação.' ); } } } type AtaObjetoUsageInfo = { linkId: Id<'atasObjetos'>; quantidadeTotal: number; limitePercentual: number; limitePermitido: number; quantidadeUsada: number; restante: number; }; function normalizeLimitePercentual(value: number | undefined): number { const fallback = 50; if (value === undefined) return fallback; if (!Number.isFinite(value)) return fallback; // Normalizar para faixa 0..100 if (value < 0) return 0; if (value > 100) return 100; return value; } function computeLimitePermitido(quantidadeTotal: number, limitePercentual: number): number { if (!Number.isFinite(quantidadeTotal) || quantidadeTotal <= 0) return 0; const pct = normalizeLimitePercentual(limitePercentual); return Math.floor(quantidadeTotal * (pct / 100)); } async function getAtaObjetoLinkOrThrow( ctx: MutationCtx, ataId: Id<'atas'>, objetoId: Id<'objetos'> ): Promise> { const link = await ctx.db .query('atasObjetos') .withIndex('by_ataId_and_objetoId', (q) => q.eq('ataId', ataId).eq('objetoId', objetoId)) .unique(); if (!link) { throw new Error('Esta ata não está vinculada a este objeto.'); } return link; } async function computeQuantidadeUsadaFromDb( ctx: MutationCtx, ataId: Id<'atas'>, objetoId: Id<'objetos'> ): Promise { const items = await ctx.db .query('objetoItems') .withIndex('by_ataId_and_objetoId', (q) => q.eq('ataId', ataId).eq('objetoId', objetoId)) .collect(); const sumByPedidoId = new Map, number>(); for (const item of items) { const prev = sumByPedidoId.get(item.pedidoId) ?? 0; sumByPedidoId.set(item.pedidoId, prev + item.quantidade); } let total = 0; for (const [pedidoId, sum] of sumByPedidoId.entries()) { const pedido = await ctx.db.get(pedidoId); if (pedido && pedido.status !== 'cancelado') { total += sum; } } return total; } async function getAtaObjetoUsageInfo( ctx: MutationCtx, ataId: Id<'atas'>, objetoId: Id<'objetos'>, requireConfigured: boolean ): Promise { const link = await getAtaObjetoLinkOrThrow(ctx, ataId, objetoId); const quantidadeTotalRaw = link.quantidadeTotal; const hasQuantidadeTotal = quantidadeTotalRaw !== undefined && Number.isFinite(quantidadeTotalRaw) && quantidadeTotalRaw > 0; if (requireConfigured && !hasQuantidadeTotal) { throw new Error( 'Esta ata está vinculada ao objeto, mas a quantidade do produto na ata não foi configurada. Configure a quantidade/limite antes de usar esta ata.' ); } const limitePercentual = normalizeLimitePercentual(link.limitePercentual); const quantidadeTotal = hasQuantidadeTotal ? (quantidadeTotalRaw as number) : 0; const limitePermitido = computeLimitePermitido(quantidadeTotal, limitePercentual); let quantidadeUsada = link.quantidadeUsada; if (quantidadeUsada === undefined) { quantidadeUsada = await computeQuantidadeUsadaFromDb(ctx, ataId, objetoId); await ctx.db.patch(link._id, { quantidadeUsada }); } const restante = Math.max(0, limitePermitido - quantidadeUsada); return { linkId: link._id, quantidadeTotal, limitePercentual, limitePermitido, quantidadeUsada, restante }; } async function assertAtaObjetoCanConsume( ctx: MutationCtx, ataId: Id<'atas'>, objetoId: Id<'objetos'>, delta: number ) { if (!Number.isFinite(delta) || delta <= 0) return; const info = await getAtaObjetoUsageInfo(ctx, ataId, objetoId, true); if (info.quantidadeUsada + delta > info.limitePermitido) { throw new Error( `Limite de uso da ata atingido para este objeto. Limite permitido: ${info.limitePermitido}. Usado: ${info.quantidadeUsada}. Tentativa de adicionar: ${delta}. Restante: ${info.restante}.` ); } } async function applyAtaObjetoUsageDelta( ctx: MutationCtx, ataId: Id<'atas'>, objetoId: Id<'objetos'>, delta: number ) { if (!Number.isFinite(delta) || delta === 0) return; const info = await getAtaObjetoUsageInfo(ctx, ataId, objetoId, delta > 0); const novoUsadoRaw = info.quantidadeUsada + delta; const novoUsado = Math.max(0, novoUsadoRaw); if (delta > 0 && novoUsado > info.limitePermitido) { throw new Error( `Limite de uso da ata atingido para este objeto. Limite permitido: ${info.limitePermitido}. Usado: ${info.quantidadeUsada}. Tentativa de adicionar: ${delta}. Restante: ${info.restante}.` ); } await ctx.db.patch(info.linkId, { quantidadeUsada: novoUsado }); } async function isFuncionarioInComprasSector( ctx: QueryCtx | MutationCtx, funcionarioId: Id<'funcionarios'> ) { const config = await ctx.db.query('config').first(); if (!config || !config.comprasSetorId) return false; const isInSector = await ctx.db .query('funcionarioSetores') .withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', funcionarioId)) .filter((q) => q.eq(q.field('setorId'), config.comprasSetorId)) .first(); return !!isInSector; } async function isUsuarioEnvolvidoNoPedido( ctx: QueryCtx | MutationCtx, pedidoId: Id<'pedidos'>, user: Awaited> ) { const pedido = await ctx.db.get(pedidoId); if (!pedido) return false; // Criador do pedido (por usuarioId) if (pedido.criadoPor === user._id) return true; // Envolvimento por itens (requer funcionarioId) if (!user.funcionarioId) return false; const hasItem = await ctx.db .query('objetoItems') .withIndex('by_pedidoId', (q) => q.eq('pedidoId', pedidoId)) .filter((q) => q.eq(q.field('adicionadoPor'), user.funcionarioId)) .first(); return !!hasItem; } async function assertPodeGerenciarDocumentosDoPedido( ctx: QueryCtx | MutationCtx, pedidoId: Id<'pedidos'>, user: Awaited> ) { const isEnvolvido = await isUsuarioEnvolvidoNoPedido(ctx, pedidoId, user); if (isEnvolvido) return; if (user.funcionarioId) { const isCompras = await isFuncionarioInComprasSector(ctx, user.funcionarioId); if (isCompras) return; } throw new Error('Acesso negado.'); } // ========== QUERIES ========== export const list = 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.array( v.object({ _id: v.id('pedidos'), _creationTime: v.number(), numeroSei: v.optional(v.string()), numeroDfd: v.optional(v.string()), 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, 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, numeroDfd: p.numeroDfd, 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 }; }) ); } }); export const get = query({ args: { id: v.id('pedidos') }, returns: v.union( v.object({ _id: v.id('pedidos'), _creationTime: v.number(), numeroSei: v.optional(v.string()), numeroDfd: v.optional(v.string()), 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() }), v.null() ), handler: async (ctx, args) => { return await ctx.db.get(args.id); } }); export const getItems = query({ args: { pedidoId: v.id('pedidos') }, returns: v.array( v.object({ _id: v.id('objetoItems'), _creationTime: v.number(), pedidoId: v.id('pedidos'), objetoId: v.id('objetos'), ataId: v.optional(v.id('atas')), acaoId: v.optional(v.id('acoes')), modalidade: v.union( v.literal('dispensa'), v.literal('inexgibilidade'), v.literal('adesao'), v.literal('consumo') ), valorEstimado: v.string(), valorReal: v.optional(v.string()), quantidade: v.number(), adicionadoPor: v.id('funcionarios'), adicionadoPorNome: v.string(), criadoEm: v.number() }) ), handler: async (ctx, args) => { const items = await ctx.db .query('objetoItems') .withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId)) .collect(); // Get employee names const itemsWithNames = await Promise.all( items.map(async (item) => { const funcionario = await ctx.db.get(item.adicionadoPor); return { ...item, adicionadoPorNome: funcionario?.nome || 'Desconhecido' }; }) ); return itemsWithNames; } }); export const getHistory = query({ args: { pedidoId: v.id('pedidos') }, handler: async (ctx, args) => { const history = await ctx.db .query('historicoPedidos') .withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId)) .order('desc') .collect(); // Get user names const historyWithNames = await Promise.all( history.map(async (entry) => { const usuario = await ctx.db.get(entry.usuarioId); return { _id: entry._id, _creationTime: entry._creationTime, pedidoId: entry.pedidoId, usuarioId: entry.usuarioId, usuarioNome: usuario?.nome || 'Desconhecido', acao: entry.acao, detalhes: entry.detalhes, data: entry.data }; }) ); return historyWithNames; } }); export const checkExisting = query({ args: { numeroSei: v.optional(v.string()), itensFiltro: v.optional( v.array( v.object({ objetoId: v.id('objetos'), modalidade: v.union( v.literal('dispensa'), v.literal('inexgibilidade'), v.literal('adesao'), v.literal('consumo') ) }) ) ) }, returns: v.array( v.object({ _id: v.id('pedidos'), _creationTime: v.number(), numeroSei: v.optional(v.string()), numeroDfd: 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') ), // acaoId removed criadoPor: v.id('usuarios'), aceitoPor: v.optional(v.id('funcionarios')), descricaoAjuste: v.optional(v.string()), criadoEm: v.number(), atualizadoEm: v.number(), matchingItems: v.optional( v.array( v.object({ objetoId: v.id('objetos'), modalidade: v.union( v.literal('dispensa'), v.literal('inexgibilidade'), v.literal('adesao'), v.literal('consumo') ), quantidade: v.number() }) ) ) }) ), handler: async (ctx, args) => { const user = await getCurrentUserFunction(ctx); if (!user) return []; const openStatuses: Array< 'em_rascunho' | 'aguardando_aceite' | 'em_analise' | 'precisa_ajustes' > = ['em_rascunho', 'aguardando_aceite', 'em_analise', 'precisa_ajustes']; // 1) Buscar todos os pedidos "abertos" usando o índice by_status let pedidosAbertos: Doc<'pedidos'>[] = []; for (const status of openStatuses) { const partial = await ctx.db .query('pedidos') .withIndex('by_status', (q) => q.eq('status', status)) .collect(); pedidosAbertos = pedidosAbertos.concat(partial); } // 2) Filtros opcionais: numeroSei pedidosAbertos = pedidosAbertos.filter((p) => { if (args.numeroSei && p.numeroSei !== args.numeroSei) return false; return true; }); // 3) Filtro por itens (objetoId + modalidade), se informado, e coleta de matchingItems const resultados = []; const itensFiltro = args.itensFiltro ?? []; for (const pedido of pedidosAbertos) { let include = true; let matchingItems: { objetoId: Id<'objetos'>; modalidade: Doc<'objetoItems'>['modalidade']; quantidade: number; }[] = []; // Se houver filtro de itens, verificamos se o pedido tem ALGUM dos itens (objetoId + modalidade) if (itensFiltro.length > 0) { const items = await ctx.db .query('objetoItems') .withIndex('by_pedidoId', (q) => q.eq('pedidoId', pedido._id)) .collect(); const matching = items.filter((i) => itensFiltro.some((f) => f.objetoId === i.objetoId && f.modalidade === i.modalidade) ); if (matching.length > 0) { matchingItems = matching.map((i) => ({ objetoId: i.objetoId, modalidade: i.modalidade, quantidade: i.quantidade })); } else { include = false; } } if (include) { resultados.push({ _id: pedido._id, _creationTime: pedido._creationTime, numeroSei: pedido.numeroSei, numeroDfd: pedido.numeroDfd, status: pedido.status, criadoPor: pedido.criadoPor, aceitoPor: pedido.aceitoPor, descricaoAjuste: pedido.descricaoAjuste, criadoEm: pedido.criadoEm, atualizadoEm: pedido.atualizadoEm, matchingItems: matchingItems.length > 0 ? matchingItems : undefined }); } } return resultados; } }); export const listForAcceptance = query({ args: {}, returns: v.array( v.object({ _id: v.id('pedidos'), _creationTime: v.number(), numeroSei: v.optional(v.string()), numeroDfd: v.optional(v.string()), status: v.string(), criadoPor: v.id('usuarios'), criadoPorNome: v.string(), criadoEm: v.number(), atualizadoEm: v.number() }) ), handler: async (ctx) => { const user = await getUsuarioAutenticado(ctx); // Security Check: Must be in Compras Sector const config = await ctx.db.query('config').first(); if (!config || !config.comprasSetorId) return []; if (!user.funcionarioId) return []; const isInSector = await ctx.db .query('funcionarioSetores') .withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', user.funcionarioId!)) .filter((q) => q.eq(q.field('setorId'), config.comprasSetorId)) .first(); if (!isInSector) return []; // Fetch orders waiting for acceptance const orders = await ctx.db .query('pedidos') .withIndex('by_status', (q) => q.eq('status', 'aguardando_aceite')) .collect(); // Enrich with creator name return await Promise.all( orders.map(async (o) => { const creator = await ctx.db.get(o.criadoPor); return { _id: o._id, _creationTime: o._creationTime, numeroSei: o.numeroSei, numeroDfd: o.numeroDfd, status: o.status, criadoPor: o.criadoPor, criadoPorNome: creator?.nome || 'Desconhecido', aceitoPor: o.aceitoPor, descricaoAjuste: o.descricaoAjuste, criadoEm: o.criadoEm, atualizadoEm: o.atualizadoEm }; }) ); } }); export const listMyAnalysis = query({ args: {}, returns: v.array( v.object({ _id: v.id('pedidos'), _creationTime: v.number(), numeroSei: v.optional(v.string()), numeroDfd: v.optional(v.string()), status: v.string(), criadoPor: v.id('usuarios'), criadoPorNome: v.string(), aceitoPor: v.optional(v.id('funcionarios')), descricaoAjuste: v.optional(v.string()), criadoEm: v.number(), atualizadoEm: v.number() }) ), handler: async (ctx) => { const user = await getUsuarioAutenticado(ctx); // Security Check: Must be in Compras Sector const config = await ctx.db.query('config').first(); if (!config || !config.comprasSetorId) return []; if (!user.funcionarioId) return []; const isInSector = await ctx.db .query('funcionarioSetores') .withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', user.funcionarioId!)) .filter((q) => q.eq(q.field('setorId'), config.comprasSetorId)) .first(); if (!isInSector) return []; // Fetch orders accepted by this user // We don't have an index on aceitoPor yet, but we can filter or add index. // Ideally we should add an index, but for now let's filter since volume might be low per user. // Wait, we can't filter efficiently without index if table is huge. // Let's assume we should add index or filter in memory if small. // Given the schema change was just adding the field, let's filter. // Actually, let's add the index in the schema update if possible, but I already did that step. // I'll filter for now. const orders = await ctx.db .query('pedidos') .filter((q) => q.eq(q.field('aceitoPor'), user.funcionarioId)) .collect(); return await Promise.all( orders.map(async (o) => { const creator = await ctx.db.get(o.criadoPor); return { _id: o._id, _creationTime: o._creationTime, numeroSei: o.numeroSei, numeroDfd: o.numeroDfd, status: o.status, criadoPor: o.criadoPor, criadoPorNome: creator?.nome || 'Desconhecido', aceitoPor: o.aceitoPor, descricaoAjuste: o.descricaoAjuste, criadoEm: o.criadoEm, atualizadoEm: o.atualizadoEm }; }) ); } }); export const listByItemCreator = 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.array( v.object({ _id: v.id('pedidos'), _creationTime: v.number(), numeroSei: v.optional(v.string()), numeroDfd: v.optional(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, args) => { const user = await getUsuarioAutenticado(ctx); if (!user.funcionarioId) return []; // Find all items added by this user const myItems = await ctx.db .query('objetoItems') .withIndex('by_adicionadoPor', (q) => q.eq('adicionadoPor', user.funcionarioId!)) .collect(); // Get unique pedidoIds const pedidoIds = [...new Set(myItems.map((i) => i.pedidoId))]; // Fetch orders const orders = await Promise.all(pedidoIds.map((id) => ctx.db.get(id))); // Filter out nulls and enrich const validOrders = orders.filter((o) => o !== null); const filtered = applyPedidoFilters(validOrders as Doc<'pedidos'>[], args); return await Promise.all( 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, numeroDfd: o.numeroDfd, status: o.status, criadoPor: o.criadoPor, criadoPorNome: creator?.nome || 'Desconhecido', 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()), numeroDfd: 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 | null>(); const cacheFuncionarios = new Map | null>(); const cacheObjetos = new Map | null>(); const cacheAtas = new Map | null>(); const cacheAcoes = new Map | null>(); async function getUsuario(id: Id<'usuarios'>): Promise | 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 | 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 | 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 | 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 | 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(); 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, numeroDfd: p.numeroDfd, 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') }, returns: v.null(), handler: async (ctx, args) => { const user = await getUsuarioAutenticado(ctx); // Security Check: Must be in Compras Sector const config = await ctx.db.query('config').first(); if (!config || !config.comprasSetorId) { throw new Error('Setor de compras não configurado.'); } if (!user.funcionarioId) { throw new Error('Usuário sem funcionário vinculado.'); } const isInSector = await ctx.db .query('funcionarioSetores') .withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', user.funcionarioId!)) .filter((q) => q.eq(q.field('setorId'), config.comprasSetorId)) .first(); if (!isInSector) { throw new Error('Você não tem permissão para aceitar pedidos (Setor inválido).'); } const pedido = await ctx.db.get(args.pedidoId); if (!pedido) throw new Error('Pedido não encontrado.'); // Só é permitido adicionar itens em pedidos em rascunho, aguardando aceite ou em análise. // Nos demais status (ex.: precisa_ajustes, concluído, cancelado etc.) a inclusão é bloqueada. if ( pedido.status !== 'em_rascunho' && pedido.status !== 'aguardando_aceite' && pedido.status !== 'em_analise' ) { throw new Error('Não é possível adicionar itens neste status de pedido.'); } if (pedido.status !== 'aguardando_aceite') { throw new Error('Este pedido não está aguardando aceite.'); } if (pedido.aceitoPor) { throw new Error('Este pedido já foi aceito por outro funcionário.'); } await ctx.db.patch(args.pedidoId, { status: 'em_analise', aceitoPor: user.funcionarioId, atualizadoEm: Date.now() }); await ctx.db.insert('historicoPedidos', { pedidoId: args.pedidoId, usuarioId: user._id, acao: 'aceite_pedido', detalhes: JSON.stringify({ aceitoPor: user.funcionarioId }), data: Date.now() }); } }); // ========== MUTATIONS ========== export const create = mutation({ args: { numeroSei: v.optional(v.string()), numeroDfd: v.optional(v.string()) // acaoId removed }, returns: v.id('pedidos'), handler: async (ctx, args) => { const user = await getUsuarioAutenticado(ctx); // 1. Check Config const config = await ctx.db.query('config').first(); if (!config || !config.comprasSetorId) { throw new Error('Setor de Compras não configurado. Contate o administrador.'); } // 2. Check Existing (Double check) - Removed acaoId check here as it's now per item // 3. Create Order const pedidoId = await ctx.db.insert('pedidos', { numeroSei: args.numeroSei, numeroDfd: args.numeroDfd, status: 'em_rascunho', criadoPor: user._id, criadoEm: Date.now(), atualizadoEm: Date.now() }); // 4. Create History await ctx.db.insert('historicoPedidos', { pedidoId, usuarioId: user._id, acao: 'criacao', detalhes: JSON.stringify({ numeroSei: args.numeroSei }), data: Date.now() }); return pedidoId; } }); export const updateSeiNumber = mutation({ args: { pedidoId: v.id('pedidos'), numeroSei: v.string() }, returns: v.null(), handler: async (ctx, args) => { const user = await getUsuarioAutenticado(ctx); const pedido = await ctx.db.get(args.pedidoId); if (!pedido) throw new Error('Pedido not found'); // Check if SEI number is already taken by another order const existing = await ctx.db .query('pedidos') .filter((q) => q.and(q.eq(q.field('numeroSei'), args.numeroSei), q.neq(q.field('_id'), args.pedidoId)) ) .first(); if (existing) { throw new Error('Este número SEI já está em uso por outro pedido.'); } const oldSei = pedido.numeroSei; await ctx.db.patch(args.pedidoId, { numeroSei: args.numeroSei, atualizadoEm: Date.now() }); await ctx.db.insert('historicoPedidos', { pedidoId: args.pedidoId, usuarioId: user._id, acao: 'atualizacao_sei', detalhes: JSON.stringify({ de: oldSei, para: args.numeroSei }), data: Date.now() }); } }); export const updateDfdNumber = mutation({ args: { pedidoId: v.id('pedidos'), numeroDfd: v.string() }, returns: v.null(), handler: async (ctx, args) => { const user = await getUsuarioAutenticado(ctx); const pedido = await ctx.db.get(args.pedidoId); if (!pedido) throw new Error('Pedido não encontrado.'); // Verificar se o pedido está em um status que permite edição if (pedido.status !== 'em_rascunho' && pedido.status !== 'precisa_ajustes') { throw new Error( 'Apenas pedidos em rascunho ou que precisam de ajustes podem ter o número DFD atualizado.' ); } const oldDfd = pedido.numeroDfd; await ctx.db.patch(args.pedidoId, { numeroDfd: args.numeroDfd, atualizadoEm: Date.now() }); await ctx.db.insert('historicoPedidos', { pedidoId: args.pedidoId, usuarioId: user._id, acao: 'atualizacao_dfd', detalhes: JSON.stringify({ de: oldDfd, para: args.numeroDfd }), data: Date.now() }); } }); export const addItem = mutation({ args: { pedidoId: v.id('pedidos'), objetoId: v.id('objetos'), ataId: v.optional(v.id('atas')), acaoId: v.optional(v.id('acoes')), modalidade: v.union( v.literal('dispensa'), v.literal('inexgibilidade'), v.literal('adesao'), v.literal('consumo') ), valorEstimado: v.string(), quantidade: v.number() }, returns: v.null(), handler: async (ctx, args) => { const user = await getUsuarioAutenticado(ctx); // Ensure user has a funcionarioId linked if (!user.funcionarioId) { throw new Error('Usuário não vinculado a um funcionário.'); } const pedido = await ctx.db.get(args.pedidoId); if (!pedido) throw new Error('Pedido não encontrado.'); // Regra global: todos os itens do pedido devem ter a mesma // modalidade e a mesma ata (quando houver). await ensurePedidoModalidadeAtaConsistency(ctx, args.pedidoId, args.modalidade, args.ataId); // --- CHECK ANALYSIS MODE --- // Em pedidos em análise, a inclusão de itens deve passar por fluxo de aprovação. // Em rascunho ou aguardando aceite, a inclusão é direta, sem necessidade de aprovação. if (pedido.status === 'em_analise') { if (args.ataId) { // Não altera consumo aqui (ainda é só solicitação), mas valida limite/configuração. await assertAtaObjetoCanConsume(ctx, args.ataId, args.objetoId, args.quantidade); } await ctx.db.insert('solicitacoesItens', { pedidoId: args.pedidoId, tipo: 'adicao', dados: JSON.stringify(args), status: 'pendente', solicitadoPor: user.funcionarioId, criadoEm: Date.now() }); return; } // --- CHECK DUPLICATES (Same Product + User, Different Config) --- // Get all items of this product added by this user in this order const userProductItems = await ctx.db .query('objetoItems') .withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId)) .filter((q) => q.and( q.eq(q.field('objetoId'), args.objetoId), q.eq(q.field('adicionadoPor'), user.funcionarioId) ) ) .collect(); const conflict = userProductItems.find( (i) => i.modalidade !== args.modalidade || i.ataId !== args.ataId ); if (conflict) { throw new Error( 'Este pedido já possui este produto com outra combinação de modalidade e/ou ata para você. Todos os itens do mesmo produto devem usar a mesma modalidade e ata neste pedido. Ajuste o item existente ou crie um novo pedido para a nova combinação.' ); } // Check if item already exists with SAME parameters (exact match) to increment const existingItem = userProductItems.find( (i) => i.acaoId === args.acaoId && i.ataId === args.ataId && i.modalidade === args.modalidade ); if (existingItem) { if (args.ataId) { await applyAtaObjetoUsageDelta(ctx, args.ataId, args.objetoId, args.quantidade); } // Increment quantity const novaQuantidade = existingItem.quantidade + args.quantidade; await ctx.db.patch(existingItem._id, { quantidade: novaQuantidade }); await ctx.db.insert('historicoPedidos', { pedidoId: args.pedidoId, usuarioId: user._id, acao: 'adicao_item_incremento', detalhes: JSON.stringify({ objetoId: args.objetoId, quantidadeAdicionada: args.quantidade, novaQuantidade }), data: Date.now() }); } else { if (args.ataId) { await applyAtaObjetoUsageDelta(ctx, args.ataId, args.objetoId, args.quantidade); } // Insert new item await ctx.db.insert('objetoItems', { pedidoId: args.pedidoId, objetoId: args.objetoId, ataId: args.ataId, acaoId: args.acaoId, modalidade: args.modalidade, valorEstimado: args.valorEstimado, quantidade: args.quantidade, adicionadoPor: user.funcionarioId, criadoEm: Date.now() }); await ctx.db.insert('historicoPedidos', { pedidoId: args.pedidoId, usuarioId: user._id, acao: 'adicao_item', detalhes: JSON.stringify({ objetoId: args.objetoId, valor: args.valorEstimado, quantidade: args.quantidade, acaoId: args.acaoId, ataId: args.ataId, modalidade: args.modalidade }), data: Date.now() }); } await ctx.db.patch(args.pedidoId, { atualizadoEm: Date.now() }); } }); export const updateItemQuantity = mutation({ args: { itemId: v.id('objetoItems'), novaQuantidade: v.number() }, returns: v.null(), handler: async (ctx, args) => { const user = await getUsuarioAutenticado(ctx); if (!user.funcionarioId) { throw new Error('Usuário não vinculado a um funcionário.'); } const item = await ctx.db.get(args.itemId); if (!item) throw new Error('Item não encontrado.'); const pedido = await ctx.db.get(item.pedidoId); if (!pedido) throw new Error('Pedido não encontrado.'); const delta = args.novaQuantidade - item.quantidade; // --- CHECK ANALYSIS / ACCEPTANCE MODE --- if (pedido.status === 'em_analise' || pedido.status === 'aguardando_aceite') { if (delta > 0 && item.ataId) { // Não altera consumo aqui (ainda é só solicitação), mas valida limite/configuração. await assertAtaObjetoCanConsume(ctx, item.ataId, item.objetoId, delta); } await ctx.db.insert('solicitacoesItens', { pedidoId: item.pedidoId, tipo: 'alteracao_quantidade', dados: JSON.stringify({ itemId: args.itemId, novaQuantidade: args.novaQuantidade }), status: 'pendente', solicitadoPor: user.funcionarioId, criadoEm: Date.now() }); return; } const quantidadeAnterior = item.quantidade; // Check permission: only item owner can change quantity const isOwner = item.adicionadoPor === user.funcionarioId; if (!isOwner) { throw new Error('Apenas quem adicionou este item pode alterar a quantidade.'); } // Atualiza consumo (ata+objeto) antes de persistir a nova quantidade if (item.ataId) { await applyAtaObjetoUsageDelta(ctx, item.ataId, item.objetoId, delta); } // Update quantity await ctx.db.patch(args.itemId, { quantidade: args.novaQuantidade }); await ctx.db.patch(item.pedidoId, { atualizadoEm: Date.now() }); // Create history entry await ctx.db.insert('historicoPedidos', { pedidoId: item.pedidoId, usuarioId: user._id, acao: 'alteracao_quantidade', detalhes: JSON.stringify({ objetoId: item.objetoId, quantidadeAnterior, novaQuantidade: args.novaQuantidade }), data: Date.now() }); } }); export const removeItem = mutation({ args: { itemId: v.id('objetoItems') }, returns: v.null(), handler: async (ctx, args) => { const user = await getUsuarioAutenticado(ctx); const item = await ctx.db.get(args.itemId); if (!item) throw new Error('Item not found'); const pedido = await ctx.db.get(item.pedidoId); if (!pedido) throw new Error('Pedido não encontrado.'); // --- CHECK ANALYSIS / ACCEPTANCE MODE --- if (pedido.status === 'em_analise' || pedido.status === 'aguardando_aceite') { if (!user.funcionarioId) throw new Error('Usuário sem funcionário vinculado.'); await ctx.db.insert('solicitacoesItens', { pedidoId: item.pedidoId, tipo: 'exclusao', dados: JSON.stringify({ itemId: args.itemId }), status: 'pendente', solicitadoPor: user.funcionarioId, criadoEm: Date.now() }); return; } if (item.ataId) { await applyAtaObjetoUsageDelta(ctx, item.ataId, item.objetoId, -item.quantidade); } await ctx.db.delete(args.itemId); await ctx.db.patch(item.pedidoId, { atualizadoEm: Date.now() }); await ctx.db.insert('historicoPedidos', { pedidoId: item.pedidoId, usuarioId: user._id, acao: 'remocao_item', detalhes: JSON.stringify({ objetoId: item.objetoId, valor: item.valorEstimado }), data: Date.now() }); } }); export const removeItemsBatch = mutation({ args: { itemIds: v.array(v.id('objetoItems')) }, returns: v.null(), handler: async (ctx, args) => { const user = await getUsuarioAutenticado(ctx); if (!user.funcionarioId) { throw new Error('Usuário não vinculado a um funcionário.'); } if (args.itemIds.length === 0) { return null; } const firstItem = await ctx.db.get(args.itemIds[0]); if (!firstItem) { throw new Error('Item não encontrado.'); } const pedidoId = firstItem.pedidoId; const pedido = await ctx.db.get(pedidoId); if (!pedido) { throw new Error('Pedido não encontrado.'); } if ( pedido.status !== 'em_rascunho' && pedido.status !== 'precisa_ajustes' && pedido.status !== 'em_analise' ) { throw new Error( 'Só é possível remover itens em pedidos em rascunho, em análise ou que precisam de ajustes.' ); } for (const itemId of args.itemIds) { const item = await ctx.db.get(itemId); if (!item) continue; if (item.pedidoId !== pedidoId) { throw new Error('Todos os itens devem pertencer ao mesmo pedido.'); } if (item.adicionadoPor !== user.funcionarioId) { throw new Error('Você só pode remover itens que você adicionou.'); } if (item.ataId) { await applyAtaObjetoUsageDelta(ctx, item.ataId, item.objetoId, -item.quantidade); } await ctx.db.delete(itemId); await ctx.db.insert('historicoPedidos', { pedidoId, usuarioId: user._id, acao: 'remocao_item', detalhes: JSON.stringify({ objetoId: item.objetoId, valor: item.valorEstimado }), data: Date.now() }); } await ctx.db.patch(pedidoId, { atualizadoEm: Date.now() }); return null; } }); export const splitPedido = mutation({ args: { pedidoId: v.id('pedidos'), itemIds: v.array(v.id('objetoItems')), numeroSei: v.optional(v.string()), numeroDfd: v.optional(v.string()) }, returns: v.id('pedidos'), handler: async (ctx, args) => { const user = await getUsuarioAutenticado(ctx); if (!user.funcionarioId) { throw new Error('Usuário não vinculado a um funcionário.'); } if (args.itemIds.length === 0) { throw new Error('Selecione ao menos um item para dividir o pedido.'); } const pedidoOriginal = await ctx.db.get(args.pedidoId); if (!pedidoOriginal) { throw new Error('Pedido não encontrado.'); } if (pedidoOriginal.status !== 'em_rascunho' && pedidoOriginal.status !== 'precisa_ajustes') { throw new Error('Só é possível dividir pedidos em rascunho ou que precisam de ajustes.'); } const itens = []; for (const itemId of args.itemIds) { const item = await ctx.db.get(itemId); if (!item) { continue; } if (item.pedidoId !== args.pedidoId) { throw new Error('Todos os itens devem pertencer ao mesmo pedido.'); } if (item.adicionadoPor !== user.funcionarioId) { throw new Error('Você só pode mover itens que você adicionou.'); } itens.push(item); } if (itens.length === 0) { throw new Error('Nenhum dos itens selecionados pôde ser usado para divisão.'); } const novoPedidoId = await ctx.db.insert('pedidos', { numeroSei: args.numeroSei, numeroDfd: args.numeroDfd, status: 'em_rascunho', criadoPor: user._id, criadoEm: Date.now(), atualizadoEm: Date.now() }); for (const item of itens) { await ctx.db.patch(item._id, { pedidoId: novoPedidoId }); } await ctx.db.insert('historicoPedidos', { pedidoId: args.pedidoId, usuarioId: user._id, acao: 'divisao_pedido_origem', detalhes: JSON.stringify({ itensMovidos: itens.map((i) => i._id), novoPedidoId }), data: Date.now() }); await ctx.db.insert('historicoPedidos', { pedidoId: novoPedidoId, usuarioId: user._id, acao: 'divisao_pedido_destino', detalhes: JSON.stringify({ pedidoOriginalId: args.pedidoId, itensRecebidos: itens.map((i) => i._id) }), data: Date.now() }); await ctx.db.patch(args.pedidoId, { atualizadoEm: Date.now() }); return novoPedidoId; } }); export const updateItem = mutation({ args: { itemId: v.id('objetoItems'), valorEstimado: v.string(), modalidade: v.union( v.literal('dispensa'), v.literal('inexgibilidade'), v.literal('adesao'), v.literal('consumo') ), acaoId: v.optional(v.id('acoes')), ataId: v.optional(v.id('atas')) }, returns: v.null(), handler: async (ctx, args) => { const user = await getUsuarioAutenticado(ctx); if (!user.funcionarioId) { throw new Error('Usuário não vinculado a um funcionário.'); } const item = await ctx.db.get(args.itemId); if (!item) throw new Error('Item não encontrado.'); const pedido = await ctx.db.get(item.pedidoId); if (!pedido) throw new Error('Pedido não encontrado.'); // Apenas quem adicionou o item pode editá-lo const isOwner = item.adicionadoPor === user.funcionarioId; if (!isOwner) { throw new Error('Apenas quem adicionou este item pode editá-lo.'); } const oldValues = { valorEstimado: item.valorEstimado, modalidade: item.modalidade, acaoId: 'acaoId' in item ? item.acaoId : undefined, ataId: 'ataId' in item ? item.ataId : undefined }; // Regra global: ao alterar um item, garantir que os demais itens do pedido // continuem (ou passem a estar) com a mesma modalidade e ata. await ensurePedidoModalidadeAtaConsistency( ctx, item.pedidoId, args.modalidade, args.ataId, args.itemId ); // Em pedidos em análise ou aguardando aceite, geramos uma solicitação em vez de alterar diretamente if (pedido.status === 'em_analise' || pedido.status === 'aguardando_aceite') { const oldAtaId = oldValues.ataId; const newAtaId = args.ataId; if (newAtaId && newAtaId !== oldAtaId) { // Não altera consumo aqui (ainda é só solicitação), mas valida limite/configuração. await assertAtaObjetoCanConsume(ctx, newAtaId, item.objetoId, item.quantidade); } await ctx.db.insert('solicitacoesItens', { pedidoId: item.pedidoId, tipo: 'alteracao_detalhes', dados: JSON.stringify({ itemId: args.itemId, de: oldValues, para: { valorEstimado: args.valorEstimado, modalidade: args.modalidade, acaoId: args.acaoId, ataId: args.ataId } }), status: 'pendente', solicitadoPor: user.funcionarioId, criadoEm: Date.now() }); return; } // Se a ata mudou, mover consumo entre vínculos (ata antiga -> nova ata) const oldAtaId = oldValues.ataId; const newAtaId = args.ataId; if ((oldAtaId ?? null) !== (newAtaId ?? null)) { if (newAtaId) { await applyAtaObjetoUsageDelta(ctx, newAtaId, item.objetoId, item.quantidade); } if (oldAtaId) { await applyAtaObjetoUsageDelta(ctx, oldAtaId, item.objetoId, -item.quantidade); } } await ctx.db.patch(args.itemId, { valorEstimado: args.valorEstimado, modalidade: args.modalidade, acaoId: args.acaoId, ataId: args.ataId }); await ctx.db.patch(item.pedidoId, { atualizadoEm: Date.now() }); await ctx.db.insert('historicoPedidos', { pedidoId: item.pedidoId, usuarioId: user._id, acao: 'edicao_item', detalhes: JSON.stringify({ objetoId: item.objetoId, de: oldValues, para: { valorEstimado: args.valorEstimado, modalidade: args.modalidade, acaoId: args.acaoId, ataId: args.ataId } }), data: Date.now() }); } }); export const getPermissions = query({ args: { pedidoId: v.id('pedidos') }, handler: async (ctx, args) => { const user = await getUsuarioAutenticado(ctx); const pedido = await ctx.db.get(args.pedidoId); if (!pedido || !user.funcionarioId) { return { canSendToAcceptance: false, canStartAnalysis: false, canConclude: false, canRequestAdjustments: false, canCancel: false, canCompleteAdjustments: false, canManageRequests: false, currentFuncionarioId: user?.funcionarioId ?? null }; } // Check Compras Sector let isInComprasSector = false; const config = await ctx.db.query('config').first(); if (config && config.comprasSetorId) { const funcionarioSetores = await ctx.db .query('funcionarioSetores') .withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', user.funcionarioId!)) .filter((q) => q.eq(q.field('setorId'), config.comprasSetorId)) .first(); isInComprasSector = !!funcionarioSetores; } // Check if user has added items // Optimized: Check if ANY item in this order was added by this user const userItem = await ctx.db .query('objetoItems') .withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId)) .filter((q) => q.eq(q.field('adicionadoPor'), user.funcionarioId)) .first(); const hasAddedItems = !!userItem; // Verificar se existem itens adicionados por OUTROS usuários. const foreignItem = await ctx.db .query('objetoItems') .withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId)) .filter((q) => q.neq(q.field('adicionadoPor'), user.funcionarioId)) .first(); const hasOnlyCreatorItems = !foreignItem; const isCreator = pedido.criadoPor === user._id; return { canSendToAcceptance: pedido.status === 'em_rascunho' && hasAddedItems, canStartAnalysis: pedido.status === 'aguardando_aceite' && isInComprasSector, canConclude: pedido.status === 'em_analise' && isInComprasSector, canRequestAdjustments: pedido.status === 'em_analise' && isInComprasSector && pedido.aceitoPor === user.funcionarioId, canCompleteAdjustments: pedido.status === 'precisa_ajustes' && hasAddedItems, canCancel: pedido.status !== 'cancelado' && pedido.status !== 'concluido' && isCreator && hasOnlyCreatorItems, canManageRequests: pedido.status === 'em_analise' && isInComprasSector, currentFuncionarioId: user.funcionarioId }; } }); export const enviarParaAceite = mutation({ args: { pedidoId: v.id('pedidos') }, returns: v.null(), handler: async (ctx, args) => { const user = await getUsuarioAutenticado(ctx); if (!user.funcionarioId) throw new Error('Usuário sem funcionário vinculado.'); const pedido = await ctx.db.get(args.pedidoId); if (!pedido) throw new Error('Pedido não encontrado.'); if (pedido.status !== 'em_rascunho') { throw new Error('Status inválido para envio.'); } // Check if user has added items const userItem = await ctx.db .query('objetoItems') .withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId)) .filter((q) => q.eq(q.field('adicionadoPor'), user.funcionarioId)) .first(); if (!userItem) { throw new Error( 'Você precisa ter adicionado ao menos um item ao pedido para enviá-lo para aceite.' ); } // Validar que numeroSei e numeroDfd estão preenchidos if (!pedido.numeroSei || !pedido.numeroSei.trim()) { throw new Error('O número SEI deve ser informado antes de enviar o pedido para aceite.'); } if (!pedido.numeroDfd || !pedido.numeroDfd.trim()) { throw new Error('O número DFD deve ser informado antes de enviar o pedido para aceite.'); } const oldStatus = pedido.status; const newStatus = 'aguardando_aceite'; await ctx.db.patch(args.pedidoId, { status: newStatus, atualizadoEm: Date.now() }); await ctx.db.insert('historicoPedidos', { pedidoId: args.pedidoId, usuarioId: user._id, acao: 'alteracao_status', detalhes: JSON.stringify({ de: oldStatus, para: newStatus }), data: Date.now() }); await ctx.scheduler.runAfter(0, internal.pedidos.notifyStatusChange, { pedidoId: args.pedidoId, oldStatus, newStatus, actorId: user._id }); } }); export const iniciarAnalise = mutation({ args: { pedidoId: v.id('pedidos') }, returns: v.null(), handler: async (ctx, args) => { const user = await getUsuarioAutenticado(ctx); const pedido = await ctx.db.get(args.pedidoId); if (!pedido) throw new Error('Pedido não encontrado.'); if (pedido.status !== 'aguardando_aceite') { throw new Error('O pedido não está aguardando aceite.'); } // Security Check: Must be in Compras Sector const config = await ctx.db.query('config').first(); if (!config || !config.comprasSetorId) throw new Error('Setor de compras não configurado.'); const isInSector = await ctx.db .query('funcionarioSetores') .withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', user.funcionarioId!)) .filter((q) => q.eq(q.field('setorId'), config.comprasSetorId)) .first(); if (!isInSector) throw new Error('Acesso não autorizado (Setor de Compras).'); const oldStatus = pedido.status; const newStatus = 'em_analise'; await ctx.db.patch(args.pedidoId, { status: newStatus, aceitoPor: user.funcionarioId, // Also mark as accepted by this user atualizadoEm: Date.now() }); await ctx.db.insert('historicoPedidos', { pedidoId: args.pedidoId, usuarioId: user._id, acao: 'alteracao_status', detalhes: JSON.stringify({ de: oldStatus, para: newStatus }), data: Date.now() }); await ctx.scheduler.runAfter(0, internal.pedidos.notifyStatusChange, { pedidoId: args.pedidoId, oldStatus, newStatus, actorId: user._id }); } }); export const concluirPedido = mutation({ args: { pedidoId: v.id('pedidos') }, returns: v.null(), handler: async (ctx, args) => { const user = await getUsuarioAutenticado(ctx); const pedido = await ctx.db.get(args.pedidoId); if (!pedido) throw new Error('Pedido não encontrado.'); if (pedido.status !== 'em_analise') { throw new Error('O pedido deve estar em análise para ser concluído.'); } // Security Check: Must be in Compras Sector const config = await ctx.db.query('config').first(); if (!config || !config.comprasSetorId) throw new Error('Setor de compras não configurado.'); const isInSector = await ctx.db .query('funcionarioSetores') .withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', user.funcionarioId!)) .filter((q) => q.eq(q.field('setorId'), config.comprasSetorId)) .first(); if (!isInSector) throw new Error('Acesso não autorizado (Setor de Compras).'); const oldStatus = pedido.status; const newStatus = 'concluido'; await ctx.db.patch(args.pedidoId, { status: newStatus, concluidoEm: Date.now(), atualizadoEm: Date.now() }); await ctx.db.insert('historicoPedidos', { pedidoId: args.pedidoId, usuarioId: user._id, acao: 'alteracao_status', detalhes: JSON.stringify({ de: oldStatus, para: newStatus }), data: Date.now() }); await ctx.scheduler.runAfter(0, internal.pedidos.notifyStatusChange, { pedidoId: args.pedidoId, oldStatus, newStatus, actorId: user._id }); } }); export const solicitarAjustes = mutation({ args: { pedidoId: v.id('pedidos'), descricao: v.string() }, returns: v.null(), handler: async (ctx, args) => { const user = await getUsuarioAutenticado(ctx); const pedido = await ctx.db.get(args.pedidoId); if (!pedido) throw new Error('Pedido não encontrado.'); if (pedido.status !== 'em_analise') { throw new Error('O pedido deve estar em análise para solicitar ajustes.'); } // Security Check: Only the user who accepted the order can request adjustments if (pedido.aceitoPor !== user.funcionarioId) { throw new Error('Apenas o funcionário que aceitou o pedido pode solicitar ajustes.'); } const oldStatus = pedido.status; const newStatus = 'precisa_ajustes'; await ctx.db.patch(args.pedidoId, { status: newStatus, descricaoAjuste: args.descricao, atualizadoEm: Date.now() }); await ctx.db.insert('historicoPedidos', { pedidoId: args.pedidoId, usuarioId: user._id, acao: 'solicitacao_ajuste', detalhes: JSON.stringify({ de: oldStatus, para: newStatus, descricao: args.descricao }), data: Date.now() }); await ctx.scheduler.runAfter(0, internal.pedidos.notifyStatusChange, { pedidoId: args.pedidoId, oldStatus, newStatus, actorId: user._id, customMessage: args.descricao }); } }); export const concluirAjustes = mutation({ args: { pedidoId: v.id('pedidos') }, returns: v.null(), handler: async (ctx, args) => { const user = await getUsuarioAutenticado(ctx); if (!user.funcionarioId) throw new Error('Usuário sem funcionário vinculado.'); const pedido = await ctx.db.get(args.pedidoId); if (!pedido) throw new Error('Pedido não encontrado.'); if (pedido.status !== 'precisa_ajustes') { throw new Error('O pedido não está aguardando ajustes.'); } // Security Check: Must have items in the order const requestorItem = await ctx.db .query('objetoItems') .withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId)) .filter((q) => q.eq(q.field('adicionadoPor'), user.funcionarioId)) .first(); if (!requestorItem) { throw new Error('Você deve ter itens no pedido para concluir os ajustes.'); } const oldStatus = pedido.status; const newStatus = 'em_analise'; const descricaoAnterior = pedido.descricaoAjuste; await ctx.db.patch(args.pedidoId, { status: newStatus, descricaoAjuste: undefined, // Clear the adjustment description from the active field atualizadoEm: Date.now() }); await ctx.db.insert('historicoPedidos', { pedidoId: args.pedidoId, usuarioId: user._id, acao: 'conclusao_ajustes', detalhes: JSON.stringify({ de: oldStatus, para: newStatus, descricaoResolvida: descricaoAnterior }), data: Date.now() }); await ctx.scheduler.runAfter(0, internal.pedidos.notifyStatusChange, { pedidoId: args.pedidoId, oldStatus, newStatus, actorId: user._id, customMessage: 'Ajustes concluídos. O pedido retornou para análise.' }); } }); export const cancelarPedido = mutation({ args: { pedidoId: v.id('pedidos') }, returns: v.null(), handler: async (ctx, args) => { const user = await getUsuarioAutenticado(ctx); const pedido = await ctx.db.get(args.pedidoId); if (!pedido) throw new Error('Pedido não encontrado.'); if (pedido.status === 'concluido' || pedido.status === 'cancelado') { throw new Error('Pedido já finalizado.'); } // Regra: apenas o criador pode cancelar o pedido const isCreator = pedido.criadoPor === user._id; if (!isCreator) { throw new Error('Apenas quem criou este pedido pode cancelá-lo.'); } if (!user.funcionarioId) { throw new Error('Usuário sem funcionário vinculado. Não é possível cancelar este pedido.'); } // Regra extra: o pedido só pode ser cancelado se todos os itens // tiverem sido adicionados pelo mesmo funcionário do criador. const foreignItem = await ctx.db .query('objetoItems') .withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId)) .filter((q) => q.neq(q.field('adicionadoPor'), user.funcionarioId)) .first(); if (foreignItem) { throw new Error( 'Não é possível cancelar este pedido porque há itens adicionados por outros usuários.' ); } const oldStatus = pedido.status; const newStatus = 'cancelado'; // Ao cancelar, liberar consumo de todas as combinações (ataId,objetoId) deste pedido const items = await ctx.db .query('objetoItems') .withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId)) .collect(); for (const item of items) { if (item.ataId) { await applyAtaObjetoUsageDelta(ctx, item.ataId, item.objetoId, -item.quantidade); } } await ctx.db.patch(args.pedidoId, { status: newStatus, atualizadoEm: Date.now() }); await ctx.db.insert('historicoPedidos', { pedidoId: args.pedidoId, usuarioId: user._id, acao: 'alteracao_status', detalhes: JSON.stringify({ de: oldStatus, para: newStatus }), data: Date.now() }); await ctx.scheduler.runAfter(0, internal.pedidos.notifyStatusChange, { pedidoId: args.pedidoId, oldStatus, newStatus, actorId: user._id }); } }); // ========== INTERNAL (NOTIFICATIONS) ========== export const notifyStatusChange = internalMutation({ args: { pedidoId: v.id('pedidos'), oldStatus: v.string(), newStatus: v.string(), actorId: v.id('usuarios'), customMessage: v.optional(v.string()) }, returns: v.null(), handler: async (ctx, args) => { const pedido = await ctx.db.get(args.pedidoId); if (!pedido) return; const actor = await ctx.db.get(args.actorId); const actorName = actor ? actor.nome : 'Alguém'; const recipients = new Set(); // Set of User IDs // 1. If status is "aguardando_aceite", notify Purchasing Sector if (args.newStatus === 'aguardando_aceite') { const config = await ctx.db.query('config').first(); if (config && config.comprasSetorId) { // Find all employees in this sector const funcionarioSetores = await ctx.db .query('funcionarioSetores') .withIndex('by_setorId', (q) => q.eq('setorId', config.comprasSetorId!)) .collect(); const funcionarioIds = funcionarioSetores.map((fs) => fs.funcionarioId); // Find users linked to these employees for (const fId of funcionarioIds) { const user = await ctx.db .query('usuarios') .withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', fId)) .first(); if (user) recipients.add(user._id); } } } // 2. Notify "Involved" users (Creator + Item Adders) // Always notify creator (unless they are the actor) if (pedido.criadoPor !== args.actorId) { recipients.add(pedido.criadoPor); } // Notify item adders const items = await ctx.db .query('objetoItems') .withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId)) .collect(); for (const item of items) { const user = await ctx.db .query('usuarios') .withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', item.adicionadoPor)) .first(); if (user && user._id !== args.actorId) { recipients.add(user._id); } } let description = `Status alterado de "${args.oldStatus}" para "${args.newStatus}" por ${actorName}.`; if (args.customMessage) { description += `\n\nDetalhes: ${args.customMessage}`; } // Send Notifications for (const recipientId of recipients) { const recipientIdTyped = recipientId as Id<'usuarios'>; // 1. In-App Notification await ctx.db.insert('notificacoes', { usuarioId: recipientIdTyped, tipo: 'alerta_seguranca', // Using alerta_seguranca as the closest match for system notifications titulo: `Pedido ${pedido.numeroSei || 'sem número SEI'} atualizado`, descricao: description, lida: false, criadaEm: Date.now(), remetenteId: args.actorId }); // 2. Email Notification (Async) const recipientUser = await ctx.db.get(recipientIdTyped); if (recipientUser && recipientUser.email) { // Using enfileirarEmail directly await ctx.scheduler.runAfter(0, api.email.enfileirarEmail, { destinatario: recipientUser.email, destinatarioId: recipientIdTyped, assunto: `Atualização no Pedido ${pedido.numeroSei || 'sem número SEI'}`, corpo: `O pedido ${pedido.numeroSei || 'sem número SEI'} teve seu status alterado de "${args.oldStatus}" para "${args.newStatus}" por ${actorName}.

${args.customMessage ? `Detalhes: ${args.customMessage}` : ''}`, enviadoPor: args.actorId }); } } } }); // ========== REQUESTS MANAGEMENT ========== export const getItemRequests = query({ args: { pedidoId: v.id('pedidos') }, handler: async (ctx, args) => { const requests = await ctx.db .query('solicitacoesItens') .withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId)) .filter((q) => q.eq(q.field('status'), 'pendente')) // Only pending for now? Or history? User said "manter um historico" // Actually the table has status. I should probably return all, or just pending for the main list. // The prompt says "quando aceita... exclua as solicitacoes" (or "manter um historico"?). // Prompt: "quando aceita... adicione os items automaticamente, e exclua as solicitacoes. deve tambem manter um historico." // "Exclua as solicitacoes" implies deleting the row from 'solicitacoesItens' OR just marking as processed. // "Manter um historico" might refer to 'historicoPedidos' (the central history). // I'll stick to marking as approved/rejected in 'solicitacoesItens' OR deleting and logging to 'historicoPedidos'. // But for now, let's just return pending ones for the UI to be clean. .collect(); // Enrich names return await Promise.all( requests.map(async (r) => { const func = await ctx.db.get(r.solicitadoPor); return { ...r, solicitadoPorNome: func?.nome || 'Desconhecido' }; }) ); } }); export const approveItemRequest = mutation({ args: { requestId: v.id('solicitacoesItens') }, returns: v.null(), handler: async (ctx, args) => { const user = await getUsuarioAutenticado(ctx); const funcionarioId = user.funcionarioId; if (!funcionarioId) throw new Error('Usuário sem funcionário vinculado.'); const request = await ctx.db.get(args.requestId); if (!request) throw new Error('Solicitação não encontrada.'); const pedido = await ctx.db.get(request.pedidoId); if (!pedido) throw new Error('Pedido não encontrado.'); // Security: Must be Compras Sector AND accepted by this user (or just in sector?) // Usually Analysis rights. const config = await ctx.db.query('config').first(); if (!config || !config.comprasSetorId) throw new Error('Setor de compras não configurado.'); const isInSector = await ctx.db .query('funcionarioSetores') .withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', funcionarioId)) .filter((q) => q.eq(q.field('setorId'), config.comprasSetorId)) .first(); if (!isInSector) throw new Error('Acesso negado.'); // Documentos anexados à solicitação (se houver) devem migrar para o pedido ao aprovar. const solicitacaoDocs = await ctx.db .query('solicitacoesItensDocumentos') .withIndex('by_requestId', (q) => q.eq('requestId', request._id)) .collect(); // Apply the change const data = JSON.parse(request.dados); if (request.tipo === 'adicao') { // Reuse addItem logic (simplified: insert or increment) // We trust the request data structure matches addItem args const newItem = data; // Regra global: todos os itens do pedido devem ter a mesma // modalidade e ata (quando houver). await ensurePedidoModalidadeAtaConsistency( ctx, request.pedidoId, newItem.modalidade, newItem.ataId ); // Note: We MUST use the original requester's ID (request.solicitadoPor) as addedBy? // Or should we attribute it to the requester? YES. // BUT `addItem` logic usually checks if `existingItem.adicionadoPor === user`. // Since we want to merge with the requester's items, we should check `request.solicitadoPor`. const userProductItems = await ctx.db .query('objetoItems') .withIndex('by_pedidoId', (q) => q.eq('pedidoId', request.pedidoId)) .filter((q) => q.and( q.eq(q.field('objetoId'), newItem.objetoId), q.eq(q.field('adicionadoPor'), request.solicitadoPor) ) ) .collect(); // Duplicate check (should have been caught at request time? Not necessarily if state changed, but let's re-verify or just proceed) // If conflict exists now, we might error or just merge. // Let's assume strict check: const conflict = userProductItems.find( (i) => i.modalidade !== newItem.modalidade || i.ataId !== newItem.ataId ); if (conflict) { // Reject strictly? Or throw error? throw new Error('Conflito detectado: Item com configuração diferente já existe.'); } const existingItem = userProductItems.find( (i) => i.acaoId === newItem.acaoId && i.ataId === newItem.ataId && i.modalidade === newItem.modalidade ); if (newItem.ataId) { await applyAtaObjetoUsageDelta(ctx, newItem.ataId, newItem.objetoId, newItem.quantidade); } if (existingItem) { await ctx.db.patch(existingItem._id, { quantidade: existingItem.quantidade + newItem.quantidade }); // Logs attached to REQUESTER or APPROVER? Usually Approver took action, but it was on behalf of requester. // Let's log 'aprovacao_solicitacao' } else { await ctx.db.insert('objetoItems', { pedidoId: request.pedidoId, objetoId: newItem.objetoId, ataId: newItem.ataId, acaoId: newItem.acaoId, modalidade: newItem.modalidade, valorEstimado: newItem.valorEstimado, quantidade: newItem.quantidade, adicionadoPor: request.solicitadoPor, // Important: Attribute to requester criadoEm: Date.now() }); } } else if (request.tipo === 'alteracao_quantidade') { const { itemId, novaQuantidade } = data as { itemId: Id<'objetoItems'>; novaQuantidade: number; }; const item = (await ctx.db.get(itemId)) as Doc<'objetoItems'> | null; if (item) { const delta = novaQuantidade - item.quantidade; if (item.ataId) { await applyAtaObjetoUsageDelta(ctx, item.ataId, item.objetoId, delta); } await ctx.db.patch(itemId, { quantidade: novaQuantidade }); } } else if (request.tipo === 'exclusao') { const { itemId } = data as { itemId: Id<'objetoItems'> }; const item = (await ctx.db.get(itemId)) as Doc<'objetoItems'> | null; if (item) { if (item.ataId) { await applyAtaObjetoUsageDelta(ctx, item.ataId, item.objetoId, -item.quantidade); } await ctx.db.delete(itemId); } } else if (request.tipo === 'alteracao_detalhes') { const { itemId, para } = data as { itemId: Id<'objetoItems'>; para: { valorEstimado: string; modalidade: Doc<'objetoItems'>['modalidade']; acaoId?: Id<'acoes'>; ataId?: Id<'atas'>; }; }; const item = await ctx.db.get(itemId); if (item) { // Regra global também se aplica na alteração de detalhes aprovada await ensurePedidoModalidadeAtaConsistency( ctx, item.pedidoId, para.modalidade, para.ataId, itemId ); const oldAtaId = ('ataId' in item ? item.ataId : undefined) ?? undefined; const newAtaId = para.ataId; if ((oldAtaId ?? null) !== (newAtaId ?? null)) { if (newAtaId) { await applyAtaObjetoUsageDelta(ctx, newAtaId, item.objetoId, item.quantidade); } if (oldAtaId) { await applyAtaObjetoUsageDelta(ctx, oldAtaId, item.objetoId, -item.quantidade); } } await ctx.db.patch(itemId, { valorEstimado: para.valorEstimado, modalidade: para.modalidade, acaoId: para.acaoId, ataId: para.ataId }); } } // Update request status await ctx.db.patch(request._id, { status: 'aprovado' }); // OR delete it? "exclua as solicitacoes". // I'll delete it to keep table clean as requested. await ctx.db.delete(request._id); // Migrar docs: cria em pedidoDocumentos e remove os registros da solicitacao const documentosMigrados: Array<{ nome: string; descricao: string }> = []; if (solicitacaoDocs.length > 0) { for (const doc of solicitacaoDocs) { await ctx.db.insert('pedidoDocumentos', { pedidoId: request.pedidoId, descricao: doc.descricao, nome: doc.nome, storageId: doc.storageId, tipo: doc.tipo, tamanho: doc.tamanho, criadoPor: doc.criadoPor, criadoEm: doc.criadoEm, origemSolicitacaoId: request._id }); documentosMigrados.push({ nome: doc.nome, descricao: doc.descricao }); await ctx.db.delete(doc._id); } } // History await ctx.db.insert('historicoPedidos', { pedidoId: request.pedidoId, usuarioId: user._id, acao: 'aprovacao_solicitacao', detalhes: JSON.stringify({ requestId: request._id, tipo: request.tipo, solicitadoPor: request.solicitadoPor, dados: data, documentosMigrados }), data: Date.now() }); await ctx.db.patch(request.pedidoId, { atualizadoEm: Date.now() }); } }); export const rejectItemRequest = mutation({ args: { requestId: v.id('solicitacoesItens') }, returns: v.null(), handler: async (ctx, args) => { const user = await getUsuarioAutenticado(ctx); const funcionarioId = user.funcionarioId; if (!funcionarioId) throw new Error('Usuário sem funcionário vinculado.'); const request = await ctx.db.get(args.requestId); if (!request) throw new Error('Solicitação não encontrada.'); const config = await ctx.db.query('config').first(); if (!config || !config.comprasSetorId) throw new Error('Config Error'); const isInSector = await ctx.db .query('funcionarioSetores') .withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', funcionarioId)) .filter((q) => q.eq(q.field('setorId'), config.comprasSetorId)) .first(); if (!isInSector) throw new Error('Acesso negado.'); // Remover documentos anexados à solicitação (para não deixar órfãos no storage) const solicitacaoDocs = await ctx.db .query('solicitacoesItensDocumentos') .withIndex('by_requestId', (q) => q.eq('requestId', request._id)) .collect(); const documentosRemovidos: Array<{ nome: string; descricao: string }> = []; for (const doc of solicitacaoDocs) { documentosRemovidos.push({ nome: doc.nome, descricao: doc.descricao }); await ctx.storage.delete(doc.storageId); await ctx.db.delete(doc._id); } // Delete request await ctx.db.delete(args.requestId); // History await ctx.db.insert('historicoPedidos', { pedidoId: request.pedidoId, usuarioId: user._id, acao: 'rejeicao_solicitacao', detalhes: JSON.stringify({ requestId: request._id, tipo: request.tipo, solicitadoPor: request.solicitadoPor, documentosRemovidos }), data: Date.now() }); } }); // ========== DOCUMENTOS (PEDIDO / SOLICITAÇÃO) ========== export const generatePedidoUploadUrl = mutation({ args: { pedidoId: v.id('pedidos') }, returns: v.string(), handler: async (ctx, args) => { const user = await getUsuarioAutenticado(ctx); await assertPodeGerenciarDocumentosDoPedido(ctx, args.pedidoId, user); return await ctx.storage.generateUploadUrl(); } }); export const addPedidoDocumento = mutation({ args: { pedidoId: v.id('pedidos'), descricao: v.string(), nome: v.string(), storageId: v.id('_storage'), tipo: v.string(), tamanho: v.number(), origemSolicitacaoId: v.optional(v.id('solicitacoesItens')) }, returns: v.id('pedidoDocumentos'), handler: async (ctx, args) => { const user = await getUsuarioAutenticado(ctx); await assertPodeGerenciarDocumentosDoPedido(ctx, args.pedidoId, user); if (!user.funcionarioId) { throw new Error('Usuário sem funcionário vinculado.'); } // Garantir que o pedido existe const pedido = await ctx.db.get(args.pedidoId); if (!pedido) throw new Error('Pedido não encontrado.'); return await ctx.db.insert('pedidoDocumentos', { pedidoId: args.pedidoId, descricao: args.descricao, nome: args.nome, storageId: args.storageId, tipo: args.tipo, tamanho: args.tamanho, criadoPor: user.funcionarioId, criadoEm: Date.now(), origemSolicitacaoId: args.origemSolicitacaoId }); } }); export const listPedidoDocumentos = query({ args: { pedidoId: v.id('pedidos') }, handler: async (ctx, args) => { const user = await getUsuarioAutenticado(ctx); await assertPodeGerenciarDocumentosDoPedido(ctx, args.pedidoId, user); const docs = await ctx.db .query('pedidoDocumentos') .withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId)) .order('desc') .collect(); return await Promise.all( docs.map(async (doc) => { const url = await ctx.storage.getUrl(doc.storageId); const func = await ctx.db.get(doc.criadoPor); return { ...doc, criadoPorNome: func?.nome ?? 'Desconhecido', url }; }) ); } }); export const removePedidoDocumento = mutation({ args: { id: v.id('pedidoDocumentos') }, returns: v.null(), handler: async (ctx, args) => { const user = await getUsuarioAutenticado(ctx); const doc = await ctx.db.get(args.id); if (!doc) throw new Error('Documento não encontrado.'); // Pode remover se for o autor OU se for envolvido/compras if (!user.funcionarioId || doc.criadoPor !== user.funcionarioId) { await assertPodeGerenciarDocumentosDoPedido(ctx, doc.pedidoId, user); } await ctx.storage.delete(doc.storageId); await ctx.db.delete(doc._id); return null; } }); export const generateSolicitacaoUploadUrl = mutation({ args: { requestId: v.id('solicitacoesItens') }, returns: v.string(), handler: async (ctx, args) => { const user = await getUsuarioAutenticado(ctx); if (!user.funcionarioId) throw new Error('Usuário sem funcionário vinculado.'); const request = await ctx.db.get(args.requestId); if (!request) throw new Error('Solicitação não encontrada.'); if (request.tipo !== 'adicao') { throw new Error('Apenas solicitações de adição permitem anexar documentos.'); } if (request.status !== 'pendente') { throw new Error('Não é possível anexar documentos em uma solicitação não pendente.'); } if (request.solicitadoPor !== user.funcionarioId) { throw new Error('Apenas quem criou a solicitação pode anexar documentos.'); } return await ctx.storage.generateUploadUrl(); } }); export const addSolicitacaoDocumento = mutation({ args: { requestId: v.id('solicitacoesItens'), descricao: v.string(), nome: v.string(), storageId: v.id('_storage'), tipo: v.string(), tamanho: v.number() }, returns: v.id('solicitacoesItensDocumentos'), handler: async (ctx, args) => { const user = await getUsuarioAutenticado(ctx); if (!user.funcionarioId) throw new Error('Usuário sem funcionário vinculado.'); const request = await ctx.db.get(args.requestId); if (!request) throw new Error('Solicitação não encontrada.'); if (request.tipo !== 'adicao') { throw new Error('Apenas solicitações de adição permitem anexar documentos.'); } if (request.status !== 'pendente') { throw new Error('Não é possível anexar documentos em uma solicitação não pendente.'); } if (request.solicitadoPor !== user.funcionarioId) { throw new Error('Apenas quem criou a solicitação pode anexar documentos.'); } return await ctx.db.insert('solicitacoesItensDocumentos', { requestId: args.requestId, pedidoId: request.pedidoId, descricao: args.descricao, nome: args.nome, storageId: args.storageId, tipo: args.tipo, tamanho: args.tamanho, criadoPor: user.funcionarioId, criadoEm: Date.now() }); } }); export const listSolicitacaoDocumentos = query({ args: { requestId: v.id('solicitacoesItens') }, handler: async (ctx, args) => { const user = await getUsuarioAutenticado(ctx); const request = await ctx.db.get(args.requestId); if (!request) return []; await assertPodeGerenciarDocumentosDoPedido(ctx, request.pedidoId, user); const docs = await ctx.db .query('solicitacoesItensDocumentos') .withIndex('by_requestId', (q) => q.eq('requestId', args.requestId)) .order('desc') .collect(); return await Promise.all( docs.map(async (doc) => { const url = await ctx.storage.getUrl(doc.storageId); const func = await ctx.db.get(doc.criadoPor); return { ...doc, criadoPorNome: func?.nome ?? 'Desconhecido', url }; }) ); } }); export const removeSolicitacaoDocumento = mutation({ args: { id: v.id('solicitacoesItensDocumentos') }, returns: v.null(), handler: async (ctx, args) => { const user = await getUsuarioAutenticado(ctx); if (!user.funcionarioId) throw new Error('Usuário sem funcionário vinculado.'); const doc = await ctx.db.get(args.id); if (!doc) throw new Error('Documento não encontrado.'); const request = await ctx.db.get(doc.requestId); if (!request) throw new Error('Solicitação não encontrada.'); if (request.tipo !== 'adicao') { throw new Error('Apenas solicitações de adição permitem documentos.'); } if (request.status !== 'pendente') { throw new Error('Não é possível remover documentos de uma solicitação não pendente.'); } if (doc.criadoPor !== user.funcionarioId || request.solicitadoPor !== user.funcionarioId) { throw new Error('Apenas quem criou a solicitação pode remover documentos.'); } await ctx.storage.delete(doc.storageId); await ctx.db.delete(doc._id); return null; } });