Files
sgse-app/packages/backend/convex/almoxarifado.ts

1608 lines
46 KiB
TypeScript

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) ||
(m.codigoBarras && m.codigoBarras.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 buscarMaterialPorCodigoBarras = query({
args: { codigoBarras: v.string() },
handler: async (ctx, args) => {
const usuario = await getCurrentUserFunction(ctx);
if (!usuario) return null;
try {
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
recurso: 'almoxarifado',
acao: 'listar'
});
} catch {
return null;
}
const material = await ctx.db
.query('materiais')
.withIndex('by_codigoBarras', (q) => q.eq('codigoBarras', args.codigoBarras))
.first();
return material ?? null;
}
});
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 listarMovimentacoesComHistorico = query({
args: {},
handler: async (ctx) => {
const usuario = await getCurrentUserFunction(ctx);
if (!usuario) return [];
try {
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
recurso: 'almoxarifado',
acao: 'listar'
});
} catch {
return [];
}
// Buscar movimentações de estoque
const movimentacoes = await ctx.db
.query('movimentacoesEstoque')
.withIndex('by_data')
.collect();
// Buscar histórico de alterações de materiais (criação, edição, exclusão)
const historicoMateriais = await ctx.db
.query('historicoAlteracoes')
.withIndex('by_tipoEntidade', (q) => q.eq('tipoEntidade', 'material'))
.collect();
// Buscar todos os usuários únicos para enriquecer os dados
const usuarioIds = new Set<Id<'usuarios'>>();
for (const mov of movimentacoes) {
usuarioIds.add(mov.usuarioId);
}
for (const hist of historicoMateriais) {
usuarioIds.add(hist.usuarioId);
}
// Buscar informações dos usuários
const usuariosMap = new Map<Id<'usuarios'>, Doc<'usuarios'>>();
for (const userId of usuarioIds) {
const usuario = await ctx.db.get(userId);
if (usuario) {
usuariosMap.set(userId, usuario);
}
}
// Transformar movimentações em formato unificado
const movimentacoesFormatadas = movimentacoes.map((mov) => {
const usuario = usuariosMap.get(mov.usuarioId);
return {
id: mov._id,
tipo: 'movimentacao' as const,
tipoMovimentacao: mov.tipo,
materialId: mov.materialId,
data: mov.data,
quantidade: mov.quantidade,
quantidadeAnterior: mov.quantidadeAnterior,
quantidadeNova: mov.quantidadeNova,
motivo: mov.motivo,
funcionarioId: mov.funcionarioId,
usuarioId: mov.usuarioId,
usuarioNome: usuario?.nome || 'Usuário desconhecido',
observacoes: mov.observacoes
};
});
// Transformar histórico de alterações em formato unificado
const historicoFormatado = historicoMateriais.map((hist) => {
const usuario = usuariosMap.get(hist.usuarioId);
return {
id: hist._id,
tipo: 'alteracao' as const,
tipoAlteracao: hist.acao, // 'criacao', 'edicao', 'exclusao'
materialId: hist.entidadeId as Id<'materiais'>, // Converter string para Id
data: hist.timestamp,
quantidade: undefined,
quantidadeAnterior: undefined,
quantidadeNova: undefined,
motivo: hist.observacoes || hist.acao,
funcionarioId: undefined,
usuarioId: hist.usuarioId,
usuarioNome: usuario?.nome || 'Usuário desconhecido',
observacoes: hist.observacoes,
dadosAnteriores: hist.dadosAnteriores,
dadosNovos: hist.dadosNovos
};
});
// Combinar e ordenar por data (mais recente primeiro)
const todos = [...movimentacoesFormatadas, ...historicoFormatado];
todos.sort((a, b) => b.data - a.data);
return todos;
}
});
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);
}
});
export const obterUltimosProdutosCadastrados = query({
args: {
limit: v.optional(v.number())
},
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 [];
}
const limit = args.limit ?? 10;
const materiais = await ctx.db.query('materiais').collect();
// Ordenar por data de criação (mais recente primeiro) e pegar os últimos N
const materiaisOrdenados = materiais
.sort((a, b) => b.criadoEm - a.criadoEm)
.slice(0, limit);
return materiaisOrdenados.map((m) => ({
_id: m._id,
nome: m.nome,
codigo: m.codigo,
estoqueAtual: m.estoqueAtual,
unidadeMedida: m.unidadeMedida,
criadoEm: m.criadoEm
}));
}
});
// ========== MUTATIONS ==========
async function registrarHistorico(
ctx: MutationCtx,
tipoEntidade: string,
entidadeId: string,
acao: string,
dadosAnteriores?: Record<string, unknown>,
dadosNovos?: Record<string, unknown>,
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<string, string> = {
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()),
codigoBarras: v.optional(v.string()),
imagemUrl: v.optional(v.string()),
imagemBase64: 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');
}
// Verificar se código de barras já existe (se fornecido)
if (args.codigoBarras) {
const codigoBarrasExistente = await ctx.db
.query('materiais')
.withIndex('by_codigoBarras', (q) => q.eq('codigoBarras', args.codigoBarras))
.first();
if (codigoBarrasExistente) {
throw new Error('Código de barras já está cadastrado para outro material');
}
}
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<string, unknown>);
// 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()),
codigoBarras: v.optional(v.string()),
imagemUrl: v.optional(v.string()),
imagemBase64: 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');
}
}
// Verificar se código de barras já existe (se foi alterado)
if (args.codigoBarras && args.codigoBarras !== material.codigoBarras) {
const codigoBarrasExistente = await ctx.db
.query('materiais')
.withIndex('by_codigoBarras', (q) => q.eq('codigoBarras', args.codigoBarras))
.first();
if (codigoBarrasExistente) {
throw new Error('Código de barras já está cadastrado para outro material');
}
}
const dadosAnteriores = { ...material };
const dadosNovos: Partial<Doc<'materiais'>> & { 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.codigoBarras !== undefined) dadosNovos.codigoBarras = args.codigoBarras;
if (args.imagemUrl !== undefined) dadosNovos.imagemUrl = args.imagemUrl;
if (args.imagemBase64 !== undefined) dadosNovos.imagemBase64 = args.imagemBase64;
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(),
funcionarioId: v.optional(v.id('funcionarios')),
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');
// Se funcionarioId não foi fornecido, usar o do usuário logado (se existir)
const funcionarioId = args.funcionarioId || usuario.funcionarioId;
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,
funcionarioId,
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(),
funcionarioId: v.optional(v.id('funcionarios')),
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');
// Se funcionarioId não foi fornecido, usar o do usuário logado (se existir)
const funcionarioId = args.funcionarioId || usuario.funcionarioId;
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,
funcionarioId,
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'
});
}
});
export const deletarMaterial = mutation({
args: {
id: v.id('materiais')
},
handler: async (ctx, args) => {
await ctx.runQuery(internal.permissoesAcoes.assertPermissaoAcaoAtual, {
recurso: 'almoxarifado',
acao: 'deletar_material'
});
const material = await ctx.db.get(args.id);
if (!material) throw new Error('Material não encontrado');
// Verificar se há movimentações relacionadas
const movimentacoes = await ctx.db
.query('movimentacoesEstoque')
.withIndex('by_materialId', (q) => q.eq('materialId', args.id))
.first();
if (movimentacoes) {
throw new Error('Não é possível excluir material com movimentações de estoque registradas. O material possui histórico de movimentações e deve ser desativado ao invés de excluído.');
}
// Verificar se há requisições relacionadas
const requisicoes = await ctx.db
.query('requisicaoItens')
.withIndex('by_materialId', (q) => q.eq('materialId', args.id))
.first();
if (requisicoes) {
throw new Error('Não é possível excluir material com requisições registradas. O material possui requisições associadas e deve ser desativado ao invés de excluído.');
}
// Verificar se há alertas relacionados
const alertas = await ctx.db
.query('alertasEstoque')
.withIndex('by_materialId', (q) => q.eq('materialId', args.id))
.first();
if (alertas) {
// Deletar alertas relacionados
const todosAlertas = await ctx.db
.query('alertasEstoque')
.withIndex('by_materialId', (q) => q.eq('materialId', args.id))
.collect();
for (const alerta of todosAlertas) {
await ctx.db.delete(alerta._id);
}
}
// Registrar histórico antes de deletar
await registrarHistorico(ctx, 'material', args.id.toString(), 'exclusao', material, undefined);
// Deletar o material
await ctx.db.delete(args.id);
}
});
// ========== 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()
});
}
}
});