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

1363 lines
37 KiB
TypeScript

import { v } from 'convex/values';
import { api, internal } from './_generated/api';
import type { Doc, Id } from './_generated/dataModel';
import type { MutationCtx, QueryCtx } from './_generated/server';
import { internalMutation, mutation, query } from './_generated/server';
import { getCurrentUserFunction } from './auth';
// ========== HELPERS ==========
async function getUsuarioAutenticado(ctx: QueryCtx | MutationCtx) {
const user = await getCurrentUserFunction(ctx);
if (!user) throw new Error('Unauthorized');
return user;
}
// ========== QUERIES ==========
export const list = query({
args: {},
returns: v.array(
v.object({
_id: v.id('pedidos'),
_creationTime: v.number(),
numeroSei: v.optional(v.string()),
status: v.union(
v.literal('em_rascunho'),
v.literal('aguardando_aceite'),
v.literal('em_analise'),
v.literal('precisa_ajustes'),
v.literal('cancelado'),
v.literal('concluido')
),
// acaoId removed from return
criadoPor: v.id('usuarios'),
criadoEm: v.number(),
atualizadoEm: v.number()
})
),
handler: async (ctx) => {
return await ctx.db.query('pedidos').collect();
}
});
export const get = query({
args: { id: v.id('pedidos') },
returns: v.union(
v.object({
_id: v.id('pedidos'),
_creationTime: v.number(),
numeroSei: v.optional(v.string()),
status: v.union(
v.literal('em_rascunho'),
v.literal('aguardando_aceite'),
v.literal('em_analise'),
v.literal('precisa_ajustes'),
v.literal('cancelado'),
v.literal('concluido')
),
acaoId: v.optional(v.id('acoes')),
criadoPor: v.id('usuarios'),
criadoEm: v.number(),
atualizadoEm: v.number()
}),
v.null()
),
handler: async (ctx, args) => {
return await ctx.db.get(args.id);
}
});
export const getItems = query({
args: { pedidoId: v.id('pedidos') },
returns: v.array(
v.object({
_id: v.id('objetoItems'),
_creationTime: v.number(),
pedidoId: v.id('pedidos'),
objetoId: v.id('objetos'),
ataId: v.optional(v.id('atas')),
acaoId: v.optional(v.id('acoes')),
modalidade: v.union(
v.literal('dispensa'),
v.literal('inexgibilidade'),
v.literal('adesao'),
v.literal('consumo')
),
valorEstimado: v.string(),
valorReal: v.optional(v.string()),
quantidade: v.number(),
adicionadoPor: v.id('funcionarios'),
adicionadoPorNome: v.string(),
criadoEm: v.number()
})
),
handler: async (ctx, args) => {
const items = await ctx.db
.query('objetoItems')
.withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId))
.collect();
// Get employee names
const itemsWithNames = await Promise.all(
items.map(async (item) => {
const funcionario = await ctx.db.get(item.adicionadoPor);
return {
...item,
adicionadoPorNome: funcionario?.nome || 'Desconhecido'
};
})
);
return itemsWithNames;
}
});
export const getHistory = query({
args: { pedidoId: v.id('pedidos') },
handler: async (ctx, args) => {
const history = await ctx.db
.query('historicoPedidos')
.withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId))
.order('desc')
.collect();
// Get user names
const historyWithNames = await Promise.all(
history.map(async (entry) => {
const usuario = await ctx.db.get(entry.usuarioId);
return {
_id: entry._id,
_creationTime: entry._creationTime,
pedidoId: entry.pedidoId,
usuarioId: entry.usuarioId,
usuarioNome: usuario?.nome || 'Desconhecido',
acao: entry.acao,
detalhes: entry.detalhes,
data: entry.data
};
})
);
return historyWithNames;
}
});
export const checkExisting = query({
args: {
numeroSei: v.optional(v.string()),
itensFiltro: v.optional(
v.array(
v.object({
objetoId: v.id('objetos'),
modalidade: v.union(
v.literal('dispensa'),
v.literal('inexgibilidade'),
v.literal('adesao'),
v.literal('consumo')
)
})
)
)
},
returns: v.array(
v.object({
_id: v.id('pedidos'),
_creationTime: v.number(),
numeroSei: v.optional(v.string()),
status: v.union(
v.literal('em_rascunho'),
v.literal('aguardando_aceite'),
v.literal('em_analise'),
v.literal('precisa_ajustes'),
v.literal('cancelado'),
v.literal('concluido')
),
// acaoId removed
criadoPor: v.id('usuarios'),
criadoEm: v.number(),
atualizadoEm: v.number(),
matchingItems: v.optional(
v.array(
v.object({
objetoId: v.id('objetos'),
modalidade: v.union(
v.literal('dispensa'),
v.literal('inexgibilidade'),
v.literal('adesao'),
v.literal('consumo')
),
quantidade: v.number()
})
)
)
})
),
handler: async (ctx, args) => {
const user = await getCurrentUserFunction(ctx);
if (!user) return [];
const openStatuses: Array<
'em_rascunho' | 'aguardando_aceite' | 'em_analise' | 'precisa_ajustes'
> = ['em_rascunho', 'aguardando_aceite', 'em_analise', 'precisa_ajustes'];
// 1) Buscar todos os pedidos "abertos" usando o índice by_status
let pedidosAbertos: Doc<'pedidos'>[] = [];
for (const status of openStatuses) {
const partial = await ctx.db
.query('pedidos')
.withIndex('by_status', (q) => q.eq('status', status))
.collect();
pedidosAbertos = pedidosAbertos.concat(partial);
}
// 2) Filtros opcionais: numeroSei
pedidosAbertos = pedidosAbertos.filter((p) => {
if (args.numeroSei && p.numeroSei !== args.numeroSei) return false;
return true;
});
// 3) Filtro por itens (objetoId + modalidade), se informado, e coleta de matchingItems
const resultados = [];
const itensFiltro = args.itensFiltro ?? [];
for (const pedido of pedidosAbertos) {
let include = true;
let matchingItems: {
objetoId: Id<'objetos'>;
modalidade: Doc<'objetoItems'>['modalidade'];
quantidade: number;
}[] = [];
// Se houver filtro de itens, verificamos se o pedido tem ALGUM dos itens (objetoId + modalidade)
if (itensFiltro.length > 0) {
const items = await ctx.db
.query('objetoItems')
.withIndex('by_pedidoId', (q) => q.eq('pedidoId', pedido._id))
.collect();
const matching = items.filter((i) =>
itensFiltro.some((f) => f.objetoId === i.objetoId && f.modalidade === i.modalidade)
);
if (matching.length > 0) {
matchingItems = matching.map((i) => ({
objetoId: i.objetoId,
modalidade: i.modalidade,
quantidade: i.quantidade
}));
} else {
include = false;
}
}
if (include) {
resultados.push({
_id: pedido._id,
_creationTime: pedido._creationTime,
numeroSei: pedido.numeroSei,
status: pedido.status,
criadoPor: pedido.criadoPor,
criadoEm: pedido.criadoEm,
atualizadoEm: pedido.atualizadoEm,
matchingItems: matchingItems.length > 0 ? matchingItems : undefined
});
}
}
return resultados;
}
});
export const listForAcceptance = query({
args: {},
returns: v.array(
v.object({
_id: v.id('pedidos'),
_creationTime: v.number(),
numeroSei: v.optional(v.string()),
status: v.string(),
criadoPor: v.id('usuarios'),
criadoPorNome: v.string(),
criadoEm: v.number(),
atualizadoEm: v.number()
})
),
handler: async (ctx) => {
const user = await getUsuarioAutenticado(ctx);
// Security Check: Must be in Compras Sector
const config = await ctx.db.query('config').first();
if (!config || !config.comprasSetorId) return [];
if (!user.funcionarioId) return [];
const isInSector = await ctx.db
.query('funcionarioSetores')
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', user.funcionarioId!))
.filter((q) => q.eq(q.field('setorId'), config.comprasSetorId))
.first();
if (!isInSector) return [];
// Fetch orders waiting for acceptance
const orders = await ctx.db
.query('pedidos')
.withIndex('by_status', (q) => q.eq('status', 'aguardando_aceite'))
.collect();
// Enrich with creator name
return await Promise.all(
orders.map(async (o) => {
const creator = await ctx.db.get(o.criadoPor);
return {
_id: o._id,
_creationTime: o._creationTime,
numeroSei: o.numeroSei,
status: o.status,
criadoPor: o.criadoPor,
criadoPorNome: creator?.nome || 'Desconhecido',
criadoEm: o.criadoEm,
atualizadoEm: o.atualizadoEm
};
})
);
}
});
export const listMyAnalysis = query({
args: {},
returns: v.array(
v.object({
_id: v.id('pedidos'),
_creationTime: v.number(),
numeroSei: v.optional(v.string()),
status: v.string(),
criadoPor: v.id('usuarios'),
criadoPorNome: v.string(),
aceitoPor: v.optional(v.id('funcionarios')),
criadoEm: v.number(),
atualizadoEm: v.number()
})
),
handler: async (ctx) => {
const user = await getUsuarioAutenticado(ctx);
// Security Check: Must be in Compras Sector
const config = await ctx.db.query('config').first();
if (!config || !config.comprasSetorId) return [];
if (!user.funcionarioId) return [];
const isInSector = await ctx.db
.query('funcionarioSetores')
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', user.funcionarioId!))
.filter((q) => q.eq(q.field('setorId'), config.comprasSetorId))
.first();
if (!isInSector) return [];
// Fetch orders accepted by this user
// We don't have an index on aceitoPor yet, but we can filter or add index.
// Ideally we should add an index, but for now let's filter since volume might be low per user.
// Wait, we can't filter efficiently without index if table is huge.
// Let's assume we should add index or filter in memory if small.
// Given the schema change was just adding the field, let's filter.
// Actually, let's add the index in the schema update if possible, but I already did that step.
// I'll filter for now.
const orders = await ctx.db
.query('pedidos')
.filter((q) => q.eq(q.field('aceitoPor'), user.funcionarioId))
.collect();
return await Promise.all(
orders.map(async (o) => {
const creator = await ctx.db.get(o.criadoPor);
return {
_id: o._id,
_creationTime: o._creationTime,
numeroSei: o.numeroSei,
status: o.status,
criadoPor: o.criadoPor,
criadoPorNome: creator?.nome || 'Desconhecido',
aceitoPor: o.aceitoPor,
criadoEm: o.criadoEm,
atualizadoEm: o.atualizadoEm
};
})
);
}
});
export const listByItemCreator = query({
args: {},
returns: v.array(
v.object({
_id: v.id('pedidos'),
_creationTime: v.number(),
numeroSei: v.optional(v.string()),
status: v.string(),
criadoPor: v.id('usuarios'),
criadoPorNome: v.string(),
criadoEm: v.number(),
atualizadoEm: v.number()
})
),
handler: async (ctx) => {
const user = await getUsuarioAutenticado(ctx);
if (!user.funcionarioId) return [];
// Find all items added by this user
const myItems = await ctx.db
.query('objetoItems')
.withIndex('by_adicionadoPor', (q) => q.eq('adicionadoPor', user.funcionarioId!))
.collect();
// Get unique pedidoIds
const pedidoIds = [...new Set(myItems.map((i) => i.pedidoId))];
// Fetch orders
const orders = await Promise.all(pedidoIds.map((id) => ctx.db.get(id)));
// Filter out nulls and enrich
const validOrders = orders.filter((o) => o !== null);
return await Promise.all(
validOrders.map(async (o) => {
const creator = await ctx.db.get(o!.criadoPor);
return {
_id: o!._id,
_creationTime: o!._creationTime,
numeroSei: o!.numeroSei,
status: o!.status,
criadoPor: o!.criadoPor,
criadoPorNome: creator?.nome || 'Desconhecido',
criadoEm: o!.criadoEm,
atualizadoEm: o!.atualizadoEm
};
})
);
}
});
export const acceptOrder = mutation({
args: {
pedidoId: v.id('pedidos')
},
returns: v.null(),
handler: async (ctx, args) => {
const user = await getUsuarioAutenticado(ctx);
// Security Check: Must be in Compras Sector
const config = await ctx.db.query('config').first();
if (!config || !config.comprasSetorId) {
throw new Error('Setor de compras não configurado.');
}
if (!user.funcionarioId) {
throw new Error('Usuário sem funcionário vinculado.');
}
const isInSector = await ctx.db
.query('funcionarioSetores')
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', user.funcionarioId!))
.filter((q) => q.eq(q.field('setorId'), config.comprasSetorId))
.first();
if (!isInSector) {
throw new Error('Você não tem permissão para aceitar pedidos (Setor inválido).');
}
const pedido = await ctx.db.get(args.pedidoId);
if (!pedido) throw new Error('Pedido não encontrado.');
if (pedido.status !== 'aguardando_aceite') {
throw new Error('Este pedido não está aguardando aceite.');
}
if (pedido.aceitoPor) {
throw new Error('Este pedido já foi aceito por outro funcionário.');
}
await ctx.db.patch(args.pedidoId, {
status: 'em_analise',
aceitoPor: user.funcionarioId,
atualizadoEm: Date.now()
});
await ctx.db.insert('historicoPedidos', {
pedidoId: args.pedidoId,
usuarioId: user._id,
acao: 'aceite_pedido',
detalhes: JSON.stringify({ aceitoPor: user.funcionarioId }),
data: Date.now()
});
}
});
// ========== MUTATIONS ==========
export const create = mutation({
args: {
numeroSei: v.optional(v.string())
// acaoId removed
},
returns: v.id('pedidos'),
handler: async (ctx, args) => {
const user = await getUsuarioAutenticado(ctx);
// 1. Check Config
const config = await ctx.db.query('config').first();
if (!config || !config.comprasSetorId) {
throw new Error('Setor de Compras não configurado. Contate o administrador.');
}
// 2. Check Existing (Double check) - Removed acaoId check here as it's now per item
// 3. Create Order
const pedidoId = await ctx.db.insert('pedidos', {
numeroSei: args.numeroSei,
status: 'em_rascunho',
criadoPor: user._id,
criadoEm: Date.now(),
atualizadoEm: Date.now()
});
// 4. Create History
await ctx.db.insert('historicoPedidos', {
pedidoId,
usuarioId: user._id,
acao: 'criacao',
detalhes: JSON.stringify({ numeroSei: args.numeroSei }),
data: Date.now()
});
return pedidoId;
}
});
export const updateSeiNumber = mutation({
args: {
pedidoId: v.id('pedidos'),
numeroSei: v.string()
},
returns: v.null(),
handler: async (ctx, args) => {
const user = await getUsuarioAutenticado(ctx);
const pedido = await ctx.db.get(args.pedidoId);
if (!pedido) throw new Error('Pedido not found');
// Check if SEI number is already taken by another order
const existing = await ctx.db
.query('pedidos')
.filter((q) =>
q.and(q.eq(q.field('numeroSei'), args.numeroSei), q.neq(q.field('_id'), args.pedidoId))
)
.first();
if (existing) {
throw new Error('Este número SEI já está em uso por outro pedido.');
}
const oldSei = pedido.numeroSei;
await ctx.db.patch(args.pedidoId, {
numeroSei: args.numeroSei,
atualizadoEm: Date.now()
});
await ctx.db.insert('historicoPedidos', {
pedidoId: args.pedidoId,
usuarioId: user._id,
acao: 'atualizacao_sei',
detalhes: JSON.stringify({ de: oldSei, para: args.numeroSei }),
data: Date.now()
});
}
});
export const addItem = mutation({
args: {
pedidoId: v.id('pedidos'),
objetoId: v.id('objetos'),
ataId: v.optional(v.id('atas')),
acaoId: v.optional(v.id('acoes')),
modalidade: v.union(
v.literal('dispensa'),
v.literal('inexgibilidade'),
v.literal('adesao'),
v.literal('consumo')
),
valorEstimado: v.string(),
quantidade: v.number()
},
returns: v.null(),
handler: async (ctx, args) => {
const user = await getUsuarioAutenticado(ctx);
// Ensure user has a funcionarioId linked
if (!user.funcionarioId) {
throw new Error('Usuário não vinculado a um funcionário.');
}
// Check if item already exists with same parameters (user, object, action, modalidade)
const existingItem = await ctx.db
.query('objetoItems')
.withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId))
.filter((q) =>
q.and(
q.eq(q.field('objetoId'), args.objetoId),
q.eq(q.field('adicionadoPor'), user.funcionarioId),
q.eq(q.field('acaoId'), args.acaoId),
q.eq(q.field('ataId'), args.ataId),
q.eq(q.field('modalidade'), args.modalidade)
)
)
.first();
if (existingItem) {
// Increment quantity
const novaQuantidade = existingItem.quantidade + args.quantidade;
await ctx.db.patch(existingItem._id, { quantidade: novaQuantidade });
await ctx.db.insert('historicoPedidos', {
pedidoId: args.pedidoId,
usuarioId: user._id,
acao: 'adicao_item_incremento',
detalhes: JSON.stringify({
objetoId: args.objetoId,
quantidadeAdicionada: args.quantidade,
novaQuantidade
}),
data: Date.now()
});
} else {
// Insert new item
await ctx.db.insert('objetoItems', {
pedidoId: args.pedidoId,
objetoId: args.objetoId,
ataId: args.ataId,
acaoId: args.acaoId,
modalidade: args.modalidade,
valorEstimado: args.valorEstimado,
quantidade: args.quantidade,
adicionadoPor: user.funcionarioId,
criadoEm: Date.now()
});
await ctx.db.insert('historicoPedidos', {
pedidoId: args.pedidoId,
usuarioId: user._id,
acao: 'adicao_item',
detalhes: JSON.stringify({
objetoId: args.objetoId,
valor: args.valorEstimado,
quantidade: args.quantidade,
acaoId: args.acaoId,
ataId: args.ataId,
modalidade: args.modalidade
}),
data: Date.now()
});
}
await ctx.db.patch(args.pedidoId, { atualizadoEm: Date.now() });
}
});
export const updateItemQuantity = mutation({
args: {
itemId: v.id('objetoItems'),
novaQuantidade: v.number()
},
returns: v.null(),
handler: async (ctx, args) => {
const user = await getUsuarioAutenticado(ctx);
if (!user.funcionarioId) {
throw new Error('Usuário não vinculado a um funcionário.');
}
const item = await ctx.db.get(args.itemId);
if (!item) throw new Error('Item não encontrado.');
const quantidadeAnterior = item.quantidade;
// Check permission: only item owner can change quantity
const isOwner = item.adicionadoPor === user.funcionarioId;
if (!isOwner) {
throw new Error('Apenas quem adicionou este item pode alterar a quantidade.');
}
// Update quantity
await ctx.db.patch(args.itemId, { quantidade: args.novaQuantidade });
await ctx.db.patch(item.pedidoId, { atualizadoEm: Date.now() });
// Create history entry
await ctx.db.insert('historicoPedidos', {
pedidoId: item.pedidoId,
usuarioId: user._id,
acao: 'alteracao_quantidade',
detalhes: JSON.stringify({
objetoId: item.objetoId,
quantidadeAnterior,
novaQuantidade: args.novaQuantidade
}),
data: Date.now()
});
}
});
export const removeItem = mutation({
args: {
itemId: v.id('objetoItems')
},
returns: v.null(),
handler: async (ctx, args) => {
const user = await getUsuarioAutenticado(ctx);
const item = await ctx.db.get(args.itemId);
if (!item) throw new Error('Item not found');
await ctx.db.delete(args.itemId);
await ctx.db.patch(item.pedidoId, { atualizadoEm: Date.now() });
await ctx.db.insert('historicoPedidos', {
pedidoId: item.pedidoId,
usuarioId: user._id,
acao: 'remocao_item',
detalhes: JSON.stringify({
objetoId: item.objetoId,
valor: item.valorEstimado
}),
data: Date.now()
});
}
});
export const removeItemsBatch = mutation({
args: {
itemIds: v.array(v.id('objetoItems'))
},
returns: v.null(),
handler: async (ctx, args) => {
const user = await getUsuarioAutenticado(ctx);
if (!user.funcionarioId) {
throw new Error('Usuário não vinculado a um funcionário.');
}
if (args.itemIds.length === 0) {
return null;
}
const firstItem = await ctx.db.get(args.itemIds[0]);
if (!firstItem) {
throw new Error('Item não encontrado.');
}
const pedidoId = firstItem.pedidoId;
const pedido = await ctx.db.get(pedidoId);
if (!pedido) {
throw new Error('Pedido não encontrado.');
}
if (pedido.status !== 'em_rascunho' && pedido.status !== 'precisa_ajustes') {
throw new Error(
'Só é possível remover itens em pedidos em rascunho ou que precisam de ajustes.'
);
}
for (const itemId of args.itemIds) {
const item = await ctx.db.get(itemId);
if (!item) continue;
if (item.pedidoId !== pedidoId) {
throw new Error('Todos os itens devem pertencer ao mesmo pedido.');
}
if (item.adicionadoPor !== user.funcionarioId) {
throw new Error('Você só pode remover itens que você adicionou.');
}
await ctx.db.delete(itemId);
await ctx.db.insert('historicoPedidos', {
pedidoId,
usuarioId: user._id,
acao: 'remocao_item',
detalhes: JSON.stringify({
objetoId: item.objetoId,
valor: item.valorEstimado
}),
data: Date.now()
});
}
await ctx.db.patch(pedidoId, { atualizadoEm: Date.now() });
return null;
}
});
export const splitPedido = mutation({
args: {
pedidoId: v.id('pedidos'),
itemIds: v.array(v.id('objetoItems')),
numeroSei: v.optional(v.string())
},
returns: v.id('pedidos'),
handler: async (ctx, args) => {
const user = await getUsuarioAutenticado(ctx);
if (!user.funcionarioId) {
throw new Error('Usuário não vinculado a um funcionário.');
}
if (args.itemIds.length === 0) {
throw new Error('Selecione ao menos um item para dividir o pedido.');
}
const pedidoOriginal = await ctx.db.get(args.pedidoId);
if (!pedidoOriginal) {
throw new Error('Pedido não encontrado.');
}
if (pedidoOriginal.status !== 'em_rascunho' && pedidoOriginal.status !== 'precisa_ajustes') {
throw new Error('Só é possível dividir pedidos em rascunho ou que precisam de ajustes.');
}
const itens = [];
for (const itemId of args.itemIds) {
const item = await ctx.db.get(itemId);
if (!item) {
continue;
}
if (item.pedidoId !== args.pedidoId) {
throw new Error('Todos os itens devem pertencer ao mesmo pedido.');
}
if (item.adicionadoPor !== user.funcionarioId) {
throw new Error('Você só pode mover itens que você adicionou.');
}
itens.push(item);
}
if (itens.length === 0) {
throw new Error('Nenhum dos itens selecionados pôde ser usado para divisão.');
}
const novoPedidoId = await ctx.db.insert('pedidos', {
numeroSei: args.numeroSei,
status: 'em_rascunho',
criadoPor: user._id,
criadoEm: Date.now(),
atualizadoEm: Date.now()
});
for (const item of itens) {
await ctx.db.patch(item._id, {
pedidoId: novoPedidoId
});
}
await ctx.db.insert('historicoPedidos', {
pedidoId: args.pedidoId,
usuarioId: user._id,
acao: 'divisao_pedido_origem',
detalhes: JSON.stringify({
itensMovidos: itens.map((i) => i._id),
novoPedidoId
}),
data: Date.now()
});
await ctx.db.insert('historicoPedidos', {
pedidoId: novoPedidoId,
usuarioId: user._id,
acao: 'divisao_pedido_destino',
detalhes: JSON.stringify({
pedidoOriginalId: args.pedidoId,
itensRecebidos: itens.map((i) => i._id)
}),
data: Date.now()
});
await ctx.db.patch(args.pedidoId, { atualizadoEm: Date.now() });
return novoPedidoId;
}
});
export const updateItem = mutation({
args: {
itemId: v.id('objetoItems'),
valorEstimado: v.string(),
modalidade: v.union(
v.literal('dispensa'),
v.literal('inexgibilidade'),
v.literal('adesao'),
v.literal('consumo')
),
acaoId: v.optional(v.id('acoes')),
ataId: v.optional(v.id('atas'))
},
returns: v.null(),
handler: async (ctx, args) => {
const user = await getUsuarioAutenticado(ctx);
if (!user.funcionarioId) {
throw new Error('Usuário não vinculado a um funcionário.');
}
const item = await ctx.db.get(args.itemId);
if (!item) throw new Error('Item não encontrado.');
// Apenas quem adicionou o item pode editá-lo
const isOwner = item.adicionadoPor === user.funcionarioId;
if (!isOwner) {
throw new Error('Apenas quem adicionou este item pode editá-lo.');
}
const oldValues = {
valorEstimado: item.valorEstimado,
modalidade: item.modalidade,
acaoId: 'acaoId' in item ? item.acaoId : undefined,
ataId: 'ataId' in item ? item.ataId : undefined
};
await ctx.db.patch(args.itemId, {
valorEstimado: args.valorEstimado,
modalidade: args.modalidade,
acaoId: args.acaoId,
ataId: args.ataId
});
await ctx.db.patch(item.pedidoId, { atualizadoEm: Date.now() });
await ctx.db.insert('historicoPedidos', {
pedidoId: item.pedidoId,
usuarioId: user._id,
acao: 'edicao_item',
detalhes: JSON.stringify({
objetoId: item.objetoId,
de: oldValues,
para: {
valorEstimado: args.valorEstimado,
modalidade: args.modalidade,
acaoId: args.acaoId,
ataId: args.ataId
}
}),
data: Date.now()
});
}
});
export const getPermissions = query({
args: { pedidoId: v.id('pedidos') },
handler: async (ctx, args) => {
const user = await getUsuarioAutenticado(ctx);
const pedido = await ctx.db.get(args.pedidoId);
if (!pedido || !user.funcionarioId) {
return {
canSendToAcceptance: false,
canStartAnalysis: false,
canConclude: false,
canRequestAdjustments: false,
canCancel: false
};
}
// Check Compras Sector
let isInComprasSector = false;
const config = await ctx.db.query('config').first();
if (config && config.comprasSetorId) {
const funcionarioSetores = await ctx.db
.query('funcionarioSetores')
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', user.funcionarioId!))
.filter((q) => q.eq(q.field('setorId'), config.comprasSetorId))
.first();
isInComprasSector = !!funcionarioSetores;
}
// Check if user has added items
// Optimized: Check if ANY item in this order was added by this user
const userItem = await ctx.db
.query('objetoItems')
.withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId))
.filter((q) => q.eq(q.field('adicionadoPor'), user.funcionarioId))
.first();
const hasAddedItems = !!userItem;
const isCreator = pedido.criadoPor === user._id;
return {
canSendToAcceptance:
(pedido.status === 'em_rascunho' || pedido.status === 'precisa_ajustes') && hasAddedItems,
canStartAnalysis: pedido.status === 'aguardando_aceite' && isInComprasSector,
canConclude: pedido.status === 'em_analise' && isInComprasSector,
canRequestAdjustments: pedido.status === 'em_analise' && isInComprasSector,
canCancel:
pedido.status !== 'cancelado' &&
pedido.status !== 'concluido' &&
(isCreator || isInComprasSector)
};
}
});
export const enviarParaAceite = mutation({
args: { pedidoId: v.id('pedidos') },
returns: v.null(),
handler: async (ctx, args) => {
const user = await getUsuarioAutenticado(ctx);
if (!user.funcionarioId) throw new Error('Usuário sem funcionário vinculado.');
const pedido = await ctx.db.get(args.pedidoId);
if (!pedido) throw new Error('Pedido não encontrado.');
if (pedido.status !== 'em_rascunho' && pedido.status !== 'precisa_ajustes') {
throw new Error('Status inválido para envio.');
}
// Check if user has added items
const userItem = await ctx.db
.query('objetoItems')
.withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId))
.filter((q) => q.eq(q.field('adicionadoPor'), user.funcionarioId))
.first();
if (!userItem) {
throw new Error(
'Você precisa ter adicionado ao menos um item ao pedido para enviá-lo para aceite.'
);
}
const oldStatus = pedido.status;
const newStatus = 'aguardando_aceite';
await ctx.db.patch(args.pedidoId, {
status: newStatus,
atualizadoEm: Date.now()
});
await ctx.db.insert('historicoPedidos', {
pedidoId: args.pedidoId,
usuarioId: user._id,
acao: 'alteracao_status',
detalhes: JSON.stringify({ de: oldStatus, para: newStatus }),
data: Date.now()
});
await ctx.scheduler.runAfter(0, internal.pedidos.notifyStatusChange, {
pedidoId: args.pedidoId,
oldStatus,
newStatus,
actorId: user._id
});
}
});
export const iniciarAnalise = mutation({
args: { pedidoId: v.id('pedidos') },
returns: v.null(),
handler: async (ctx, args) => {
const user = await getUsuarioAutenticado(ctx);
const pedido = await ctx.db.get(args.pedidoId);
if (!pedido) throw new Error('Pedido não encontrado.');
if (pedido.status !== 'aguardando_aceite') {
throw new Error('O pedido não está aguardando aceite.');
}
// Security Check: Must be in Compras Sector
const config = await ctx.db.query('config').first();
if (!config || !config.comprasSetorId) throw new Error('Setor de compras não configurado.');
const isInSector = await ctx.db
.query('funcionarioSetores')
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', user.funcionarioId!))
.filter((q) => q.eq(q.field('setorId'), config.comprasSetorId))
.first();
if (!isInSector) throw new Error('Acesso não autorizado (Setor de Compras).');
const oldStatus = pedido.status;
const newStatus = 'em_analise';
await ctx.db.patch(args.pedidoId, {
status: newStatus,
aceitoPor: user.funcionarioId, // Also mark as accepted by this user
atualizadoEm: Date.now()
});
await ctx.db.insert('historicoPedidos', {
pedidoId: args.pedidoId,
usuarioId: user._id,
acao: 'alteracao_status',
detalhes: JSON.stringify({ de: oldStatus, para: newStatus }),
data: Date.now()
});
await ctx.scheduler.runAfter(0, internal.pedidos.notifyStatusChange, {
pedidoId: args.pedidoId,
oldStatus,
newStatus,
actorId: user._id
});
}
});
export const concluirPedido = mutation({
args: { pedidoId: v.id('pedidos') },
returns: v.null(),
handler: async (ctx, args) => {
const user = await getUsuarioAutenticado(ctx);
const pedido = await ctx.db.get(args.pedidoId);
if (!pedido) throw new Error('Pedido não encontrado.');
if (pedido.status !== 'em_analise') {
throw new Error('O pedido deve estar em análise para ser concluído.');
}
// Security Check: Must be in Compras Sector
const config = await ctx.db.query('config').first();
if (!config || !config.comprasSetorId) throw new Error('Setor de compras não configurado.');
const isInSector = await ctx.db
.query('funcionarioSetores')
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', user.funcionarioId!))
.filter((q) => q.eq(q.field('setorId'), config.comprasSetorId))
.first();
if (!isInSector) throw new Error('Acesso não autorizado (Setor de Compras).');
const oldStatus = pedido.status;
const newStatus = 'concluido';
await ctx.db.patch(args.pedidoId, {
status: newStatus,
atualizadoEm: Date.now()
});
await ctx.db.insert('historicoPedidos', {
pedidoId: args.pedidoId,
usuarioId: user._id,
acao: 'alteracao_status',
detalhes: JSON.stringify({ de: oldStatus, para: newStatus }),
data: Date.now()
});
await ctx.scheduler.runAfter(0, internal.pedidos.notifyStatusChange, {
pedidoId: args.pedidoId,
oldStatus,
newStatus,
actorId: user._id
});
}
});
export const solicitarAjustes = mutation({
args: { pedidoId: v.id('pedidos') },
returns: v.null(),
handler: async (ctx, args) => {
const user = await getUsuarioAutenticado(ctx);
const pedido = await ctx.db.get(args.pedidoId);
if (!pedido) throw new Error('Pedido não encontrado.');
if (pedido.status !== 'em_analise') {
throw new Error('O pedido deve estar em análise para solicitar ajustes.');
}
// Security Check: Must be in Compras Sector
const config = await ctx.db.query('config').first();
if (!config || !config.comprasSetorId) throw new Error('Setor de compras não configurado.');
const isInSector = await ctx.db
.query('funcionarioSetores')
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', user.funcionarioId!))
.filter((q) => q.eq(q.field('setorId'), config.comprasSetorId))
.first();
if (!isInSector) throw new Error('Acesso não autorizado (Setor de Compras).');
const oldStatus = pedido.status;
const newStatus = 'precisa_ajustes';
await ctx.db.patch(args.pedidoId, {
status: newStatus,
aceitoPor: undefined, // Clear accepted by since it's back to adjustments
atualizadoEm: Date.now()
});
await ctx.db.insert('historicoPedidos', {
pedidoId: args.pedidoId,
usuarioId: user._id,
acao: 'alteracao_status',
detalhes: JSON.stringify({ de: oldStatus, para: newStatus }),
data: Date.now()
});
await ctx.scheduler.runAfter(0, internal.pedidos.notifyStatusChange, {
pedidoId: args.pedidoId,
oldStatus,
newStatus,
actorId: user._id
});
}
});
export const cancelarPedido = mutation({
args: { pedidoId: v.id('pedidos') },
returns: v.null(),
handler: async (ctx, args) => {
const user = await getUsuarioAutenticado(ctx);
const pedido = await ctx.db.get(args.pedidoId);
if (!pedido) throw new Error('Pedido não encontrado.');
if (pedido.status === 'concluido' || pedido.status === 'cancelado') {
throw new Error('Pedido já finalizado.');
}
// Anyone involved (creator or compras) can cancel? Or just creator?
// Logic: If it's creator OR Compras.
const isCreator = pedido.criadoPor === user._id;
let isCompras = false;
const config = await ctx.db.query('config').first();
if (config && config.comprasSetorId && user.funcionarioId) {
const fs = await ctx.db
.query('funcionarioSetores')
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', user.funcionarioId!))
.filter((q) => q.eq(q.field('setorId'), config.comprasSetorId))
.first();
isCompras = !!fs;
}
if (!isCreator && !isCompras) {
throw new Error('Permissão negada para cancelar este pedido.');
}
const oldStatus = pedido.status;
const newStatus = 'cancelado';
await ctx.db.patch(args.pedidoId, {
status: newStatus,
atualizadoEm: Date.now()
});
await ctx.db.insert('historicoPedidos', {
pedidoId: args.pedidoId,
usuarioId: user._id,
acao: 'alteracao_status',
detalhes: JSON.stringify({ de: oldStatus, para: newStatus }),
data: Date.now()
});
await ctx.scheduler.runAfter(0, internal.pedidos.notifyStatusChange, {
pedidoId: args.pedidoId,
oldStatus,
newStatus,
actorId: user._id
});
}
});
// ========== INTERNAL (NOTIFICATIONS) ==========
export const notifyStatusChange = internalMutation({
args: {
pedidoId: v.id('pedidos'),
oldStatus: v.string(),
newStatus: v.string(),
actorId: v.id('usuarios')
},
returns: v.null(),
handler: async (ctx, args) => {
const pedido = await ctx.db.get(args.pedidoId);
if (!pedido) return;
const actor = await ctx.db.get(args.actorId);
const actorName = actor ? actor.nome : 'Alguém';
const recipients = new Set<string>(); // Set of User IDs
// 1. If status is "aguardando_aceite", notify Purchasing Sector
if (args.newStatus === 'aguardando_aceite') {
const config = await ctx.db.query('config').first();
if (config && config.comprasSetorId) {
// Find all employees in this sector
const funcionarioSetores = await ctx.db
.query('funcionarioSetores')
.withIndex('by_setorId', (q) => q.eq('setorId', config.comprasSetorId!))
.collect();
const funcionarioIds = funcionarioSetores.map((fs) => fs.funcionarioId);
// Find users linked to these employees
for (const fId of funcionarioIds) {
const user = await ctx.db
.query('usuarios')
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', fId))
.first();
if (user) recipients.add(user._id);
}
}
}
// 2. Notify "Involved" users (Creator + Item Adders)
// Always notify creator (unless they are the actor)
if (pedido.criadoPor !== args.actorId) {
recipients.add(pedido.criadoPor);
}
// Notify item adders
const items = await ctx.db
.query('objetoItems')
.withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId))
.collect();
for (const item of items) {
const user = await ctx.db
.query('usuarios')
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', item.adicionadoPor))
.first();
if (user && user._id !== args.actorId) {
recipients.add(user._id);
}
}
// Send Notifications
for (const recipientId of recipients) {
const recipientIdTyped = recipientId as Id<'usuarios'>;
// 1. In-App Notification
await ctx.db.insert('notificacoes', {
usuarioId: recipientIdTyped,
tipo: 'alerta_seguranca', // Using alerta_seguranca as the closest match for system notifications
titulo: `Pedido ${pedido.numeroSei || 'sem número SEI'} atualizado`,
descricao: `Status alterado de "${args.oldStatus}" para "${args.newStatus}" por ${actorName}.`,
lida: false,
criadaEm: Date.now(),
remetenteId: args.actorId
});
// 2. Email Notification (Async)
const recipientUser = await ctx.db.get(recipientIdTyped);
if (recipientUser && recipientUser.email) {
// Using enfileirarEmail directly
await ctx.scheduler.runAfter(0, api.email.enfileirarEmail, {
destinatario: recipientUser.email,
destinatarioId: recipientIdTyped,
assunto: `Atualização no Pedido ${pedido.numeroSei || 'sem número SEI'}`,
corpo: `O pedido ${pedido.numeroSei || 'sem número SEI'} teve seu status alterado de "${args.oldStatus}" para "${args.newStatus}" por ${actorName}.`,
enviadoPor: args.actorId
});
}
}
}
});