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 ========== async function getUsuarioAutenticado(ctx: QueryCtx | MutationCtx) { const user = await getCurrentUserFunction(ctx); if (!user) throw new Error('Unauthorized'); return user; } // ========== QUERIES ========== export const list = query({ args: {}, 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') ), // acaoId removed from return criadoPor: v.id('usuarios'), aceitoPor: v.optional(v.id('funcionarios')), descricaoAjuste: v.optional(v.string()), criadoEm: v.number(), atualizadoEm: v.number() }) ), handler: async (ctx) => { return await ctx.db.query('pedidos').collect(); } }); 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()), 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: v.optional(v.id('acoes')), criadoPor: v.id('usuarios'), aceitoPor: v.optional(v.id('funcionarios')), descricaoAjuste: v.optional(v.string()), 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()), 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, 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()), 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, 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()), 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, 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: {}, returns: v.array( v.object({ _id: v.id('pedidos'), _creationTime: v.number(), numeroSei: 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); 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); return await Promise.all( validOrders.map(async (o) => { const creator = await ctx.db.get(o!.criadoPor); return { _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 }; }) ); } }); 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.'); 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()) // 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, 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 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.'); // --- CHECK ANALYSIS / ACCEPTANCE MODE --- if (pedido.status === 'em_analise' || pedido.status === 'aguardando_aceite') { 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( 'Você já adicionou este item com outra modalidade ou ata. Não é permitido adicionar o mesmo produto com configurações diferentes neste pedido.' ); } // 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) { // 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 { // 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.'); // --- CHECK ANALYSIS / ACCEPTANCE MODE --- if (pedido.status === 'em_analise' || pedido.status === 'aguardando_aceite') { 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.'); } // 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; } 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.'); } 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()) }, 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, 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 }; // 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') { 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; } 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 }; } // 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; const isCreator = pedido.criadoPor === user._id; return { canSendToAcceptance: (pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes') && 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, canManageRequests: pedido.status === 'em_analise' && isInComprasSector }; } }); 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' && pedido.status !== 'precisa_ajustes') { 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.' ); } 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, 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.'); } // Anyone involved (creator or compras) can cancel? Or just creator? // Logic: If it's creator OR Compras. const isCreator = pedido.criadoPor === user._id; let isCompras = false; const config = await ctx.db.query('config').first(); if (config && config.comprasSetorId && user.funcionarioId) { const fs = await ctx.db .query('funcionarioSetores') .withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', user.funcionarioId!)) .filter((q) => q.eq(q.field('setorId'), config.comprasSetorId)) .first(); isCompras = !!fs; } if (!isCreator && !isCompras) { throw new Error('Permissão negada para cancelar este pedido.'); } const oldStatus = pedido.status; const newStatus = 'cancelado'; 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 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', user.funcionarioId!)) .filter((q) => q.eq(q.field('setorId'), config.comprasSetorId)) .first(); if (!isInSector) throw new Error('Acesso negado.'); // 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; // 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 (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; const item = await ctx.db.get(itemId); if (item) { await ctx.db.patch(itemId, { quantidade: novaQuantidade }); } } else if (request.tipo === 'exclusao') { const { itemId } = data; const item = await ctx.db.get(itemId); if (item) { 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) { 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); // History await ctx.db.insert('historicoPedidos', { pedidoId: request.pedidoId, usuarioId: user._id, acao: 'aprovacao_solicitacao', detalhes: JSON.stringify({ tipo: request.tipo, solicitadoPor: request.solicitadoPor, dados: data }), 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 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', user.funcionarioId!)) .filter((q) => q.eq(q.field('setorId'), config.comprasSetorId)) .first(); if (!isInSector) throw new Error('Acesso negado.'); // 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({ tipo: request.tipo, solicitadoPor: request.solicitadoPor }), data: Date.now() }); } });