import { v } from 'convex/values'; import type { Doc, Id } from './_generated/dataModel'; import type { MutationCtx, QueryCtx } from './_generated/server'; import { internalMutation, internalAction, internalQuery, mutation, query } from './_generated/server'; import { internal } from './_generated/api'; import { api } from './_generated/api'; import { getCurrentUserFunction } from './auth'; import { alertaStatus, alertaTipo, movimentacaoTipo, requisicaoStatus } from './tables/almoxarifado'; // ========== QUERIES ========== export const listarMateriais = query({ args: { categoria: v.optional(v.string()), ativo: v.optional(v.boolean()), estoqueBaixo: v.optional(v.boolean()), busca: v.optional(v.string()) }, handler: async (ctx, args) => { const usuario = await getCurrentUserFunction(ctx); if (!usuario) return []; try { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { recurso: 'almoxarifado', acao: 'listar' }); } catch { return []; } let materiais; if (args.ativo !== undefined) { materiais = await ctx.db .query('materiais') .withIndex('by_ativo', (q) => q.eq('ativo', args.ativo!)) .collect(); } else if (args.categoria) { materiais = await ctx.db .query('materiais') .withIndex('by_categoria', (q) => q.eq('categoria', args.categoria!)) .collect(); } else { materiais = await ctx.db.query('materiais').collect(); } // Filtros adicionais if (args.busca) { const buscaLower = args.busca.toLowerCase(); materiais = materiais.filter( (m) => m.codigo.toLowerCase().includes(buscaLower) || m.nome.toLowerCase().includes(buscaLower) ); } if (args.estoqueBaixo) { materiais = materiais.filter((m) => m.estoqueAtual <= m.estoqueMinimo); } return materiais; } }); export const obterMaterial = query({ args: { id: v.id('materiais') }, handler: async (ctx, args) => { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { recurso: 'almoxarifado', acao: 'listar' }); const material = await ctx.db.get(args.id); if (!material) throw new Error('Material não encontrado'); return material; } }); export const listarMovimentacoes = query({ args: { materialId: v.optional(v.id('materiais')), tipo: v.optional(movimentacaoTipo), dataInicio: v.optional(v.number()), dataFim: v.optional(v.number()), funcionarioId: v.optional(v.id('funcionarios')) }, handler: async (ctx, args) => { const usuario = await getCurrentUserFunction(ctx); if (!usuario) return []; try { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { recurso: 'almoxarifado', acao: 'listar' }); } catch { return []; } let movimentacoes; if (args.materialId) { movimentacoes = await ctx.db .query('movimentacoesEstoque') .withIndex('by_materialId', (q) => q.eq('materialId', args.materialId!)) .collect(); } else if (args.tipo) { movimentacoes = await ctx.db .query('movimentacoesEstoque') .withIndex('by_tipo', (q) => q.eq('tipo', args.tipo!)) .collect(); } else if (args.funcionarioId) { movimentacoes = await ctx.db .query('movimentacoesEstoque') .withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', args.funcionarioId!)) .collect(); } else { movimentacoes = await ctx.db .query('movimentacoesEstoque') .withIndex('by_data') .collect(); } // Filtros de data if (args.dataInicio) { movimentacoes = movimentacoes.filter((m) => m.data >= args.dataInicio!); } if (args.dataFim) { movimentacoes = movimentacoes.filter((m) => m.data <= args.dataFim!); } // Ordenar por data (mais recente primeiro) movimentacoes.sort((a, b) => b.data - a.data); return movimentacoes; } }); export const listarRequisicoes = query({ args: { status: v.optional(requisicaoStatus), solicitanteId: v.optional(v.id('funcionarios')), setorId: v.optional(v.id('setores')) }, handler: async (ctx, args) => { const usuario = await getCurrentUserFunction(ctx); if (!usuario) return []; try { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { recurso: 'almoxarifado', acao: 'listar' }); } catch { return []; } let requisicoes; if (args.status) { requisicoes = await ctx.db .query('requisicoesMaterial') .withIndex('by_status', (q) => q.eq('status', args.status!)) .collect(); } else if (args.solicitanteId) { requisicoes = await ctx.db .query('requisicoesMaterial') .withIndex('by_solicitanteId', (q) => q.eq('solicitanteId', args.solicitanteId!)) .collect(); } else if (args.setorId) { requisicoes = await ctx.db .query('requisicoesMaterial') .withIndex('by_setorId', (q) => q.eq('setorId', args.setorId!)) .collect(); } else { requisicoes = await ctx.db.query('requisicoesMaterial').collect(); } // Ordenar por data de criação (mais recente primeiro) requisicoes.sort((a, b) => b.criadoEm - a.criadoEm); return requisicoes; } }); export const obterRequisicao = query({ args: { id: v.id('requisicoesMaterial') }, handler: async (ctx, args) => { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { recurso: 'almoxarifado', acao: 'listar' }); const requisicao = await ctx.db.get(args.id); if (!requisicao) throw new Error('Requisição não encontrada'); // Buscar itens da requisição const itens = await ctx.db .query('requisicaoItens') .withIndex('by_requisicaoId', (q) => q.eq('requisicaoId', args.id)) .collect(); return { ...requisicao, itens }; } }); export const listarAlertas = query({ args: { status: v.optional(alertaStatus), tipo: v.optional(alertaTipo) }, handler: async (ctx, args) => { const usuario = await getCurrentUserFunction(ctx); if (!usuario) return []; try { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { recurso: 'almoxarifado', acao: 'listar' }); } catch { return []; } let alertas; if (args.status) { alertas = await ctx.db .query('alertasEstoque') .withIndex('by_status', (q) => q.eq('status', args.status!)) .collect(); } else if (args.tipo) { alertas = await ctx.db .query('alertasEstoque') .withIndex('by_tipo', (q) => q.eq('tipo', args.tipo!)) .collect(); } else { alertas = await ctx.db.query('alertasEstoque').collect(); } // Ordenar por data de criação (mais recente primeiro) alertas.sort((a, b) => b.criadoEm - a.criadoEm); return alertas; } }); export const obterHistorico = query({ args: { tipoEntidade: v.string(), entidadeId: v.string() }, handler: async (ctx, args) => { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { recurso: 'almoxarifado', acao: 'listar' }); const historico = await ctx.db .query('historicoAlteracoes') .withIndex('by_tipoEntidade', (q) => q.eq('tipoEntidade', args.tipoEntidade)) .filter((q) => q.eq(q.field('entidadeId'), args.entidadeId)) .collect(); // Ordenar por timestamp (mais recente primeiro) historico.sort((a, b) => b.timestamp - a.timestamp); return historico; } }); export const obterEstatisticas = query({ args: {}, handler: async (ctx) => { const usuario = await getCurrentUserFunction(ctx); if (!usuario) { return { totalMateriais: 0, totalMateriaisAtivos: 0, totalAlertasAtivos: 0, movimentacoesMes: 0, materiaisEstoqueBaixo: 0 }; } try { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { recurso: 'almoxarifado', acao: 'listar' }); } catch { return { totalMateriais: 0, totalMateriaisAtivos: 0, totalAlertasAtivos: 0, movimentacoesMes: 0, materiaisEstoqueBaixo: 0 }; } const materiais = await ctx.db.query('materiais').collect(); const materiaisAtivos = materiais.filter((m) => m.ativo); const materiaisEstoqueBaixo = materiais.filter((m) => m.estoqueAtual <= m.estoqueMinimo); const alertasAtivos = await ctx.db .query('alertasEstoque') .withIndex('by_status', (q) => q.eq('status', 'ativo')) .collect(); const agora = Date.now(); const inicioMes = new Date(agora); inicioMes.setDate(1); inicioMes.setHours(0, 0, 0, 0); const movimentacoes = await ctx.db .query('movimentacoesEstoque') .withIndex('by_data') .collect(); const movimentacoesMes = movimentacoes.filter((m) => m.data >= inicioMes.getTime()).length; return { totalMateriais: materiais.length, totalMateriaisAtivos: materiaisAtivos.length, totalAlertasAtivos: alertasAtivos.length, movimentacoesMes, materiaisEstoqueBaixo: materiaisEstoqueBaixo.length }; } }); export const verificarEstoqueBaixo = query({ args: {}, handler: async (ctx) => { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { recurso: 'almoxarifado', acao: 'listar' }); const materiais = await ctx.db .query('materiais') .withIndex('by_ativo', (q) => q.eq('ativo', true)) .collect(); return materiais.filter((m) => m.estoqueAtual <= m.estoqueMinimo); } }); // ========== MUTATIONS ========== async function registrarHistorico( ctx: MutationCtx, tipoEntidade: string, entidadeId: string, acao: string, dadosAnteriores?: Record, dadosNovos?: Record, observacoes?: string ) { const usuario = await getCurrentUserFunction(ctx); if (!usuario) return; await ctx.db.insert('historicoAlteracoes', { tipoEntidade, entidadeId, acao, usuarioId: usuario._id, dadosAnteriores: dadosAnteriores ? JSON.stringify(dadosAnteriores) : undefined, dadosNovos: dadosNovos ? JSON.stringify(dadosNovos) : undefined, timestamp: Date.now(), observacoes }); } /** * Função helper para enviar emails de alertas de almoxarifado */ async function enviarEmailAlerta( ctx: MutationCtx, alerta: Doc<'alertasEstoque'>, material: Doc<'materiais'>, tipoEmail: 'criado' | 'resolvido' | 'ignorado', usuarioResolucao?: Doc<'usuarios'> ) { try { // Buscar configuração de almoxarifado const config = await ctx.db .query('configuracoesAlmoxarifado') .withIndex('by_ativo', (q) => q.eq('ativo', true)) .first(); // Verificar se emails de alerta estão ativados if (!config || !config.emailAlertasAtivo || !config.emailsDestinatarios || config.emailsDestinatarios.length === 0) { return; // Emails desativados ou sem destinatários } // Determinar template e variáveis baseado no tipo let templateCodigo: string; // URL do sistema (buscar de configuração ou usar padrão) const urlSistema = process.env.NEXT_PUBLIC_SITE_URL || process.env.SITE_URL || 'http://localhost:5173'; const variaveis: Record = { materialNome: material.nome, materialCodigo: material.codigo, quantidadeAtual: material.estoqueAtual.toString(), quantidadeMinima: material.estoqueMinimo.toString(), unidadeMedida: material.unidadeMedida, urlSistema }; if (tipoEmail === 'criado') { templateCodigo = 'almoxarifado_alerta_criado'; const tipoAlertaLabel = alerta.tipo === 'estoque_zerado' ? 'Estoque Zerado' : alerta.tipo === 'estoque_minimo' ? 'Estoque Mínimo' : 'Reposição Necessária'; const diferenca = alerta.quantidadeMinima - alerta.quantidadeAtual; variaveis.tipoAlerta = tipoAlertaLabel; variaveis.diferenca = diferenca.toString(); } else if (tipoEmail === 'resolvido') { templateCodigo = 'almoxarifado_alerta_resolvido'; const usuarioNome = usuarioResolucao?.nome || 'Sistema'; const dataResolucao = alerta.resolvidoEm ? new Date(alerta.resolvidoEm).toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }) : new Date().toLocaleDateString('pt-BR'); variaveis.resolvidoPor = usuarioNome; variaveis.dataResolucao = dataResolucao; } else { // ignorado templateCodigo = 'almoxarifado_alerta_ignorado'; const tipoAlertaLabel = alerta.tipo === 'estoque_zerado' ? 'Estoque Zerado' : alerta.tipo === 'estoque_minimo' ? 'Estoque Mínimo' : 'Reposição Necessária'; const dataIgnorado = new Date().toLocaleDateString('pt-BR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' }); variaveis.tipoAlerta = tipoAlertaLabel; variaveis.dataIgnorado = dataIgnorado; } // Buscar usuário atual para enviar o email const usuarioAtual = await getCurrentUserFunction(ctx); if (!usuarioAtual) return; // Enviar email para cada destinatário configurado for (const emailDestinatario of config.emailsDestinatarios) { try { // Agendar action para enviar email com template (assíncrono, não bloqueia) ctx.scheduler .runAfter(0, api.email.enviarEmailComTemplate, { destinatario: emailDestinatario, templateCodigo, variaveis, enviadoPor: usuarioAtual._id }) .catch((error) => { console.error(`Erro ao agendar email de alerta para ${emailDestinatario}:`, error); }); } catch (error) { console.error(`Erro ao agendar email de alerta para ${emailDestinatario}:`, error); // Continua para o próximo destinatário mesmo se falhar } } } catch (error) { console.error('Erro ao enviar emails de alerta:', error); // Não falha a operação principal se houver erro no email } } async function verificarECriarAlerta(ctx: MutationCtx, materialId: Id<'materiais'>) { const material = await ctx.db.get(materialId); if (!material || !material.ativo) return; // Verificar se já existe alerta ativo para este material const alertasExistentes = await ctx.db .query('alertasEstoque') .withIndex('by_materialId', (q) => q.eq('materialId', materialId)) .filter((q) => q.eq(q.field('status'), 'ativo')) .collect(); if (alertasExistentes.length > 0) { // Já existe alerta ativo return; } // Determinar tipo de alerta let tipo: 'estoque_minimo' | 'estoque_zerado' | 'reposicao_necessaria' = 'estoque_minimo'; if (material.estoqueAtual === 0) { tipo = 'estoque_zerado'; } else if (material.estoqueAtual < material.estoqueMinimo) { tipo = 'estoque_minimo'; } // Criar alerta const alertaId = await ctx.db.insert('alertasEstoque', { materialId, tipo, quantidadeAtual: material.estoqueAtual, quantidadeMinima: material.estoqueMinimo, status: 'ativo', criadoEm: Date.now() }); // Buscar alerta criado para enviar email const alertaCriado = await ctx.db.get(alertaId); if (alertaCriado) { // Enviar email de notificação (assíncrono, não bloqueia) await enviarEmailAlerta(ctx, alertaCriado, material, 'criado'); } } async function resolverAlertasMaterial(ctx: MutationCtx, materialId: Id<'materiais'>) { const alertas = await ctx.db .query('alertasEstoque') .withIndex('by_materialId', (q) => q.eq('materialId', materialId)) .filter((q) => q.eq(q.field('status'), 'ativo')) .collect(); const usuario = await getCurrentUserFunction(ctx); if (!usuario) return; for (const alerta of alertas) { await ctx.db.patch(alerta._id, { status: 'resolvido', resolvidoEm: Date.now(), resolvidoPor: usuario._id }); } } export const criarMaterial = mutation({ args: { codigo: v.string(), nome: v.string(), descricao: v.optional(v.string()), categoria: v.string(), unidadeMedida: v.string(), estoqueMinimo: v.number(), estoqueMaximo: v.optional(v.number()), estoqueAtual: v.optional(v.number()), localizacao: v.optional(v.string()), fornecedor: v.optional(v.string()) }, handler: async (ctx, args) => { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { recurso: 'almoxarifado', acao: 'criar_material' }); // Verificar se código já existe const codigoExistente = await ctx.db .query('materiais') .withIndex('by_codigo', (q) => q.eq('codigo', args.codigo)) .unique(); if (codigoExistente) { throw new Error('Código do material já existe'); } const usuario = await getCurrentUserFunction(ctx); if (!usuario) throw new Error('Usuário não autenticado'); const agora = Date.now(); const materialId = await ctx.db.insert('materiais', { ...args, estoqueAtual: args.estoqueAtual ?? 0, ativo: true, criadoPor: usuario._id, criadoEm: agora, atualizadoEm: agora }); // Registrar histórico await registrarHistorico(ctx, 'material', materialId.toString(), 'criacao', undefined, args as Record); // Verificar se precisa criar alerta if (args.estoqueAtual !== undefined && args.estoqueAtual <= args.estoqueMinimo) { await verificarECriarAlerta(ctx, materialId); } return materialId; } }); export const editarMaterial = mutation({ args: { id: v.id('materiais'), codigo: v.optional(v.string()), nome: v.optional(v.string()), descricao: v.optional(v.string()), categoria: v.optional(v.string()), unidadeMedida: v.optional(v.string()), estoqueMinimo: v.optional(v.number()), estoqueMaximo: v.optional(v.number()), localizacao: v.optional(v.string()), fornecedor: v.optional(v.string()), ativo: v.optional(v.boolean()) }, handler: async (ctx, args) => { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { recurso: 'almoxarifado', acao: 'editar_material' }); const material = await ctx.db.get(args.id); if (!material) throw new Error('Material não encontrado'); // Verificar se código já existe (se foi alterado) if (args.codigo && args.codigo !== material.codigo) { const codigoExistente = await ctx.db .query('materiais') .withIndex('by_codigo', (q) => q.eq('codigo', args.codigo!)) .unique(); if (codigoExistente) { throw new Error('Código do material já existe'); } } const dadosAnteriores = { ...material }; const dadosNovos: Partial> & { atualizadoEm: number } = { atualizadoEm: Date.now() }; // Atualizar apenas campos fornecidos if (args.codigo !== undefined) dadosNovos.codigo = args.codigo; if (args.nome !== undefined) dadosNovos.nome = args.nome; if (args.descricao !== undefined) dadosNovos.descricao = args.descricao; if (args.categoria !== undefined) dadosNovos.categoria = args.categoria; if (args.unidadeMedida !== undefined) dadosNovos.unidadeMedida = args.unidadeMedida; if (args.estoqueMinimo !== undefined) dadosNovos.estoqueMinimo = args.estoqueMinimo; if (args.estoqueMaximo !== undefined) dadosNovos.estoqueMaximo = args.estoqueMaximo; if (args.localizacao !== undefined) dadosNovos.localizacao = args.localizacao; if (args.fornecedor !== undefined) dadosNovos.fornecedor = args.fornecedor; if (args.ativo !== undefined) dadosNovos.ativo = args.ativo; await ctx.db.patch(args.id, dadosNovos); // Registrar histórico await registrarHistorico(ctx, 'material', args.id.toString(), 'edicao', dadosAnteriores, dadosNovos); // Verificar se precisa criar/resolver alertas if (args.estoqueMinimo !== undefined || args.ativo !== undefined) { const materialAtualizado = await ctx.db.get(args.id); if (materialAtualizado) { if (materialAtualizado.ativo && materialAtualizado.estoqueAtual <= materialAtualizado.estoqueMinimo) { await verificarECriarAlerta(ctx, args.id); } else if (materialAtualizado.estoqueAtual > materialAtualizado.estoqueMinimo) { await resolverAlertasMaterial(ctx, args.id); } } } } }); export const registrarEntrada = mutation({ args: { materialId: v.id('materiais'), quantidade: v.number(), motivo: v.string(), documento: v.optional(v.string()), observacoes: v.optional(v.string()) }, handler: async (ctx, args) => { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { recurso: 'almoxarifado', acao: 'registrar_movimentacao' }); if (args.quantidade <= 0) { throw new Error('Quantidade deve ser maior que zero'); } const material = await ctx.db.get(args.materialId); if (!material) throw new Error('Material não encontrado'); if (!material.ativo) throw new Error('Material está inativo'); const usuario = await getCurrentUserFunction(ctx); if (!usuario) throw new Error('Usuário não autenticado'); const quantidadeAnterior = material.estoqueAtual; const quantidadeNova = quantidadeAnterior + args.quantidade; // Atualizar estoque await ctx.db.patch(args.materialId, { estoqueAtual: quantidadeNova, atualizadoEm: Date.now() }); // Registrar movimentação const movimentacaoId = await ctx.db.insert('movimentacoesEstoque', { materialId: args.materialId, tipo: 'entrada', quantidade: args.quantidade, quantidadeAnterior, quantidadeNova, motivo: args.motivo, documento: args.documento, usuarioId: usuario._id, data: Date.now(), observacoes: args.observacoes }); // Registrar histórico await registrarHistorico(ctx, 'movimentacao', movimentacaoId.toString(), 'movimentacao', undefined, { tipo: 'entrada', materialId: args.materialId, quantidade: args.quantidade }); // Verificar se precisa resolver alertas if (quantidadeNova > material.estoqueMinimo) { await resolverAlertasMaterial(ctx, args.materialId); } return movimentacaoId; } }); export const registrarSaida = mutation({ args: { materialId: v.id('materiais'), quantidade: v.number(), motivo: v.string(), funcionarioId: v.optional(v.id('funcionarios')), setorId: v.optional(v.id('setores')), observacoes: v.optional(v.string()) }, handler: async (ctx, args) => { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { recurso: 'almoxarifado', acao: 'registrar_movimentacao' }); if (args.quantidade <= 0) { throw new Error('Quantidade deve ser maior que zero'); } const material = await ctx.db.get(args.materialId); if (!material) throw new Error('Material não encontrado'); if (!material.ativo) throw new Error('Material está inativo'); // Verificar configuração de estoque negativo const config = await ctx.db .query('configuracoesAlmoxarifado') .filter((q) => q.eq(q.field('ativo'), true)) .first(); if (!config?.permitirEstoqueNegativo && material.estoqueAtual < args.quantidade) { throw new Error('Estoque insuficiente'); } const usuario = await getCurrentUserFunction(ctx); if (!usuario) throw new Error('Usuário não autenticado'); const quantidadeAnterior = material.estoqueAtual; const quantidadeNova = Math.max(0, quantidadeAnterior - args.quantidade); // Atualizar estoque await ctx.db.patch(args.materialId, { estoqueAtual: quantidadeNova, atualizadoEm: Date.now() }); // Registrar movimentação const movimentacaoId = await ctx.db.insert('movimentacoesEstoque', { materialId: args.materialId, tipo: 'saida', quantidade: args.quantidade, quantidadeAnterior, quantidadeNova, motivo: args.motivo, funcionarioId: args.funcionarioId, setorId: args.setorId, usuarioId: usuario._id, data: Date.now(), observacoes: args.observacoes }); // Registrar histórico await registrarHistorico(ctx, 'movimentacao', movimentacaoId.toString(), 'movimentacao', undefined, { tipo: 'saida', materialId: args.materialId, quantidade: args.quantidade }); // Verificar se precisa criar alerta if (quantidadeNova <= material.estoqueMinimo) { await verificarECriarAlerta(ctx, args.materialId); } return movimentacaoId; } }); export const ajustarEstoque = mutation({ args: { materialId: v.id('materiais'), quantidadeNova: v.number(), motivo: v.string(), observacoes: v.optional(v.string()) }, handler: async (ctx, args) => { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { recurso: 'almoxarifado', acao: 'ajustar_estoque' }); if (args.quantidadeNova < 0) { throw new Error('Quantidade não pode ser negativa'); } const material = await ctx.db.get(args.materialId); if (!material) throw new Error('Material não encontrado'); if (!material.ativo) throw new Error('Material está inativo'); const usuario = await getCurrentUserFunction(ctx); if (!usuario) throw new Error('Usuário não autenticado'); const quantidadeAnterior = material.estoqueAtual; const diferenca = args.quantidadeNova - quantidadeAnterior; // Atualizar estoque await ctx.db.patch(args.materialId, { estoqueAtual: args.quantidadeNova, atualizadoEm: Date.now() }); // Registrar movimentação const movimentacaoId = await ctx.db.insert('movimentacoesEstoque', { materialId: args.materialId, tipo: 'ajuste', quantidade: Math.abs(diferenca), quantidadeAnterior, quantidadeNova: args.quantidadeNova, motivo: args.motivo, usuarioId: usuario._id, data: Date.now(), observacoes: args.observacoes }); // Registrar histórico await registrarHistorico(ctx, 'movimentacao', movimentacaoId.toString(), 'movimentacao', undefined, { tipo: 'ajuste', materialId: args.materialId, quantidadeAnterior, quantidadeNova: args.quantidadeNova }); // Verificar se precisa criar/resolver alertas if (args.quantidadeNova <= material.estoqueMinimo) { await verificarECriarAlerta(ctx, args.materialId); } else { await resolverAlertasMaterial(ctx, args.materialId); } return movimentacaoId; } }); export const criarRequisicao = mutation({ args: { solicitanteId: v.id('funcionarios'), setorId: v.id('setores'), itens: v.array( v.object({ materialId: v.id('materiais'), quantidadeSolicitada: v.number(), observacoes: v.optional(v.string()) }) ), observacoes: v.optional(v.string()) }, handler: async (ctx, args) => { if (args.itens.length === 0) { throw new Error('Requisição deve ter pelo menos um item'); } // Gerar número sequencial da requisição const todasRequisicoes = await ctx.db.query('requisicoesMaterial').collect(); const proximoNumero = (todasRequisicoes.length + 1).toString().padStart(6, '0'); const numero = `REQ-${proximoNumero}`; const agora = Date.now(); const requisicaoId = await ctx.db.insert('requisicoesMaterial', { numero, solicitanteId: args.solicitanteId, setorId: args.setorId, status: 'pendente', observacoes: args.observacoes, criadoEm: agora, atualizadoEm: agora }); // Criar itens da requisição for (const item of args.itens) { await ctx.db.insert('requisicaoItens', { requisicaoId, materialId: item.materialId, quantidadeSolicitada: item.quantidadeSolicitada, observacoes: item.observacoes }); } // Registrar histórico await registrarHistorico(ctx, 'requisicao', requisicaoId.toString(), 'criacao', undefined, { numero, solicitanteId: args.solicitanteId, itens: args.itens }); return requisicaoId; } }); export const aprovarRequisicao = mutation({ args: { id: v.id('requisicoesMaterial'), observacoes: v.optional(v.string()) }, handler: async (ctx, args) => { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { recurso: 'almoxarifado', acao: 'aprovar_requisicao' }); const requisicao = await ctx.db.get(args.id); if (!requisicao) throw new Error('Requisição não encontrada'); if (requisicao.status !== 'pendente') { throw new Error('Apenas requisições pendentes podem ser aprovadas'); } const usuario = await getCurrentUserFunction(ctx); if (!usuario) throw new Error('Usuário não autenticado'); // Buscar funcionário do usuário const funcionario = await ctx.db .query('funcionarios') .filter((q) => q.eq(q.field('email'), usuario.email)) .first(); if (!funcionario) throw new Error('Funcionário não encontrado para o usuário'); await ctx.db.patch(args.id, { status: 'aprovada', aprovadoPor: funcionario._id, dataAprovacao: Date.now(), atualizadoEm: Date.now(), observacoes: args.observacoes || requisicao.observacoes }); // Registrar histórico await registrarHistorico(ctx, 'requisicao', args.id.toString(), 'edicao', requisicao, { status: 'aprovada', aprovadoPor: funcionario._id }); } }); export const atenderRequisicao = mutation({ args: { id: v.id('requisicoesMaterial') }, handler: async (ctx, args) => { await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, { recurso: 'almoxarifado', acao: 'registrar_movimentacao' }); const requisicao = await ctx.db.get(args.id); if (!requisicao) throw new Error('Requisição não encontrada'); if (requisicao.status !== 'aprovada') { throw new Error('Apenas requisições aprovadas podem ser atendidas'); } // Buscar itens da requisição const itens = await ctx.db .query('requisicaoItens') .withIndex('by_requisicaoId', (q) => q.eq('requisicaoId', args.id)) .collect(); const usuario = await getCurrentUserFunction(ctx); if (!usuario) throw new Error('Usuário não autenticado'); // Processar cada item for (const item of itens) { const material = await ctx.db.get(item.materialId); if (!material) continue; const quantidadeAtendida = Math.min(item.quantidadeSolicitada, material.estoqueAtual); // Atualizar item com quantidade atendida await ctx.db.patch(item._id, { quantidadeAtendida }); if (quantidadeAtendida > 0) { // Registrar saída const quantidadeAnterior = material.estoqueAtual; const quantidadeNova = quantidadeAnterior - quantidadeAtendida; await ctx.db.patch(item.materialId, { estoqueAtual: quantidadeNova, atualizadoEm: Date.now() }); await ctx.db.insert('movimentacoesEstoque', { materialId: item.materialId, tipo: 'saida', quantidade: quantidadeAtendida, quantidadeAnterior, quantidadeNova, motivo: `Atendimento da requisição ${requisicao.numero}`, funcionarioId: requisicao.solicitanteId, setorId: requisicao.setorId, usuarioId: usuario._id, data: Date.now(), observacoes: `Requisição ${requisicao.numero}` }); // Verificar alertas if (quantidadeNova <= material.estoqueMinimo) { await verificarECriarAlerta(ctx, item.materialId); } } } // Atualizar status da requisição await ctx.db.patch(args.id, { status: 'atendida', atualizadoEm: Date.now() }); // Registrar histórico await registrarHistorico(ctx, 'requisicao', args.id.toString(), 'edicao', requisicao, { status: 'atendida' }); } }); export const cancelarRequisicao = mutation({ args: { id: v.id('requisicoesMaterial'), observacoes: v.optional(v.string()) }, handler: async (ctx, args) => { const requisicao = await ctx.db.get(args.id); if (!requisicao) throw new Error('Requisição não encontrada'); if (requisicao.status === 'atendida' || requisicao.status === 'cancelada') { throw new Error('Requisição já foi atendida ou cancelada'); } await ctx.db.patch(args.id, { status: 'cancelada', atualizadoEm: Date.now(), observacoes: args.observacoes || requisicao.observacoes }); // Registrar histórico await registrarHistorico(ctx, 'requisicao', args.id.toString(), 'edicao', requisicao, { status: 'cancelada' }); } }); export const resolverAlerta = mutation({ args: { id: v.id('alertasEstoque') }, handler: async (ctx, args) => { const alerta = await ctx.db.get(args.id); if (!alerta) throw new Error('Alerta não encontrado'); if (alerta.status !== 'ativo') { throw new Error('Apenas alertas ativos podem ser resolvidos'); } const usuario = await getCurrentUserFunction(ctx); if (!usuario) throw new Error('Usuário não autenticado'); // Buscar material antes de atualizar o alerta const material = await ctx.db.get(alerta.materialId); if (!material) throw new Error('Material não encontrado'); await ctx.db.patch(args.id, { status: 'resolvido', resolvidoEm: Date.now(), resolvidoPor: usuario._id }); // Buscar alerta atualizado para enviar email const alertaResolvido = await ctx.db.get(args.id); if (alertaResolvido) { // Enviar email de notificação (assíncrono, não bloqueia) await enviarEmailAlerta(ctx, alertaResolvido, material, 'resolvido', usuario); } // Registrar histórico await registrarHistorico(ctx, 'configuracao', args.id.toString(), 'edicao', alerta, { status: 'resolvido' }); } }); export const ignorarAlerta = mutation({ args: { id: v.id('alertasEstoque') }, handler: async (ctx, args) => { const alerta = await ctx.db.get(args.id); if (!alerta) throw new Error('Alerta não encontrado'); if (alerta.status !== 'ativo') { throw new Error('Apenas alertas ativos podem ser ignorados'); } // Buscar material antes de atualizar o alerta const material = await ctx.db.get(alerta.materialId); if (!material) throw new Error('Material não encontrado'); await ctx.db.patch(args.id, { status: 'ignorado' }); // Buscar alerta atualizado para enviar email const alertaIgnorado = await ctx.db.get(args.id); if (alertaIgnorado) { // Enviar email de notificação (assíncrono, não bloqueia) await enviarEmailAlerta(ctx, alertaIgnorado, material, 'ignorado'); } // Registrar histórico await registrarHistorico(ctx, 'configuracao', args.id.toString(), 'edicao', alerta, { status: 'ignorado' }); } }); // ========== INTERNAL ACTIONS ========== export const registrarHistoricoAlteracao = internalMutation({ args: { tipoEntidade: v.string(), entidadeId: v.string(), acao: v.string(), dadosAnteriores: v.optional(v.string()), dadosNovos: v.optional(v.string()), observacoes: v.optional(v.string()) }, handler: async (ctx, args) => { const usuario = await getCurrentUserFunction(ctx); if (!usuario) return; await ctx.db.insert('historicoAlteracoes', { tipoEntidade: args.tipoEntidade, entidadeId: args.entidadeId, acao: args.acao, usuarioId: usuario._id, dadosAnteriores: args.dadosAnteriores, dadosNovos: args.dadosNovos, timestamp: Date.now(), observacoes: args.observacoes }); } }); export const verificarAlertasAutomatico = internalAction({ args: {}, handler: async (ctx) => { // Buscar todos os materiais ativos const materiais = await ctx.runQuery(internal.almoxarifado.listarMateriaisInterno, { ativo: true }); // Buscar configuração const config = await ctx.runQuery(internal.configuracaoAlmoxarifado.obterConfiguracaoInterno, {}); for (const material of materiais) { // Verificar se precisa criar alerta if (material.estoqueAtual <= material.estoqueMinimo) { // Verificar se já existe alerta ativo const alertasExistentes = await ctx.runQuery( internal.almoxarifado.listarAlertasPorMaterial, { materialId: material._id, status: 'ativo' as const } ); if (alertasExistentes.length === 0) { // Criar novo alerta let tipo: 'estoque_minimo' | 'estoque_zerado' | 'reposicao_necessaria' = 'estoque_minimo'; if (material.estoqueAtual === 0) { tipo = 'estoque_zerado'; } await ctx.runMutation(internal.almoxarifado.criarAlertaInterno, { materialId: material._id, tipo, quantidadeAtual: material.estoqueAtual, quantidadeMinima: material.estoqueMinimo }); } } else { // Resolver alertas se estoque está acima do mínimo await ctx.runMutation(internal.almoxarifado.resolverAlertasMaterialInterno, { materialId: material._id }); } } // Enviar notificações se configurado if (config?.emailAlertasAtivo) { await ctx.runAction(internal.almoxarifado.enviarNotificacoesAlerta, {}); } } }); export const enviarNotificacoesAlerta = internalAction({ args: {}, handler: async (ctx) => { // Buscar alertas ativos const alertas = await ctx.runQuery(internal.almoxarifado.listarAlertasInterno, { status: 'ativo' }); if (alertas.length === 0) return; // Buscar configuração para obter emails const config = await ctx.runQuery(internal.configuracaoAlmoxarifado.obterConfiguracaoInterno, {}); if (!config?.emailAlertasAtivo || config.emailsDestinatarios.length === 0) { return; } // Aqui você pode integrar com o sistema de email do projeto // Por enquanto, apenas logamos console.log(`Enviando notificações de ${alertas.length} alertas para:`, config.emailsDestinatarios); } }); // ========== INTERNAL QUERIES ========== export const listarMateriaisInterno = internalQuery({ args: { ativo: v.optional(v.boolean()) }, handler: async (ctx, args) => { if (args.ativo !== undefined) { return await ctx.db .query('materiais') .withIndex('by_ativo', (q) => q.eq('ativo', args.ativo!)) .collect(); } return await ctx.db.query('materiais').collect(); } }); export const listarAlertasPorMaterial = internalQuery({ args: { materialId: v.id('materiais'), status: v.optional(alertaStatus) }, handler: async (ctx, args) => { const query = ctx.db .query('alertasEstoque') .withIndex('by_materialId', (q) => q.eq('materialId', args.materialId)); if (args.status) { return await query.filter((q) => q.eq(q.field('status'), args.status!)).collect(); } return await query.collect(); } }); export const listarAlertasInterno = internalQuery({ args: { status: v.optional(alertaStatus) }, handler: async (ctx, args) => { if (args.status) { return await ctx.db .query('alertasEstoque') .withIndex('by_status', (q) => q.eq('status', args.status!)) .collect(); } return await ctx.db.query('alertasEstoque').collect(); } }); // ========== INTERNAL MUTATIONS ========== export const criarAlertaInterno = internalMutation({ args: { materialId: v.id('materiais'), tipo: alertaTipo, quantidadeAtual: v.number(), quantidadeMinima: v.number() }, handler: async (ctx, args) => { await ctx.db.insert('alertasEstoque', { materialId: args.materialId, tipo: args.tipo, quantidadeAtual: args.quantidadeAtual, quantidadeMinima: args.quantidadeMinima, status: 'ativo', criadoEm: Date.now() }); } }); export const resolverAlertasMaterialInterno = internalMutation({ args: { materialId: v.id('materiais') }, handler: async (ctx, args) => { const alertas = await ctx.db .query('alertasEstoque') .withIndex('by_materialId', (q) => q.eq('materialId', args.materialId)) .filter((q) => q.eq(q.field('status'), 'ativo')) .collect(); for (const alerta of alertas) { await ctx.db.patch(alerta._id, { status: 'resolvido', resolvidoEm: Date.now() }); } } });