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

2944 lines
86 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';
import { getTodayYMD, isWithinRangeYMD, maxYMD } from './utils/datas';
// ========== HELPERS ==========
const pedidoStatusValidator = v.union(
v.literal('em_rascunho'),
v.literal('aguardando_aceite'),
v.literal('em_analise'),
v.literal('precisa_ajustes'),
v.literal('cancelado'),
v.literal('concluido')
);
type PedidoStatus = Doc<'pedidos'>['status'];
type PedidoListFilters = {
statuses?: PedidoStatus[];
numeroSei?: string;
criadoPor?: Id<'usuarios'>;
aceitoPor?: Id<'funcionarios'>;
periodoInicio?: number;
periodoFim?: number;
};
function inRange(ts: number, inicio?: number, fim?: number): boolean {
if (inicio !== undefined && ts < inicio) return false;
if (fim !== undefined && ts > fim) return false;
return true;
}
function matchesPeriodo(p: Doc<'pedidos'>, inicio?: number, fim?: number): boolean {
if (inicio === undefined && fim === undefined) return true;
if (inRange(p.criadoEm, inicio, fim)) return true;
if (p.concluidoEm !== undefined && inRange(p.concluidoEm, inicio, fim)) return true;
return false;
}
function normalizeNumeroSeiQuery(q: string | undefined): string | undefined {
const trimmed = q?.trim();
return trimmed ? trimmed.toLowerCase() : undefined;
}
function applyPedidoFilters(pedidos: Doc<'pedidos'>[], args: PedidoListFilters): Doc<'pedidos'>[] {
const numeroSeiQuery = normalizeNumeroSeiQuery(args.numeroSei);
const statusesSet =
args.statuses && args.statuses.length > 0 ? new Set<PedidoStatus>(args.statuses) : null;
const filtered = pedidos.filter((p) => {
if (statusesSet && !statusesSet.has(p.status)) return false;
if (args.criadoPor && p.criadoPor !== args.criadoPor) return false;
if (args.aceitoPor && p.aceitoPor !== args.aceitoPor) return false;
if (!matchesPeriodo(p, args.periodoInicio, args.periodoFim)) return false;
if (numeroSeiQuery) {
const sei = (p.numeroSei ?? '').toLowerCase();
if (!sei.includes(numeroSeiQuery)) return false;
}
return true;
});
// Ordem mais útil para listagem: mais recentes primeiro
filtered.sort((a, b) => b.criadoEm - a.criadoEm);
return filtered;
}
function dedupePedidos(pedidos: Doc<'pedidos'>[]): Doc<'pedidos'>[] {
const map = new Map<string, Doc<'pedidos'>>();
for (const p of pedidos) map.set(String(p._id), p);
return [...map.values()];
}
async function fetchPedidosBase(ctx: QueryCtx, args: PedidoListFilters): Promise<Doc<'pedidos'>[]> {
// 1) Se há período, buscamos por índices de criadoEm e concluidoEm e unimos (OR)
if (args.periodoInicio !== undefined || args.periodoFim !== undefined) {
const inicio = args.periodoInicio;
const fim = args.periodoFim;
const byCriado =
inicio !== undefined && fim !== undefined
? await ctx.db
.query('pedidos')
.withIndex('by_criadoEm', (q) => q.gte('criadoEm', inicio).lte('criadoEm', fim))
.collect()
: inicio !== undefined
? await ctx.db
.query('pedidos')
.withIndex('by_criadoEm', (q) => q.gte('criadoEm', inicio))
.collect()
: await ctx.db
.query('pedidos')
.withIndex('by_criadoEm', (q) => q.lte('criadoEm', fim as number))
.collect();
const byConcluido =
inicio !== undefined && fim !== undefined
? await ctx.db
.query('pedidos')
.withIndex('by_concluidoEm', (q) =>
q.gte('concluidoEm', inicio).lte('concluidoEm', fim)
)
.collect()
: inicio !== undefined
? await ctx.db
.query('pedidos')
.withIndex('by_concluidoEm', (q) => q.gte('concluidoEm', inicio))
.collect()
: await ctx.db
.query('pedidos')
.withIndex('by_concluidoEm', (q) => q.lte('concluidoEm', fim as number))
.collect();
return dedupePedidos(byCriado.concat(byConcluido));
}
// 2) Se há statuses selecionados, usar o índice by_status
if (args.statuses && args.statuses.length > 0) {
let out: Doc<'pedidos'>[] = [];
for (const status of args.statuses) {
const part = await ctx.db
.query('pedidos')
.withIndex('by_status', (q) => q.eq('status', status))
.collect();
out = out.concat(part);
}
return dedupePedidos(out);
}
// 3) Se há criador, usar by_criadoPor
if (args.criadoPor) {
return await ctx.db
.query('pedidos')
.withIndex('by_criadoPor', (q) => q.eq('criadoPor', args.criadoPor!))
.collect();
}
// 4) Se há aceitoPor, usar by_aceitoPor
if (args.aceitoPor) {
return await ctx.db
.query('pedidos')
.withIndex('by_aceitoPor', (q) => q.eq('aceitoPor', args.aceitoPor!))
.collect();
}
// 5) fallback: varrer (caso sem índices úteis)
return await ctx.db.query('pedidos').collect();
}
async function getUsuarioAutenticado(ctx: QueryCtx | MutationCtx) {
const user = await getCurrentUserFunction(ctx);
if (!user) throw new Error('Unauthorized');
return user;
}
// Garante que todos os itens de um pedido utilizem a mesma
// combinação de modalidade e ata (quando houver).
async function ensurePedidoModalidadeAtaConsistency(
ctx: MutationCtx,
pedidoId: Id<'pedidos'>,
modalidade: Doc<'objetoItems'>['modalidade'],
ataId: Id<'atas'> | undefined,
ignoreItemId?: Id<'objetoItems'>
) {
const normalizedNewAtaId = ataId ?? null;
const items = await ctx.db
.query('objetoItems')
.withIndex('by_pedidoId', (q) => q.eq('pedidoId', pedidoId))
.collect();
if (items.length === 0) {
return;
}
for (const item of items) {
if (ignoreItemId && item._id === ignoreItemId) continue;
const normalizedItemAtaId = (('ataId' in item ? item.ataId : undefined) ??
null) as Id<'atas'> | null;
if (item.modalidade !== modalidade || normalizedItemAtaId !== normalizedNewAtaId) {
throw new Error(
'Todos os itens do pedido devem usar a mesma modalidade e a mesma ata (quando houver). Ajuste os itens existentes ou crie um novo pedido para a nova combinação.'
);
}
}
}
type AtaObjetoUsageInfo = {
linkId: Id<'atasObjetos'>;
quantidadeTotal: number;
limitePercentual: number;
limitePermitido: number;
quantidadeUsada: number;
restante: number;
};
function normalizeLimitePercentual(value: number | undefined): number {
const fallback = 50;
if (value === undefined) return fallback;
if (!Number.isFinite(value)) return fallback;
// Normalizar para faixa 0..100
if (value < 0) return 0;
if (value > 100) return 100;
return value;
}
function computeLimitePermitido(quantidadeTotal: number, limitePercentual: number): number {
if (!Number.isFinite(quantidadeTotal) || quantidadeTotal <= 0) return 0;
const pct = normalizeLimitePercentual(limitePercentual);
return Math.floor(quantidadeTotal * (pct / 100));
}
async function getAtaObjetoLinkOrThrow(
ctx: MutationCtx,
ataId: Id<'atas'>,
objetoId: Id<'objetos'>
): Promise<Doc<'atasObjetos'>> {
const link = await ctx.db
.query('atasObjetos')
.withIndex('by_ataId_and_objetoId', (q) => q.eq('ataId', ataId).eq('objetoId', objetoId))
.unique();
if (!link) {
throw new Error('Esta ata não está vinculada a este objeto.');
}
return link;
}
async function computeQuantidadeUsadaFromDb(
ctx: MutationCtx,
ataId: Id<'atas'>,
objetoId: Id<'objetos'>
): Promise<number> {
const items = await ctx.db
.query('objetoItems')
.withIndex('by_ataId_and_objetoId', (q) => q.eq('ataId', ataId).eq('objetoId', objetoId))
.collect();
const sumByPedidoId = new Map<Id<'pedidos'>, number>();
for (const item of items) {
const prev = sumByPedidoId.get(item.pedidoId) ?? 0;
sumByPedidoId.set(item.pedidoId, prev + item.quantidade);
}
let total = 0;
for (const [pedidoId, sum] of sumByPedidoId.entries()) {
const pedido = await ctx.db.get(pedidoId);
if (pedido && pedido.status !== 'cancelado') {
total += sum;
}
}
return total;
}
async function getAtaObjetoUsageInfo(
ctx: MutationCtx,
ataId: Id<'atas'>,
objetoId: Id<'objetos'>,
requireConfigured: boolean
): Promise<AtaObjetoUsageInfo> {
const link = await getAtaObjetoLinkOrThrow(ctx, ataId, objetoId);
const quantidadeTotalRaw = link.quantidadeTotal;
const hasQuantidadeTotal =
quantidadeTotalRaw !== undefined &&
Number.isFinite(quantidadeTotalRaw) &&
quantidadeTotalRaw > 0;
if (requireConfigured && !hasQuantidadeTotal) {
throw new Error(
'Esta ata está vinculada ao objeto, mas a quantidade do produto na ata não foi configurada. Configure a quantidade/limite antes de usar esta ata.'
);
}
const limitePercentual = normalizeLimitePercentual(link.limitePercentual);
const quantidadeTotal = hasQuantidadeTotal ? (quantidadeTotalRaw as number) : 0;
const limitePermitido = computeLimitePermitido(quantidadeTotal, limitePercentual);
let quantidadeUsada = link.quantidadeUsada;
if (quantidadeUsada === undefined) {
quantidadeUsada = await computeQuantidadeUsadaFromDb(ctx, ataId, objetoId);
await ctx.db.patch(link._id, { quantidadeUsada });
}
const restante = Math.max(0, limitePermitido - quantidadeUsada);
return {
linkId: link._id,
quantidadeTotal,
limitePercentual,
limitePermitido,
quantidadeUsada,
restante
};
}
async function assertAtaObjetoCanConsume(
ctx: MutationCtx,
ataId: Id<'atas'>,
objetoId: Id<'objetos'>,
delta: number
) {
if (!Number.isFinite(delta) || delta <= 0) return;
// Bloqueia consumo se a ata estiver fora da vigência (considerando prorrogação).
const ata = await ctx.db.get(ataId);
if (!ata) {
throw new Error('Ata não encontrada.');
}
const dataProrrogacao = (ata as { dataProrrogacao?: string }).dataProrrogacao ?? null;
const dataFimEfetiva = maxYMD(ata.dataFim ?? null, dataProrrogacao);
const today = getTodayYMD();
const vigenteHoje = isWithinRangeYMD(today, ata.dataInicio ?? null, dataFimEfetiva);
if (!vigenteHoje) {
const inicioLabel = ata.dataInicio ?? 'sem início';
const fimLabel = dataFimEfetiva ?? 'sem fim';
throw new Error(
`Não é possível consumir esta ata pois ela está fora da vigência. Vigência: ${inicioLabel} até ${fimLabel}.`
);
}
const info = await getAtaObjetoUsageInfo(ctx, ataId, objetoId, true);
if (info.quantidadeUsada + delta > info.limitePermitido) {
throw new Error(
`Limite de uso da ata atingido para este objeto. Limite permitido: ${info.limitePermitido}. Usado: ${info.quantidadeUsada}. Tentativa de adicionar: ${delta}. Restante: ${info.restante}.`
);
}
}
async function applyAtaObjetoUsageDelta(
ctx: MutationCtx,
ataId: Id<'atas'>,
objetoId: Id<'objetos'>,
delta: number
) {
if (!Number.isFinite(delta) || delta === 0) return;
const info = await getAtaObjetoUsageInfo(ctx, ataId, objetoId, delta > 0);
const novoUsadoRaw = info.quantidadeUsada + delta;
const novoUsado = Math.max(0, novoUsadoRaw);
if (delta > 0 && novoUsado > info.limitePermitido) {
throw new Error(
`Limite de uso da ata atingido para este objeto. Limite permitido: ${info.limitePermitido}. Usado: ${info.quantidadeUsada}. Tentativa de adicionar: ${delta}. Restante: ${info.restante}.`
);
}
await ctx.db.patch(info.linkId, { quantidadeUsada: novoUsado });
}
async function isFuncionarioInComprasSector(
ctx: QueryCtx | MutationCtx,
funcionarioId: Id<'funcionarios'>
) {
const config = await ctx.db.query('config').first();
if (!config || !config.comprasSetorId) return false;
const isInSector = await ctx.db
.query('funcionarioSetores')
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', funcionarioId))
.filter((q) => q.eq(q.field('setorId'), config.comprasSetorId))
.first();
return !!isInSector;
}
async function isUsuarioEnvolvidoNoPedido(
ctx: QueryCtx | MutationCtx,
pedidoId: Id<'pedidos'>,
user: Awaited<ReturnType<typeof getUsuarioAutenticado>>
) {
const pedido = await ctx.db.get(pedidoId);
if (!pedido) return false;
// Criador do pedido (por usuarioId)
if (pedido.criadoPor === user._id) return true;
// Envolvimento por itens (requer funcionarioId)
if (!user.funcionarioId) return false;
const hasItem = await ctx.db
.query('objetoItems')
.withIndex('by_pedidoId', (q) => q.eq('pedidoId', pedidoId))
.filter((q) => q.eq(q.field('adicionadoPor'), user.funcionarioId))
.first();
return !!hasItem;
}
async function assertPodeGerenciarDocumentosDoPedido(
ctx: QueryCtx | MutationCtx,
pedidoId: Id<'pedidos'>,
user: Awaited<ReturnType<typeof getUsuarioAutenticado>>
) {
const isEnvolvido = await isUsuarioEnvolvidoNoPedido(ctx, pedidoId, user);
if (isEnvolvido) return;
if (user.funcionarioId) {
const isCompras = await isFuncionarioInComprasSector(ctx, user.funcionarioId);
if (isCompras) return;
}
throw new Error('Acesso negado.');
}
// ========== QUERIES ==========
export const list = query({
args: {
statuses: v.optional(v.array(pedidoStatusValidator)),
numeroSei: v.optional(v.string()),
criadoPor: v.optional(v.id('usuarios')),
aceitoPor: v.optional(v.id('funcionarios')),
periodoInicio: v.optional(v.number()),
periodoFim: v.optional(v.number())
},
returns: v.array(
v.object({
_id: v.id('pedidos'),
_creationTime: v.number(),
numeroSei: v.optional(v.string()),
numeroDfd: v.optional(v.string()),
status: pedidoStatusValidator,
// acaoId removed from return
criadoPor: v.id('usuarios'),
criadoPorNome: v.string(),
aceitoPor: v.optional(v.id('funcionarios')),
aceitoPorNome: v.optional(v.string()),
descricaoAjuste: v.optional(v.string()),
concluidoEm: v.optional(v.number()),
criadoEm: v.number(),
atualizadoEm: v.number()
})
),
handler: async (ctx, args) => {
const base = await fetchPedidosBase(ctx, args);
const pedidos = applyPedidoFilters(base, args);
return await Promise.all(
pedidos.map(async (p) => {
const creator = await ctx.db.get(p.criadoPor);
const aceito = p.aceitoPor ? await ctx.db.get(p.aceitoPor) : null;
return {
_id: p._id,
_creationTime: p._creationTime,
numeroSei: p.numeroSei,
numeroDfd: p.numeroDfd,
status: p.status,
criadoPor: p.criadoPor,
criadoPorNome: creator?.nome || 'Desconhecido',
aceitoPor: p.aceitoPor,
aceitoPorNome: aceito?.nome || undefined,
descricaoAjuste: p.descricaoAjuste,
concluidoEm: p.concluidoEm,
criadoEm: p.criadoEm,
atualizadoEm: p.atualizadoEm
};
})
);
}
});
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()),
numeroDfd: v.optional(v.string()),
status: pedidoStatusValidator,
acaoId: v.optional(v.id('acoes')),
criadoPor: v.id('usuarios'),
aceitoPor: v.optional(v.id('funcionarios')),
descricaoAjuste: v.optional(v.string()),
concluidoEm: v.optional(v.number()),
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()),
numeroDfd: v.optional(v.string()),
status: v.union(
v.literal('em_rascunho'),
v.literal('aguardando_aceite'),
v.literal('em_analise'),
v.literal('precisa_ajustes'),
v.literal('cancelado'),
v.literal('concluido')
),
// acaoId removed
criadoPor: v.id('usuarios'),
aceitoPor: v.optional(v.id('funcionarios')),
descricaoAjuste: v.optional(v.string()),
criadoEm: v.number(),
atualizadoEm: v.number(),
matchingItems: v.optional(
v.array(
v.object({
objetoId: v.id('objetos'),
modalidade: v.union(
v.literal('dispensa'),
v.literal('inexgibilidade'),
v.literal('adesao'),
v.literal('consumo')
),
quantidade: v.number()
})
)
)
})
),
handler: async (ctx, args) => {
const user = await getCurrentUserFunction(ctx);
if (!user) return [];
const openStatuses: Array<
'em_rascunho' | 'aguardando_aceite' | 'em_analise' | 'precisa_ajustes'
> = ['em_rascunho', 'aguardando_aceite', 'em_analise', 'precisa_ajustes'];
// 1) Buscar todos os pedidos "abertos" usando o índice by_status
let pedidosAbertos: Doc<'pedidos'>[] = [];
for (const status of openStatuses) {
const partial = await ctx.db
.query('pedidos')
.withIndex('by_status', (q) => q.eq('status', status))
.collect();
pedidosAbertos = pedidosAbertos.concat(partial);
}
// 2) Filtros opcionais: numeroSei
pedidosAbertos = pedidosAbertos.filter((p) => {
if (args.numeroSei && p.numeroSei !== args.numeroSei) return false;
return true;
});
// 3) Filtro por itens (objetoId + modalidade), se informado, e coleta de matchingItems
const resultados = [];
const itensFiltro = args.itensFiltro ?? [];
for (const pedido of pedidosAbertos) {
let include = true;
let matchingItems: {
objetoId: Id<'objetos'>;
modalidade: Doc<'objetoItems'>['modalidade'];
quantidade: number;
}[] = [];
// Se houver filtro de itens, verificamos se o pedido tem ALGUM dos itens (objetoId + modalidade)
if (itensFiltro.length > 0) {
const items = await ctx.db
.query('objetoItems')
.withIndex('by_pedidoId', (q) => q.eq('pedidoId', pedido._id))
.collect();
const matching = items.filter((i) =>
itensFiltro.some((f) => f.objetoId === i.objetoId && f.modalidade === i.modalidade)
);
if (matching.length > 0) {
matchingItems = matching.map((i) => ({
objetoId: i.objetoId,
modalidade: i.modalidade,
quantidade: i.quantidade
}));
} else {
include = false;
}
}
if (include) {
resultados.push({
_id: pedido._id,
_creationTime: pedido._creationTime,
numeroSei: pedido.numeroSei,
numeroDfd: pedido.numeroDfd,
status: pedido.status,
criadoPor: pedido.criadoPor,
aceitoPor: pedido.aceitoPor,
descricaoAjuste: pedido.descricaoAjuste,
criadoEm: pedido.criadoEm,
atualizadoEm: pedido.atualizadoEm,
matchingItems: matchingItems.length > 0 ? matchingItems : undefined
});
}
}
return resultados;
}
});
export const listForAcceptance = query({
args: {},
returns: v.array(
v.object({
_id: v.id('pedidos'),
_creationTime: v.number(),
numeroSei: v.optional(v.string()),
numeroDfd: 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,
numeroDfd: o.numeroDfd,
status: o.status,
criadoPor: o.criadoPor,
criadoPorNome: creator?.nome || 'Desconhecido',
aceitoPor: o.aceitoPor,
descricaoAjuste: o.descricaoAjuste,
criadoEm: o.criadoEm,
atualizadoEm: o.atualizadoEm
};
})
);
}
});
export const listMyAnalysis = query({
args: {},
returns: v.array(
v.object({
_id: v.id('pedidos'),
_creationTime: v.number(),
numeroSei: v.optional(v.string()),
numeroDfd: v.optional(v.string()),
status: v.string(),
criadoPor: v.id('usuarios'),
criadoPorNome: v.string(),
aceitoPor: v.optional(v.id('funcionarios')),
descricaoAjuste: v.optional(v.string()),
criadoEm: v.number(),
atualizadoEm: v.number()
})
),
handler: async (ctx) => {
const user = await getUsuarioAutenticado(ctx);
// Security Check: Must be in Compras Sector
const config = await ctx.db.query('config').first();
if (!config || !config.comprasSetorId) return [];
if (!user.funcionarioId) return [];
const isInSector = await ctx.db
.query('funcionarioSetores')
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', user.funcionarioId!))
.filter((q) => q.eq(q.field('setorId'), config.comprasSetorId))
.first();
if (!isInSector) return [];
// Fetch orders accepted by this user
// We don't have an index on aceitoPor yet, but we can filter or add index.
// Ideally we should add an index, but for now let's filter since volume might be low per user.
// Wait, we can't filter efficiently without index if table is huge.
// Let's assume we should add index or filter in memory if small.
// Given the schema change was just adding the field, let's filter.
// Actually, let's add the index in the schema update if possible, but I already did that step.
// I'll filter for now.
const orders = await ctx.db
.query('pedidos')
.filter((q) => q.eq(q.field('aceitoPor'), user.funcionarioId))
.collect();
return await Promise.all(
orders.map(async (o) => {
const creator = await ctx.db.get(o.criadoPor);
return {
_id: o._id,
_creationTime: o._creationTime,
numeroSei: o.numeroSei,
numeroDfd: o.numeroDfd,
status: o.status,
criadoPor: o.criadoPor,
criadoPorNome: creator?.nome || 'Desconhecido',
aceitoPor: o.aceitoPor,
descricaoAjuste: o.descricaoAjuste,
criadoEm: o.criadoEm,
atualizadoEm: o.atualizadoEm
};
})
);
}
});
export const listByItemCreator = query({
args: {
statuses: v.optional(v.array(pedidoStatusValidator)),
numeroSei: v.optional(v.string()),
criadoPor: v.optional(v.id('usuarios')),
aceitoPor: v.optional(v.id('funcionarios')),
periodoInicio: v.optional(v.number()),
periodoFim: v.optional(v.number())
},
returns: v.array(
v.object({
_id: v.id('pedidos'),
_creationTime: v.number(),
numeroSei: v.optional(v.string()),
numeroDfd: v.optional(v.string()),
status: pedidoStatusValidator,
aceitoPor: v.optional(v.id('funcionarios')),
criadoPor: v.id('usuarios'),
criadoPorNome: v.string(),
aceitoPorNome: v.optional(v.string()),
descricaoAjuste: v.optional(v.string()),
concluidoEm: v.optional(v.number()),
criadoEm: v.number(),
atualizadoEm: v.number()
})
),
handler: async (ctx, args) => {
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);
const filtered = applyPedidoFilters(validOrders as Doc<'pedidos'>[], args);
return await Promise.all(
filtered.map(async (o) => {
const creator = await ctx.db.get(o.criadoPor);
const aceito = o.aceitoPor ? await ctx.db.get(o.aceitoPor) : null;
return {
_id: o._id,
_creationTime: o._creationTime,
numeroSei: o.numeroSei,
numeroDfd: o.numeroDfd,
status: o.status,
criadoPor: o.criadoPor,
criadoPorNome: creator?.nome || 'Desconhecido',
aceitoPor: o.aceitoPor,
aceitoPorNome: aceito?.nome || undefined,
descricaoAjuste: o.descricaoAjuste,
concluidoEm: o.concluidoEm,
criadoEm: o.criadoEm,
atualizadoEm: o.atualizadoEm
};
})
);
}
});
export const gerarRelatorio = query({
args: {
statuses: v.optional(v.array(pedidoStatusValidator)),
numeroSei: v.optional(v.string()),
criadoPor: v.optional(v.id('usuarios')),
aceitoPor: v.optional(v.id('funcionarios')),
periodoInicio: v.optional(v.number()),
periodoFim: v.optional(v.number())
},
returns: v.object({
filtros: v.object({
statuses: v.optional(v.array(pedidoStatusValidator)),
numeroSei: v.optional(v.string()),
criadoPor: v.optional(v.id('usuarios')),
aceitoPor: v.optional(v.id('funcionarios')),
periodoInicio: v.optional(v.number()),
periodoFim: v.optional(v.number())
}),
resumo: v.object({
totalPedidos: v.number(),
totalItens: v.number(),
totalDocumentos: v.number(),
totalPorStatus: v.array(v.object({ status: pedidoStatusValidator, count: v.number() })),
totalValorEstimado: v.number(),
totalValorReal: v.number()
}),
pedidos: v.array(
v.object({
_id: v.id('pedidos'),
_creationTime: v.number(),
numeroSei: v.optional(v.string()),
numeroDfd: v.optional(v.string()),
status: pedidoStatusValidator,
criadoPor: v.id('usuarios'),
criadoPorNome: v.string(),
aceitoPor: v.optional(v.id('funcionarios')),
aceitoPorNome: v.optional(v.string()),
descricaoAjuste: v.optional(v.string()),
criadoEm: v.number(),
concluidoEm: v.optional(v.number()),
atualizadoEm: v.number(),
itensCount: v.number(),
documentosCount: v.number(),
valorEstimadoTotal: v.number(),
valorRealTotal: v.number()
})
),
itens: v.array(
v.object({
_id: v.id('objetoItems'),
pedidoId: v.id('pedidos'),
pedidoNumeroSei: v.optional(v.string()),
pedidoStatus: pedidoStatusValidator,
objetoId: v.id('objetos'),
objetoNome: v.optional(v.string()),
ataId: v.optional(v.id('atas')),
ataNumero: v.optional(v.string()),
acaoId: v.optional(v.id('acoes')),
acaoNome: v.optional(v.string()),
modalidade: v.union(
v.literal('dispensa'),
v.literal('inexgibilidade'),
v.literal('adesao'),
v.literal('consumo')
),
quantidade: v.number(),
valorEstimado: v.string(),
valorReal: v.optional(v.string()),
adicionadoPor: v.id('funcionarios'),
adicionadoPorNome: v.string(),
criadoEm: v.number()
})
)
}),
handler: async (ctx, args) => {
// Para relatório "por período", exigimos pelo menos um limite (início ou fim)
if (args.periodoInicio === undefined && args.periodoFim === undefined) {
throw new Error('Informe um período (início e/ou fim) para gerar o relatório.');
}
const base = await fetchPedidosBase(ctx, args);
const pedidosFiltrados = applyPedidoFilters(base, args);
// Guardrail para evitar timeouts/relatórios gigantescos
if (pedidosFiltrados.length > 500) {
throw new Error(
`Relatório muito grande (${pedidosFiltrados.length} pedidos). Reduza o período/filtros e tente novamente.`
);
}
// Cache para evitar múltiplos gets repetidos
const cacheUsuarios = new Map<string, Doc<'usuarios'> | null>();
const cacheFuncionarios = new Map<string, Doc<'funcionarios'> | null>();
const cacheObjetos = new Map<string, Doc<'objetos'> | null>();
const cacheAtas = new Map<string, Doc<'atas'> | null>();
const cacheAcoes = new Map<string, Doc<'acoes'> | null>();
async function getUsuario(id: Id<'usuarios'>): Promise<Doc<'usuarios'> | null> {
const k = String(id);
if (cacheUsuarios.has(k)) return cacheUsuarios.get(k) ?? null;
const doc = await ctx.db.get(id);
cacheUsuarios.set(k, doc);
return doc;
}
async function getFuncionario(id: Id<'funcionarios'>): Promise<Doc<'funcionarios'> | null> {
const k = String(id);
if (cacheFuncionarios.has(k)) return cacheFuncionarios.get(k) ?? null;
const doc = await ctx.db.get(id);
cacheFuncionarios.set(k, doc);
return doc;
}
async function getObjeto(id: Id<'objetos'>): Promise<Doc<'objetos'> | null> {
const k = String(id);
if (cacheObjetos.has(k)) return cacheObjetos.get(k) ?? null;
const doc = await ctx.db.get(id);
cacheObjetos.set(k, doc);
return doc;
}
async function getAta(id: Id<'atas'>): Promise<Doc<'atas'> | null> {
const k = String(id);
if (cacheAtas.has(k)) return cacheAtas.get(k) ?? null;
const doc = await ctx.db.get(id);
cacheAtas.set(k, doc);
return doc;
}
async function getAcao(id: Id<'acoes'>): Promise<Doc<'acoes'> | null> {
const k = String(id);
if (cacheAcoes.has(k)) return cacheAcoes.get(k) ?? null;
const doc = await ctx.db.get(id);
cacheAcoes.set(k, doc);
return doc;
}
function parseValorMoeda(input: string | undefined): number {
if (!input) return 0;
const s = input
.replace(/\s/g, '')
.replace(/[Rr]\$?/g, '')
.replace(/\./g, '')
.replace(',', '.')
.replace(/[^0-9.-]/g, '');
const n = Number(s);
return Number.isFinite(n) ? n : 0;
}
const itensOut: Array<{
_id: Id<'objetoItems'>;
pedidoId: Id<'pedidos'>;
pedidoNumeroSei?: string;
pedidoStatus: PedidoStatus;
objetoId: Id<'objetos'>;
objetoNome?: string;
ataId?: Id<'atas'>;
ataNumero?: string;
acaoId?: Id<'acoes'>;
acaoNome?: string;
modalidade: Doc<'objetoItems'>['modalidade'];
quantidade: number;
valorEstimado: string;
valorReal?: string;
adicionadoPor: Id<'funcionarios'>;
adicionadoPorNome: string;
criadoEm: number;
}> = [];
let totalItens = 0;
let totalDocumentos = 0;
let totalValorEstimado = 0;
let totalValorReal = 0;
const statusCounts = new Map<PedidoStatus, number>();
for (const s of [
'em_rascunho',
'aguardando_aceite',
'em_analise',
'precisa_ajustes',
'cancelado',
'concluido'
] as PedidoStatus[]) {
statusCounts.set(s, 0);
}
const pedidosOut = await Promise.all(
pedidosFiltrados.map(async (p) => {
statusCounts.set(p.status, (statusCounts.get(p.status) ?? 0) + 1);
const creator = await getUsuario(p.criadoPor);
const aceito = p.aceitoPor ? await getFuncionario(p.aceitoPor) : null;
const itens = await ctx.db
.query('objetoItems')
.withIndex('by_pedidoId', (q) => q.eq('pedidoId', p._id))
.collect();
const docs = await ctx.db
.query('pedidoDocumentos')
.withIndex('by_pedidoId', (q) => q.eq('pedidoId', p._id))
.collect();
totalItens += itens.length;
totalDocumentos += docs.length;
let valorEstimadoTotal = 0;
let valorRealTotal = 0;
for (const it of itens) {
valorEstimadoTotal += parseValorMoeda(it.valorEstimado);
valorRealTotal += parseValorMoeda(it.valorReal);
const funcionario = await getFuncionario(it.adicionadoPor);
const objeto = await getObjeto(it.objetoId);
const ata = it.ataId ? await getAta(it.ataId) : null;
const acao = it.acaoId ? await getAcao(it.acaoId) : null;
itensOut.push({
_id: it._id,
pedidoId: it.pedidoId,
pedidoNumeroSei: p.numeroSei,
pedidoStatus: p.status,
objetoId: it.objetoId,
objetoNome: objeto?.nome ?? undefined,
ataId: it.ataId,
ataNumero: ata?.numero ?? undefined,
acaoId: it.acaoId,
acaoNome: acao?.nome ?? undefined,
modalidade: it.modalidade,
quantidade: it.quantidade,
valorEstimado: it.valorEstimado,
valorReal: it.valorReal,
adicionadoPor: it.adicionadoPor,
adicionadoPorNome: funcionario?.nome || 'Desconhecido',
criadoEm: it.criadoEm
});
}
totalValorEstimado += valorEstimadoTotal;
totalValorReal += valorRealTotal;
return {
_id: p._id,
_creationTime: p._creationTime,
numeroSei: p.numeroSei,
numeroDfd: p.numeroDfd,
status: p.status,
criadoPor: p.criadoPor,
criadoPorNome: creator?.nome || 'Desconhecido',
aceitoPor: p.aceitoPor,
aceitoPorNome: aceito?.nome || undefined,
descricaoAjuste: p.descricaoAjuste,
criadoEm: p.criadoEm,
concluidoEm: p.concluidoEm,
atualizadoEm: p.atualizadoEm,
itensCount: itens.length,
documentosCount: docs.length,
valorEstimadoTotal,
valorRealTotal
};
})
);
// Ordenar itens por data (mais recente primeiro)
itensOut.sort((a, b) => b.criadoEm - a.criadoEm);
const totalPorStatus = [...statusCounts.entries()].map(([status, count]) => ({
status,
count
}));
return {
filtros: {
statuses: args.statuses,
numeroSei: args.numeroSei,
criadoPor: args.criadoPor,
aceitoPor: args.aceitoPor,
periodoInicio: args.periodoInicio,
periodoFim: args.periodoFim
},
resumo: {
totalPedidos: pedidosFiltrados.length,
totalItens,
totalDocumentos,
totalPorStatus,
totalValorEstimado,
totalValorReal
},
pedidos: pedidosOut,
itens: itensOut
};
}
});
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.');
// Só é permitido adicionar itens em pedidos em rascunho, aguardando aceite ou em análise.
// Nos demais status (ex.: precisa_ajustes, concluído, cancelado etc.) a inclusão é bloqueada.
if (
pedido.status !== 'em_rascunho' &&
pedido.status !== 'aguardando_aceite' &&
pedido.status !== 'em_analise'
) {
throw new Error('Não é possível adicionar itens neste status de pedido.');
}
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()),
numeroDfd: 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,
numeroDfd: args.numeroDfd,
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 updateDfdNumber = mutation({
args: {
pedidoId: v.id('pedidos'),
numeroDfd: v.string()
},
returns: v.null(),
handler: async (ctx, args) => {
const user = await getUsuarioAutenticado(ctx);
const pedido = await ctx.db.get(args.pedidoId);
if (!pedido) throw new Error('Pedido não encontrado.');
// Verificar se o pedido está em um status que permite edição
if (pedido.status !== 'em_rascunho' && pedido.status !== 'precisa_ajustes') {
throw new Error(
'Apenas pedidos em rascunho ou que precisam de ajustes podem ter o número DFD atualizado.'
);
}
const oldDfd = pedido.numeroDfd;
await ctx.db.patch(args.pedidoId, {
numeroDfd: args.numeroDfd,
atualizadoEm: Date.now()
});
await ctx.db.insert('historicoPedidos', {
pedidoId: args.pedidoId,
usuarioId: user._id,
acao: 'atualizacao_dfd',
detalhes: JSON.stringify({ de: oldDfd, para: args.numeroDfd }),
data: Date.now()
});
}
});
export const addItem = mutation({
args: {
pedidoId: v.id('pedidos'),
objetoId: v.id('objetos'),
ataId: v.optional(v.id('atas')),
acaoId: v.optional(v.id('acoes')),
modalidade: v.union(
v.literal('dispensa'),
v.literal('inexgibilidade'),
v.literal('adesao'),
v.literal('consumo')
),
valorEstimado: v.string(),
quantidade: v.number()
},
returns: v.null(),
handler: async (ctx, args) => {
const user = await getUsuarioAutenticado(ctx);
// Ensure user has a funcionarioId linked
if (!user.funcionarioId) {
throw new Error('Usuário não vinculado a um funcionário.');
}
const pedido = await ctx.db.get(args.pedidoId);
if (!pedido) throw new Error('Pedido não encontrado.');
// Regra global: todos os itens do pedido devem ter a mesma
// modalidade e a mesma ata (quando houver).
await ensurePedidoModalidadeAtaConsistency(ctx, args.pedidoId, args.modalidade, args.ataId);
// --- CHECK ANALYSIS MODE ---
// Em pedidos em análise, a inclusão de itens deve passar por fluxo de aprovação.
// Em rascunho ou aguardando aceite, a inclusão é direta, sem necessidade de aprovação.
if (pedido.status === 'em_analise') {
if (args.ataId) {
// Não altera consumo aqui (ainda é só solicitação), mas valida limite/configuração.
await assertAtaObjetoCanConsume(ctx, args.ataId, args.objetoId, args.quantidade);
}
await ctx.db.insert('solicitacoesItens', {
pedidoId: args.pedidoId,
tipo: 'adicao',
dados: JSON.stringify(args),
status: 'pendente',
solicitadoPor: user.funcionarioId,
criadoEm: Date.now()
});
return;
}
// --- CHECK DUPLICATES (Same Product + User, Different Config) ---
// Get all items of this product added by this user in this order
const userProductItems = await ctx.db
.query('objetoItems')
.withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId))
.filter((q) =>
q.and(
q.eq(q.field('objetoId'), args.objetoId),
q.eq(q.field('adicionadoPor'), user.funcionarioId)
)
)
.collect();
const conflict = userProductItems.find(
(i) => i.modalidade !== args.modalidade || i.ataId !== args.ataId
);
if (conflict) {
throw new Error(
'Este pedido já possui este produto com outra combinação de modalidade e/ou ata para você. Todos os itens do mesmo produto devem usar a mesma modalidade e ata neste pedido. Ajuste o item existente ou crie um novo pedido para a nova combinação.'
);
}
// Check if item already exists with SAME parameters (exact match) to increment
const existingItem = userProductItems.find(
(i) => i.acaoId === args.acaoId && i.ataId === args.ataId && i.modalidade === args.modalidade
);
if (existingItem) {
if (args.ataId) {
await applyAtaObjetoUsageDelta(ctx, args.ataId, args.objetoId, args.quantidade);
}
// 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 {
if (args.ataId) {
await applyAtaObjetoUsageDelta(ctx, args.ataId, args.objetoId, args.quantidade);
}
// Insert new item
await ctx.db.insert('objetoItems', {
pedidoId: args.pedidoId,
objetoId: args.objetoId,
ataId: args.ataId,
acaoId: args.acaoId,
modalidade: args.modalidade,
valorEstimado: args.valorEstimado,
quantidade: args.quantidade,
adicionadoPor: user.funcionarioId,
criadoEm: Date.now()
});
await ctx.db.insert('historicoPedidos', {
pedidoId: args.pedidoId,
usuarioId: user._id,
acao: 'adicao_item',
detalhes: JSON.stringify({
objetoId: args.objetoId,
valor: args.valorEstimado,
quantidade: args.quantidade,
acaoId: args.acaoId,
ataId: args.ataId,
modalidade: args.modalidade
}),
data: Date.now()
});
}
await ctx.db.patch(args.pedidoId, { atualizadoEm: Date.now() });
}
});
export const updateItemQuantity = mutation({
args: {
itemId: v.id('objetoItems'),
novaQuantidade: v.number()
},
returns: v.null(),
handler: async (ctx, args) => {
const user = await getUsuarioAutenticado(ctx);
if (!user.funcionarioId) {
throw new Error('Usuário não vinculado a um funcionário.');
}
const item = await ctx.db.get(args.itemId);
if (!item) throw new Error('Item não encontrado.');
const pedido = await ctx.db.get(item.pedidoId);
if (!pedido) throw new Error('Pedido não encontrado.');
const delta = args.novaQuantidade - item.quantidade;
// --- CHECK ANALYSIS / ACCEPTANCE MODE ---
if (pedido.status === 'em_analise' || pedido.status === 'aguardando_aceite') {
if (delta > 0 && item.ataId) {
// Não altera consumo aqui (ainda é só solicitação), mas valida limite/configuração.
await assertAtaObjetoCanConsume(ctx, item.ataId, item.objetoId, delta);
}
await ctx.db.insert('solicitacoesItens', {
pedidoId: item.pedidoId,
tipo: 'alteracao_quantidade',
dados: JSON.stringify({ itemId: args.itemId, novaQuantidade: args.novaQuantidade }),
status: 'pendente',
solicitadoPor: user.funcionarioId,
criadoEm: Date.now()
});
return;
}
const quantidadeAnterior = item.quantidade;
// Check permission: only item owner can change quantity
const isOwner = item.adicionadoPor === user.funcionarioId;
if (!isOwner) {
throw new Error('Apenas quem adicionou este item pode alterar a quantidade.');
}
// Atualiza consumo (ata+objeto) antes de persistir a nova quantidade
if (item.ataId) {
await applyAtaObjetoUsageDelta(ctx, item.ataId, item.objetoId, delta);
}
// Update quantity
await ctx.db.patch(args.itemId, { quantidade: args.novaQuantidade });
await ctx.db.patch(item.pedidoId, { atualizadoEm: Date.now() });
// Create history entry
await ctx.db.insert('historicoPedidos', {
pedidoId: item.pedidoId,
usuarioId: user._id,
acao: 'alteracao_quantidade',
detalhes: JSON.stringify({
objetoId: item.objetoId,
quantidadeAnterior,
novaQuantidade: args.novaQuantidade
}),
data: Date.now()
});
}
});
export const removeItem = mutation({
args: {
itemId: v.id('objetoItems')
},
returns: v.null(),
handler: async (ctx, args) => {
const user = await getUsuarioAutenticado(ctx);
const item = await ctx.db.get(args.itemId);
if (!item) throw new Error('Item not found');
const pedido = await ctx.db.get(item.pedidoId);
if (!pedido) throw new Error('Pedido não encontrado.');
// --- CHECK ANALYSIS / ACCEPTANCE MODE ---
if (pedido.status === 'em_analise' || pedido.status === 'aguardando_aceite') {
if (!user.funcionarioId) throw new Error('Usuário sem funcionário vinculado.');
await ctx.db.insert('solicitacoesItens', {
pedidoId: item.pedidoId,
tipo: 'exclusao',
dados: JSON.stringify({ itemId: args.itemId }),
status: 'pendente',
solicitadoPor: user.funcionarioId,
criadoEm: Date.now()
});
return;
}
if (item.ataId) {
await applyAtaObjetoUsageDelta(ctx, item.ataId, item.objetoId, -item.quantidade);
}
await ctx.db.delete(args.itemId);
await ctx.db.patch(item.pedidoId, { atualizadoEm: Date.now() });
await ctx.db.insert('historicoPedidos', {
pedidoId: item.pedidoId,
usuarioId: user._id,
acao: 'remocao_item',
detalhes: JSON.stringify({
objetoId: item.objetoId,
valor: item.valorEstimado
}),
data: Date.now()
});
}
});
export const removeItemsBatch = mutation({
args: {
itemIds: v.array(v.id('objetoItems'))
},
returns: v.null(),
handler: async (ctx, args) => {
const user = await getUsuarioAutenticado(ctx);
if (!user.funcionarioId) {
throw new Error('Usuário não vinculado a um funcionário.');
}
if (args.itemIds.length === 0) {
return null;
}
const firstItem = await ctx.db.get(args.itemIds[0]);
if (!firstItem) {
throw new Error('Item não encontrado.');
}
const pedidoId = firstItem.pedidoId;
const pedido = await ctx.db.get(pedidoId);
if (!pedido) {
throw new Error('Pedido não encontrado.');
}
if (
pedido.status !== 'em_rascunho' &&
pedido.status !== 'precisa_ajustes' &&
pedido.status !== 'em_analise'
) {
throw new Error(
'Só é possível remover itens em pedidos em rascunho, em análise ou que precisam de ajustes.'
);
}
for (const itemId of args.itemIds) {
const item = await ctx.db.get(itemId);
if (!item) continue;
if (item.pedidoId !== pedidoId) {
throw new Error('Todos os itens devem pertencer ao mesmo pedido.');
}
if (item.adicionadoPor !== user.funcionarioId) {
throw new Error('Você só pode remover itens que você adicionou.');
}
if (item.ataId) {
await applyAtaObjetoUsageDelta(ctx, item.ataId, item.objetoId, -item.quantidade);
}
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()),
numeroDfd: 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,
numeroDfd: args.numeroDfd,
status: 'em_rascunho',
criadoPor: user._id,
criadoEm: Date.now(),
atualizadoEm: Date.now()
});
for (const item of itens) {
await ctx.db.patch(item._id, {
pedidoId: novoPedidoId
});
}
await ctx.db.insert('historicoPedidos', {
pedidoId: args.pedidoId,
usuarioId: user._id,
acao: 'divisao_pedido_origem',
detalhes: JSON.stringify({
itensMovidos: itens.map((i) => i._id),
novoPedidoId
}),
data: Date.now()
});
await ctx.db.insert('historicoPedidos', {
pedidoId: novoPedidoId,
usuarioId: user._id,
acao: 'divisao_pedido_destino',
detalhes: JSON.stringify({
pedidoOriginalId: args.pedidoId,
itensRecebidos: itens.map((i) => i._id)
}),
data: Date.now()
});
await ctx.db.patch(args.pedidoId, { atualizadoEm: Date.now() });
return novoPedidoId;
}
});
export const updateItem = mutation({
args: {
itemId: v.id('objetoItems'),
valorEstimado: v.string(),
modalidade: v.union(
v.literal('dispensa'),
v.literal('inexgibilidade'),
v.literal('adesao'),
v.literal('consumo')
),
acaoId: v.optional(v.id('acoes')),
ataId: v.optional(v.id('atas'))
},
returns: v.null(),
handler: async (ctx, args) => {
const user = await getUsuarioAutenticado(ctx);
if (!user.funcionarioId) {
throw new Error('Usuário não vinculado a um funcionário.');
}
const item = await ctx.db.get(args.itemId);
if (!item) throw new Error('Item não encontrado.');
const pedido = await ctx.db.get(item.pedidoId);
if (!pedido) throw new Error('Pedido não encontrado.');
// Apenas quem adicionou o item pode editá-lo
const isOwner = item.adicionadoPor === user.funcionarioId;
if (!isOwner) {
throw new Error('Apenas quem adicionou este item pode editá-lo.');
}
const oldValues = {
valorEstimado: item.valorEstimado,
modalidade: item.modalidade,
acaoId: 'acaoId' in item ? item.acaoId : undefined,
ataId: 'ataId' in item ? item.ataId : undefined
};
// Regra global: ao alterar um item, garantir que os demais itens do pedido
// continuem (ou passem a estar) com a mesma modalidade e ata.
await ensurePedidoModalidadeAtaConsistency(
ctx,
item.pedidoId,
args.modalidade,
args.ataId,
args.itemId
);
// Em pedidos em análise ou aguardando aceite, geramos uma solicitação em vez de alterar diretamente
if (pedido.status === 'em_analise' || pedido.status === 'aguardando_aceite') {
const oldAtaId = oldValues.ataId;
const newAtaId = args.ataId;
if (newAtaId && newAtaId !== oldAtaId) {
// Não altera consumo aqui (ainda é só solicitação), mas valida limite/configuração.
await assertAtaObjetoCanConsume(ctx, newAtaId, item.objetoId, item.quantidade);
}
await ctx.db.insert('solicitacoesItens', {
pedidoId: item.pedidoId,
tipo: 'alteracao_detalhes',
dados: JSON.stringify({
itemId: args.itemId,
de: oldValues,
para: {
valorEstimado: args.valorEstimado,
modalidade: args.modalidade,
acaoId: args.acaoId,
ataId: args.ataId
}
}),
status: 'pendente',
solicitadoPor: user.funcionarioId,
criadoEm: Date.now()
});
return;
}
// Se a ata mudou, mover consumo entre vínculos (ata antiga -> nova ata)
const oldAtaId = oldValues.ataId;
const newAtaId = args.ataId;
if ((oldAtaId ?? null) !== (newAtaId ?? null)) {
if (newAtaId) {
await applyAtaObjetoUsageDelta(ctx, newAtaId, item.objetoId, item.quantidade);
}
if (oldAtaId) {
await applyAtaObjetoUsageDelta(ctx, oldAtaId, item.objetoId, -item.quantidade);
}
}
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,
canCompleteAdjustments: false,
canManageRequests: false,
currentFuncionarioId: user?.funcionarioId ?? null
};
}
// 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;
// Verificar se existem itens adicionados por OUTROS usuários.
const foreignItem = await ctx.db
.query('objetoItems')
.withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId))
.filter((q) => q.neq(q.field('adicionadoPor'), user.funcionarioId))
.first();
const hasOnlyCreatorItems = !foreignItem;
const isCreator = pedido.criadoPor === user._id;
return {
canSendToAcceptance: pedido.status === 'em_rascunho' && hasAddedItems,
canStartAnalysis: pedido.status === 'aguardando_aceite' && isInComprasSector,
canConclude: pedido.status === 'em_analise' && isInComprasSector,
canRequestAdjustments:
pedido.status === 'em_analise' &&
isInComprasSector &&
pedido.aceitoPor === user.funcionarioId,
canCompleteAdjustments: pedido.status === 'precisa_ajustes' && hasAddedItems,
canCancel:
pedido.status !== 'cancelado' &&
pedido.status !== 'concluido' &&
isCreator &&
hasOnlyCreatorItems,
canManageRequests: pedido.status === 'em_analise' && isInComprasSector,
currentFuncionarioId: user.funcionarioId
};
}
});
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') {
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.'
);
}
// Validar que numeroSei e numeroDfd estão preenchidos
if (!pedido.numeroSei || !pedido.numeroSei.trim()) {
throw new Error('O número SEI deve ser informado antes de enviar o pedido para aceite.');
}
if (!pedido.numeroDfd || !pedido.numeroDfd.trim()) {
throw new Error('O número DFD deve ser informado antes de enviar o pedido 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,
concluidoEm: Date.now(),
atualizadoEm: Date.now()
});
await ctx.db.insert('historicoPedidos', {
pedidoId: args.pedidoId,
usuarioId: user._id,
acao: 'alteracao_status',
detalhes: JSON.stringify({ de: oldStatus, para: newStatus }),
data: Date.now()
});
await ctx.scheduler.runAfter(0, internal.pedidos.notifyStatusChange, {
pedidoId: args.pedidoId,
oldStatus,
newStatus,
actorId: user._id
});
}
});
export const solicitarAjustes = mutation({
args: {
pedidoId: v.id('pedidos'),
descricao: v.string()
},
returns: v.null(),
handler: async (ctx, args) => {
const user = await getUsuarioAutenticado(ctx);
const pedido = await ctx.db.get(args.pedidoId);
if (!pedido) throw new Error('Pedido não encontrado.');
if (pedido.status !== 'em_analise') {
throw new Error('O pedido deve estar em análise para solicitar ajustes.');
}
// Security Check: Only the user who accepted the order can request adjustments
if (pedido.aceitoPor !== user.funcionarioId) {
throw new Error('Apenas o funcionário que aceitou o pedido pode solicitar ajustes.');
}
const oldStatus = pedido.status;
const newStatus = 'precisa_ajustes';
await ctx.db.patch(args.pedidoId, {
status: newStatus,
descricaoAjuste: args.descricao,
atualizadoEm: Date.now()
});
await ctx.db.insert('historicoPedidos', {
pedidoId: args.pedidoId,
usuarioId: user._id,
acao: 'solicitacao_ajuste',
detalhes: JSON.stringify({
de: oldStatus,
para: newStatus,
descricao: args.descricao
}),
data: Date.now()
});
await ctx.scheduler.runAfter(0, internal.pedidos.notifyStatusChange, {
pedidoId: args.pedidoId,
oldStatus,
newStatus,
actorId: user._id,
customMessage: args.descricao
});
}
});
export const concluirAjustes = mutation({
args: { pedidoId: v.id('pedidos') },
returns: v.null(),
handler: async (ctx, args) => {
const user = await getUsuarioAutenticado(ctx);
if (!user.funcionarioId) throw new Error('Usuário sem funcionário vinculado.');
const pedido = await ctx.db.get(args.pedidoId);
if (!pedido) throw new Error('Pedido não encontrado.');
if (pedido.status !== 'precisa_ajustes') {
throw new Error('O pedido não está aguardando ajustes.');
}
// Security Check: Must have items in the order
const requestorItem = await ctx.db
.query('objetoItems')
.withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId))
.filter((q) => q.eq(q.field('adicionadoPor'), user.funcionarioId))
.first();
if (!requestorItem) {
throw new Error('Você deve ter itens no pedido para concluir os ajustes.');
}
const oldStatus = pedido.status;
const newStatus = 'em_analise';
const descricaoAnterior = pedido.descricaoAjuste;
await ctx.db.patch(args.pedidoId, {
status: newStatus,
descricaoAjuste: undefined, // Clear the adjustment description from the active field
atualizadoEm: Date.now()
});
await ctx.db.insert('historicoPedidos', {
pedidoId: args.pedidoId,
usuarioId: user._id,
acao: 'conclusao_ajustes',
detalhes: JSON.stringify({
de: oldStatus,
para: newStatus,
descricaoResolvida: descricaoAnterior
}),
data: Date.now()
});
await ctx.scheduler.runAfter(0, internal.pedidos.notifyStatusChange, {
pedidoId: args.pedidoId,
oldStatus,
newStatus,
actorId: user._id,
customMessage: 'Ajustes concluídos. O pedido retornou para análise.'
});
}
});
export const cancelarPedido = mutation({
args: { pedidoId: v.id('pedidos') },
returns: v.null(),
handler: async (ctx, args) => {
const user = await getUsuarioAutenticado(ctx);
const pedido = await ctx.db.get(args.pedidoId);
if (!pedido) throw new Error('Pedido não encontrado.');
if (pedido.status === 'concluido' || pedido.status === 'cancelado') {
throw new Error('Pedido já finalizado.');
}
// Regra: apenas o criador pode cancelar o pedido
const isCreator = pedido.criadoPor === user._id;
if (!isCreator) {
throw new Error('Apenas quem criou este pedido pode cancelá-lo.');
}
if (!user.funcionarioId) {
throw new Error('Usuário sem funcionário vinculado. Não é possível cancelar este pedido.');
}
// Regra extra: o pedido só pode ser cancelado se todos os itens
// tiverem sido adicionados pelo mesmo funcionário do criador.
const foreignItem = await ctx.db
.query('objetoItems')
.withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId))
.filter((q) => q.neq(q.field('adicionadoPor'), user.funcionarioId))
.first();
if (foreignItem) {
throw new Error(
'Não é possível cancelar este pedido porque há itens adicionados por outros usuários.'
);
}
const oldStatus = pedido.status;
const newStatus = 'cancelado';
// Ao cancelar, liberar consumo de todas as combinações (ataId,objetoId) deste pedido
const items = await ctx.db
.query('objetoItems')
.withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId))
.collect();
for (const item of items) {
if (item.ataId) {
await applyAtaObjetoUsageDelta(ctx, item.ataId, item.objetoId, -item.quantidade);
}
}
await ctx.db.patch(args.pedidoId, {
status: newStatus,
atualizadoEm: Date.now()
});
await ctx.db.insert('historicoPedidos', {
pedidoId: args.pedidoId,
usuarioId: user._id,
acao: 'alteracao_status',
detalhes: JSON.stringify({ de: oldStatus, para: newStatus }),
data: Date.now()
});
await ctx.scheduler.runAfter(0, internal.pedidos.notifyStatusChange, {
pedidoId: args.pedidoId,
oldStatus,
newStatus,
actorId: user._id
});
}
});
// ========== INTERNAL (NOTIFICATIONS) ==========
export const notifyStatusChange = internalMutation({
args: {
pedidoId: v.id('pedidos'),
oldStatus: v.string(),
newStatus: v.string(),
actorId: v.id('usuarios'),
customMessage: v.optional(v.string())
},
returns: v.null(),
handler: async (ctx, args) => {
const pedido = await ctx.db.get(args.pedidoId);
if (!pedido) return;
const actor = await ctx.db.get(args.actorId);
const actorName = actor ? actor.nome : 'Alguém';
const recipients = new Set<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);
}
}
let description = `Status alterado de "${args.oldStatus}" para "${args.newStatus}" por ${actorName}.`;
if (args.customMessage) {
description += `\n\nDetalhes: ${args.customMessage}`;
}
// Send Notifications
for (const recipientId of recipients) {
const recipientIdTyped = recipientId as Id<'usuarios'>;
// 1. In-App Notification
await ctx.db.insert('notificacoes', {
usuarioId: recipientIdTyped,
tipo: 'alerta_seguranca', // Using alerta_seguranca as the closest match for system notifications
titulo: `Pedido ${pedido.numeroSei || 'sem número SEI'} atualizado`,
descricao: description,
lida: false,
criadaEm: Date.now(),
remetenteId: args.actorId
});
// 2. Email Notification (Async)
const recipientUser = await ctx.db.get(recipientIdTyped);
if (recipientUser && recipientUser.email) {
// Using enfileirarEmail directly
await ctx.scheduler.runAfter(0, api.email.enfileirarEmail, {
destinatario: recipientUser.email,
destinatarioId: recipientIdTyped,
assunto: `Atualização no Pedido ${pedido.numeroSei || 'sem número SEI'}`,
corpo: `O pedido ${pedido.numeroSei || 'sem número SEI'} teve seu status alterado de "${args.oldStatus}" para "${args.newStatus}" por ${actorName}.<br/><br/>${args.customMessage ? `<strong>Detalhes:</strong> ${args.customMessage}` : ''}`,
enviadoPor: args.actorId
});
}
}
}
});
// ========== REQUESTS MANAGEMENT ==========
export const getItemRequests = query({
args: { pedidoId: v.id('pedidos') },
handler: async (ctx, args) => {
const requests = await ctx.db
.query('solicitacoesItens')
.withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId))
.filter((q) => q.eq(q.field('status'), 'pendente')) // Only pending for now? Or history? User said "manter um historico"
// Actually the table has status. I should probably return all, or just pending for the main list.
// The prompt says "quando aceita... exclua as solicitacoes" (or "manter um historico"?).
// Prompt: "quando aceita... adicione os items automaticamente, e exclua as solicitacoes. deve tambem manter um historico."
// "Exclua as solicitacoes" implies deleting the row from 'solicitacoesItens' OR just marking as processed.
// "Manter um historico" might refer to 'historicoPedidos' (the central history).
// I'll stick to marking as approved/rejected in 'solicitacoesItens' OR deleting and logging to 'historicoPedidos'.
// But for now, let's just return pending ones for the UI to be clean.
.collect();
// Enrich names
return await Promise.all(
requests.map(async (r) => {
const func = await ctx.db.get(r.solicitadoPor);
return {
...r,
solicitadoPorNome: func?.nome || 'Desconhecido'
};
})
);
}
});
export const approveItemRequest = mutation({
args: {
requestId: v.id('solicitacoesItens')
},
returns: v.null(),
handler: async (ctx, args) => {
const user = await getUsuarioAutenticado(ctx);
const funcionarioId = user.funcionarioId;
if (!funcionarioId) throw new Error('Usuário sem funcionário vinculado.');
const request = await ctx.db.get(args.requestId);
if (!request) throw new Error('Solicitação não encontrada.');
const pedido = await ctx.db.get(request.pedidoId);
if (!pedido) throw new Error('Pedido não encontrado.');
// Security: Must be Compras Sector AND accepted by this user (or just in sector?)
// Usually Analysis rights.
const config = await ctx.db.query('config').first();
if (!config || !config.comprasSetorId) throw new Error('Setor de compras não configurado.');
const isInSector = await ctx.db
.query('funcionarioSetores')
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', funcionarioId))
.filter((q) => q.eq(q.field('setorId'), config.comprasSetorId))
.first();
if (!isInSector) throw new Error('Acesso negado.');
// Documentos anexados à solicitação (se houver) devem migrar para o pedido ao aprovar.
const solicitacaoDocs = await ctx.db
.query('solicitacoesItensDocumentos')
.withIndex('by_requestId', (q) => q.eq('requestId', request._id))
.collect();
// Apply the change
const data = JSON.parse(request.dados);
if (request.tipo === 'adicao') {
// Reuse addItem logic (simplified: insert or increment)
// We trust the request data structure matches addItem args
const newItem = data;
// Regra global: todos os itens do pedido devem ter a mesma
// modalidade e ata (quando houver).
await ensurePedidoModalidadeAtaConsistency(
ctx,
request.pedidoId,
newItem.modalidade,
newItem.ataId
);
// Note: We MUST use the original requester's ID (request.solicitadoPor) as addedBy?
// Or should we attribute it to the requester? YES.
// BUT `addItem` logic usually checks if `existingItem.adicionadoPor === user`.
// Since we want to merge with the requester's items, we should check `request.solicitadoPor`.
const userProductItems = await ctx.db
.query('objetoItems')
.withIndex('by_pedidoId', (q) => q.eq('pedidoId', request.pedidoId))
.filter((q) =>
q.and(
q.eq(q.field('objetoId'), newItem.objetoId),
q.eq(q.field('adicionadoPor'), request.solicitadoPor)
)
)
.collect();
// Duplicate check (should have been caught at request time? Not necessarily if state changed, but let's re-verify or just proceed)
// If conflict exists now, we might error or just merge.
// Let's assume strict check:
const conflict = userProductItems.find(
(i) => i.modalidade !== newItem.modalidade || i.ataId !== newItem.ataId
);
if (conflict) {
// Reject strictly? Or throw error?
throw new Error('Conflito detectado: Item com configuração diferente já existe.');
}
const existingItem = userProductItems.find(
(i) =>
i.acaoId === newItem.acaoId &&
i.ataId === newItem.ataId &&
i.modalidade === newItem.modalidade
);
if (newItem.ataId) {
await applyAtaObjetoUsageDelta(ctx, newItem.ataId, newItem.objetoId, newItem.quantidade);
}
if (existingItem) {
await ctx.db.patch(existingItem._id, {
quantidade: existingItem.quantidade + newItem.quantidade
});
// Logs attached to REQUESTER or APPROVER? Usually Approver took action, but it was on behalf of requester.
// Let's log 'aprovacao_solicitacao'
} else {
await ctx.db.insert('objetoItems', {
pedidoId: request.pedidoId,
objetoId: newItem.objetoId,
ataId: newItem.ataId,
acaoId: newItem.acaoId,
modalidade: newItem.modalidade,
valorEstimado: newItem.valorEstimado,
quantidade: newItem.quantidade,
adicionadoPor: request.solicitadoPor, // Important: Attribute to requester
criadoEm: Date.now()
});
}
} else if (request.tipo === 'alteracao_quantidade') {
const { itemId, novaQuantidade } = data as {
itemId: Id<'objetoItems'>;
novaQuantidade: number;
};
const item = (await ctx.db.get(itemId)) as Doc<'objetoItems'> | null;
if (item) {
const delta = novaQuantidade - item.quantidade;
if (item.ataId) {
await applyAtaObjetoUsageDelta(ctx, item.ataId, item.objetoId, delta);
}
await ctx.db.patch(itemId, { quantidade: novaQuantidade });
}
} else if (request.tipo === 'exclusao') {
const { itemId } = data as { itemId: Id<'objetoItems'> };
const item = (await ctx.db.get(itemId)) as Doc<'objetoItems'> | null;
if (item) {
if (item.ataId) {
await applyAtaObjetoUsageDelta(ctx, item.ataId, item.objetoId, -item.quantidade);
}
await ctx.db.delete(itemId);
}
} else if (request.tipo === 'alteracao_detalhes') {
const { itemId, para } = data as {
itemId: Id<'objetoItems'>;
para: {
valorEstimado: string;
modalidade: Doc<'objetoItems'>['modalidade'];
acaoId?: Id<'acoes'>;
ataId?: Id<'atas'>;
};
};
const item = await ctx.db.get(itemId);
if (item) {
// Regra global também se aplica na alteração de detalhes aprovada
await ensurePedidoModalidadeAtaConsistency(
ctx,
item.pedidoId,
para.modalidade,
para.ataId,
itemId
);
const oldAtaId = ('ataId' in item ? item.ataId : undefined) ?? undefined;
const newAtaId = para.ataId;
if ((oldAtaId ?? null) !== (newAtaId ?? null)) {
if (newAtaId) {
await applyAtaObjetoUsageDelta(ctx, newAtaId, item.objetoId, item.quantidade);
}
if (oldAtaId) {
await applyAtaObjetoUsageDelta(ctx, oldAtaId, item.objetoId, -item.quantidade);
}
}
await ctx.db.patch(itemId, {
valorEstimado: para.valorEstimado,
modalidade: para.modalidade,
acaoId: para.acaoId,
ataId: para.ataId
});
}
}
// Update request status
await ctx.db.patch(request._id, { status: 'aprovado' });
// OR delete it? "exclua as solicitacoes".
// I'll delete it to keep table clean as requested.
await ctx.db.delete(request._id);
// Migrar docs: cria em pedidoDocumentos e remove os registros da solicitacao
const documentosMigrados: Array<{ nome: string; descricao: string }> = [];
if (solicitacaoDocs.length > 0) {
for (const doc of solicitacaoDocs) {
await ctx.db.insert('pedidoDocumentos', {
pedidoId: request.pedidoId,
descricao: doc.descricao,
nome: doc.nome,
storageId: doc.storageId,
tipo: doc.tipo,
tamanho: doc.tamanho,
criadoPor: doc.criadoPor,
criadoEm: doc.criadoEm,
origemSolicitacaoId: request._id
});
documentosMigrados.push({ nome: doc.nome, descricao: doc.descricao });
await ctx.db.delete(doc._id);
}
}
// History
await ctx.db.insert('historicoPedidos', {
pedidoId: request.pedidoId,
usuarioId: user._id,
acao: 'aprovacao_solicitacao',
detalhes: JSON.stringify({
requestId: request._id,
tipo: request.tipo,
solicitadoPor: request.solicitadoPor,
dados: data,
documentosMigrados
}),
data: Date.now()
});
await ctx.db.patch(request.pedidoId, { atualizadoEm: Date.now() });
}
});
export const rejectItemRequest = mutation({
args: {
requestId: v.id('solicitacoesItens')
},
returns: v.null(),
handler: async (ctx, args) => {
const user = await getUsuarioAutenticado(ctx);
const funcionarioId = user.funcionarioId;
if (!funcionarioId) throw new Error('Usuário sem funcionário vinculado.');
const request = await ctx.db.get(args.requestId);
if (!request) throw new Error('Solicitação não encontrada.');
const config = await ctx.db.query('config').first();
if (!config || !config.comprasSetorId) throw new Error('Config Error');
const isInSector = await ctx.db
.query('funcionarioSetores')
.withIndex('by_funcionarioId', (q) => q.eq('funcionarioId', funcionarioId))
.filter((q) => q.eq(q.field('setorId'), config.comprasSetorId))
.first();
if (!isInSector) throw new Error('Acesso negado.');
// Remover documentos anexados à solicitação (para não deixar órfãos no storage)
const solicitacaoDocs = await ctx.db
.query('solicitacoesItensDocumentos')
.withIndex('by_requestId', (q) => q.eq('requestId', request._id))
.collect();
const documentosRemovidos: Array<{ nome: string; descricao: string }> = [];
for (const doc of solicitacaoDocs) {
documentosRemovidos.push({ nome: doc.nome, descricao: doc.descricao });
await ctx.storage.delete(doc.storageId);
await ctx.db.delete(doc._id);
}
// Delete request
await ctx.db.delete(args.requestId);
// History
await ctx.db.insert('historicoPedidos', {
pedidoId: request.pedidoId,
usuarioId: user._id,
acao: 'rejeicao_solicitacao',
detalhes: JSON.stringify({
requestId: request._id,
tipo: request.tipo,
solicitadoPor: request.solicitadoPor,
documentosRemovidos
}),
data: Date.now()
});
}
});
// ========== DOCUMENTOS (PEDIDO / SOLICITAÇÃO) ==========
export const generatePedidoUploadUrl = mutation({
args: { pedidoId: v.id('pedidos') },
returns: v.string(),
handler: async (ctx, args) => {
const user = await getUsuarioAutenticado(ctx);
await assertPodeGerenciarDocumentosDoPedido(ctx, args.pedidoId, user);
return await ctx.storage.generateUploadUrl();
}
});
export const addPedidoDocumento = mutation({
args: {
pedidoId: v.id('pedidos'),
descricao: v.string(),
nome: v.string(),
storageId: v.id('_storage'),
tipo: v.string(),
tamanho: v.number(),
origemSolicitacaoId: v.optional(v.id('solicitacoesItens'))
},
returns: v.id('pedidoDocumentos'),
handler: async (ctx, args) => {
const user = await getUsuarioAutenticado(ctx);
await assertPodeGerenciarDocumentosDoPedido(ctx, args.pedidoId, user);
if (!user.funcionarioId) {
throw new Error('Usuário sem funcionário vinculado.');
}
// Garantir que o pedido existe
const pedido = await ctx.db.get(args.pedidoId);
if (!pedido) throw new Error('Pedido não encontrado.');
return await ctx.db.insert('pedidoDocumentos', {
pedidoId: args.pedidoId,
descricao: args.descricao,
nome: args.nome,
storageId: args.storageId,
tipo: args.tipo,
tamanho: args.tamanho,
criadoPor: user.funcionarioId,
criadoEm: Date.now(),
origemSolicitacaoId: args.origemSolicitacaoId
});
}
});
export const listPedidoDocumentos = query({
args: { pedidoId: v.id('pedidos') },
handler: async (ctx, args) => {
const user = await getUsuarioAutenticado(ctx);
await assertPodeGerenciarDocumentosDoPedido(ctx, args.pedidoId, user);
const docs = await ctx.db
.query('pedidoDocumentos')
.withIndex('by_pedidoId', (q) => q.eq('pedidoId', args.pedidoId))
.order('desc')
.collect();
return await Promise.all(
docs.map(async (doc) => {
const url = await ctx.storage.getUrl(doc.storageId);
const func = await ctx.db.get(doc.criadoPor);
return {
...doc,
criadoPorNome: func?.nome ?? 'Desconhecido',
url
};
})
);
}
});
export const removePedidoDocumento = mutation({
args: { id: v.id('pedidoDocumentos') },
returns: v.null(),
handler: async (ctx, args) => {
const user = await getUsuarioAutenticado(ctx);
const doc = await ctx.db.get(args.id);
if (!doc) throw new Error('Documento não encontrado.');
// Pode remover se for o autor OU se for envolvido/compras
if (!user.funcionarioId || doc.criadoPor !== user.funcionarioId) {
await assertPodeGerenciarDocumentosDoPedido(ctx, doc.pedidoId, user);
}
await ctx.storage.delete(doc.storageId);
await ctx.db.delete(doc._id);
return null;
}
});
export const generateSolicitacaoUploadUrl = mutation({
args: { requestId: v.id('solicitacoesItens') },
returns: v.string(),
handler: async (ctx, args) => {
const user = await getUsuarioAutenticado(ctx);
if (!user.funcionarioId) throw new Error('Usuário sem funcionário vinculado.');
const request = await ctx.db.get(args.requestId);
if (!request) throw new Error('Solicitação não encontrada.');
if (request.tipo !== 'adicao') {
throw new Error('Apenas solicitações de adição permitem anexar documentos.');
}
if (request.status !== 'pendente') {
throw new Error('Não é possível anexar documentos em uma solicitação não pendente.');
}
if (request.solicitadoPor !== user.funcionarioId) {
throw new Error('Apenas quem criou a solicitação pode anexar documentos.');
}
return await ctx.storage.generateUploadUrl();
}
});
export const addSolicitacaoDocumento = mutation({
args: {
requestId: v.id('solicitacoesItens'),
descricao: v.string(),
nome: v.string(),
storageId: v.id('_storage'),
tipo: v.string(),
tamanho: v.number()
},
returns: v.id('solicitacoesItensDocumentos'),
handler: async (ctx, args) => {
const user = await getUsuarioAutenticado(ctx);
if (!user.funcionarioId) throw new Error('Usuário sem funcionário vinculado.');
const request = await ctx.db.get(args.requestId);
if (!request) throw new Error('Solicitação não encontrada.');
if (request.tipo !== 'adicao') {
throw new Error('Apenas solicitações de adição permitem anexar documentos.');
}
if (request.status !== 'pendente') {
throw new Error('Não é possível anexar documentos em uma solicitação não pendente.');
}
if (request.solicitadoPor !== user.funcionarioId) {
throw new Error('Apenas quem criou a solicitação pode anexar documentos.');
}
return await ctx.db.insert('solicitacoesItensDocumentos', {
requestId: args.requestId,
pedidoId: request.pedidoId,
descricao: args.descricao,
nome: args.nome,
storageId: args.storageId,
tipo: args.tipo,
tamanho: args.tamanho,
criadoPor: user.funcionarioId,
criadoEm: Date.now()
});
}
});
export const listSolicitacaoDocumentos = query({
args: { requestId: v.id('solicitacoesItens') },
handler: async (ctx, args) => {
const user = await getUsuarioAutenticado(ctx);
const request = await ctx.db.get(args.requestId);
if (!request) return [];
await assertPodeGerenciarDocumentosDoPedido(ctx, request.pedidoId, user);
const docs = await ctx.db
.query('solicitacoesItensDocumentos')
.withIndex('by_requestId', (q) => q.eq('requestId', args.requestId))
.order('desc')
.collect();
return await Promise.all(
docs.map(async (doc) => {
const url = await ctx.storage.getUrl(doc.storageId);
const func = await ctx.db.get(doc.criadoPor);
return {
...doc,
criadoPorNome: func?.nome ?? 'Desconhecido',
url
};
})
);
}
});
export const removeSolicitacaoDocumento = mutation({
args: { id: v.id('solicitacoesItensDocumentos') },
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 doc = await ctx.db.get(args.id);
if (!doc) throw new Error('Documento não encontrado.');
const request = await ctx.db.get(doc.requestId);
if (!request) throw new Error('Solicitação não encontrada.');
if (request.tipo !== 'adicao') {
throw new Error('Apenas solicitações de adição permitem documentos.');
}
if (request.status !== 'pendente') {
throw new Error('Não é possível remover documentos de uma solicitação não pendente.');
}
if (doc.criadoPor !== user.funcionarioId || request.solicitadoPor !== user.funcionarioId) {
throw new Error('Apenas quem criou a solicitação pode remover documentos.');
}
await ctx.storage.delete(doc.storageId);
await ctx.db.delete(doc._id);
return null;
}
});