1725 lines
49 KiB
TypeScript
1725 lines
49 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 verificarEstoqueRequisicao = query({
|
|
args: { id: v.id('requisicoesMaterial') },
|
|
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');
|
|
|
|
// Buscar itens da requisição
|
|
const itens = await ctx.db
|
|
.query('requisicaoItens')
|
|
.withIndex('by_requisicaoId', (q) => q.eq('requisicaoId', args.id))
|
|
.collect();
|
|
|
|
const problemasEstoque: Array<{
|
|
materialId: Id<'materiais'>;
|
|
materialNome: string;
|
|
quantidadeSolicitada: number;
|
|
estoqueAtual: number;
|
|
falta: number;
|
|
}> = [];
|
|
|
|
for (const item of itens) {
|
|
const material = await ctx.db.get(item.materialId);
|
|
if (!material) continue;
|
|
|
|
if (material.estoqueAtual < item.quantidadeSolicitada) {
|
|
problemasEstoque.push({
|
|
materialId: material._id,
|
|
materialNome: material.nome,
|
|
quantidadeSolicitada: item.quantidadeSolicitada,
|
|
estoqueAtual: material.estoqueAtual,
|
|
falta: item.quantidadeSolicitada - material.estoqueAtual
|
|
});
|
|
}
|
|
}
|
|
|
|
return {
|
|
temEstoqueSuficiente: problemasEstoque.length === 0,
|
|
problemasEstoque
|
|
};
|
|
}
|
|
});
|
|
|
|
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');
|
|
}
|
|
|
|
// Nota: A verificação de estoque é feita no frontend antes de permitir a aprovação
|
|
// O botão de aprovar só aparece quando há estoque suficiente
|
|
|
|
const usuario = await getCurrentUserFunction(ctx);
|
|
if (!usuario) throw new Error('Usuário não autenticado');
|
|
|
|
// Buscar funcionário do usuário - primeiro tenta por funcionarioId, depois por email
|
|
let funcionario;
|
|
if (usuario.funcionarioId) {
|
|
funcionario = await ctx.db.get(usuario.funcionarioId);
|
|
}
|
|
|
|
if (!funcionario) {
|
|
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 reprovarRequisicao = mutation({
|
|
args: {
|
|
id: v.id('requisicoesMaterial'),
|
|
motivoReprovacao: 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 reprovadas');
|
|
}
|
|
|
|
if (!args.motivoReprovacao.trim()) {
|
|
throw new Error('É necessário informar o motivo da reprovação');
|
|
}
|
|
|
|
const usuario = await getCurrentUserFunction(ctx);
|
|
if (!usuario) throw new Error('Usuário não autenticado');
|
|
|
|
// Buscar funcionário do usuário - primeiro tenta por funcionarioId, depois por email
|
|
let funcionario;
|
|
if (usuario.funcionarioId) {
|
|
funcionario = await ctx.db.get(usuario.funcionarioId);
|
|
}
|
|
|
|
if (!funcionario) {
|
|
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: 'rejeitada',
|
|
reprovadoPor: funcionario._id,
|
|
dataReprovacao: Date.now(),
|
|
motivoReprovacao: args.motivoReprovacao.trim(),
|
|
atualizadoEm: Date.now()
|
|
});
|
|
|
|
// Registrar histórico
|
|
await registrarHistorico(ctx, 'requisicao', args.id.toString(), 'edicao', requisicao, {
|
|
status: 'rejeitada',
|
|
reprovadoPor: funcionario._id,
|
|
motivoReprovacao: args.motivoReprovacao.trim()
|
|
});
|
|
}
|
|
});
|
|
|
|
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()
|
|
});
|
|
}
|
|
}
|
|
});
|
|
|