feat: enhance ata management by adding dataProrrogacao field and updating related logic for effective date handling, improving data integrity and user experience in pedidos
This commit is contained in:
@@ -45,12 +45,22 @@ export const list = query({
|
||||
// Filtro por intervalo (range): retorna atas cuja vigência intersecta o período informado.
|
||||
// Considera datas como strings "YYYY-MM-DD" (lexicograficamente comparáveis).
|
||||
const ataInicio = ata.dataInicio ?? '0000-01-01';
|
||||
const ataFim = ata.dataFim ?? '9999-12-31';
|
||||
const ataFimEfetivo = (() => {
|
||||
const a = ata.dataFim;
|
||||
const b = (ata as { dataProrrogacao?: string }).dataProrrogacao;
|
||||
if (!a && !b) return '9999-12-31';
|
||||
if (!a) return b!;
|
||||
if (!b) return a;
|
||||
return a >= b ? a : b;
|
||||
})();
|
||||
|
||||
const periodoOk =
|
||||
(!periodoInicio && !periodoFim) ||
|
||||
(periodoInicio && periodoFim && ataInicio <= periodoFim && ataFim >= periodoInicio) ||
|
||||
(periodoInicio && !periodoFim && ataFim >= periodoInicio) ||
|
||||
(periodoInicio &&
|
||||
periodoFim &&
|
||||
ataInicio <= periodoFim &&
|
||||
ataFimEfetivo >= periodoInicio) ||
|
||||
(periodoInicio && !periodoFim && ataFimEfetivo >= periodoInicio) ||
|
||||
(!periodoInicio && periodoFim && ataInicio <= periodoFim);
|
||||
|
||||
return numeroOk && seiOk && periodoOk;
|
||||
@@ -149,6 +159,7 @@ export const create = mutation({
|
||||
numero: v.string(),
|
||||
dataInicio: v.optional(v.string()),
|
||||
dataFim: v.optional(v.string()),
|
||||
dataProrrogacao: v.optional(v.string()),
|
||||
empresaId: v.id('empresas'),
|
||||
numeroSei: v.string(),
|
||||
objetos: v.array(
|
||||
@@ -174,6 +185,7 @@ export const create = mutation({
|
||||
empresaId: args.empresaId,
|
||||
dataInicio: args.dataInicio,
|
||||
dataFim: args.dataFim,
|
||||
dataProrrogacao: args.dataProrrogacao,
|
||||
criadoPor: user._id,
|
||||
criadoEm: Date.now(),
|
||||
atualizadoEm: Date.now()
|
||||
@@ -205,6 +217,7 @@ export const update = mutation({
|
||||
empresaId: v.id('empresas'),
|
||||
dataInicio: v.optional(v.string()),
|
||||
dataFim: v.optional(v.string()),
|
||||
dataProrrogacao: v.optional(v.string()),
|
||||
objetos: v.array(
|
||||
v.object({
|
||||
objetoId: v.id('objetos'),
|
||||
@@ -228,6 +241,7 @@ export const update = mutation({
|
||||
empresaId: args.empresaId,
|
||||
dataInicio: args.dataInicio,
|
||||
dataFim: args.dataFim,
|
||||
dataProrrogacao: args.dataProrrogacao,
|
||||
atualizadoEm: Date.now()
|
||||
});
|
||||
|
||||
|
||||
@@ -1,7 +1,154 @@
|
||||
import { v } from 'convex/values';
|
||||
import { mutation, query } from './_generated/server';
|
||||
import type { Id } from './_generated/dataModel';
|
||||
import type { QueryCtx } from './_generated/server';
|
||||
import { getCurrentUserFunction } from './auth';
|
||||
import { addMonthsClampedYMD, getTodayYMD, isWithinRangeYMD, maxYMD } from './utils/datas';
|
||||
|
||||
const ataComLimiteValidator = v.object({
|
||||
_id: v.id('atas'),
|
||||
numero: v.string(),
|
||||
numeroSei: v.string(),
|
||||
dataInicio: v.union(v.string(), v.null()),
|
||||
dataFim: v.union(v.string(), v.null()),
|
||||
dataProrrogacao: v.union(v.string(), v.null()),
|
||||
dataFimEfetiva: v.union(v.string(), v.null()),
|
||||
quantidadeTotal: v.union(v.number(), v.null()),
|
||||
limitePercentual: v.number(),
|
||||
quantidadeUsada: v.number(),
|
||||
limitePermitido: v.number(),
|
||||
restante: v.number(),
|
||||
isLocked: v.boolean(),
|
||||
isVencidaRecentemente: v.boolean(),
|
||||
lockReason: v.union(
|
||||
v.literal('vigencia_expirada'),
|
||||
v.literal('limite_atingido'),
|
||||
v.literal('nao_configurada'),
|
||||
v.null()
|
||||
)
|
||||
});
|
||||
|
||||
async function computeAtasComLimiteForObjeto(
|
||||
ctx: QueryCtx,
|
||||
objetoId: Id<'objetos'>,
|
||||
includeAtaIds?: Id<'atas'>[]
|
||||
) {
|
||||
const today = getTodayYMD();
|
||||
const threeMonthsAgo = addMonthsClampedYMD(today, -3);
|
||||
const includeSet = new Set<Id<'atas'>>(includeAtaIds ?? []);
|
||||
|
||||
const links = await ctx.db
|
||||
.query('atasObjetos')
|
||||
.withIndex('by_objetoId', (q) => q.eq('objetoId', objetoId))
|
||||
.collect();
|
||||
|
||||
const results: Array<{
|
||||
_id: Id<'atas'>;
|
||||
numero: string;
|
||||
numeroSei: string;
|
||||
dataInicio: string | null;
|
||||
dataFim: string | null;
|
||||
dataProrrogacao: string | null;
|
||||
dataFimEfetiva: string | null;
|
||||
quantidadeTotal: number | null;
|
||||
limitePercentual: number;
|
||||
quantidadeUsada: number;
|
||||
limitePermitido: number;
|
||||
restante: number;
|
||||
isLocked: boolean;
|
||||
isVencidaRecentemente: boolean;
|
||||
lockReason: 'vigencia_expirada' | 'limite_atingido' | 'nao_configurada' | null;
|
||||
}> = [];
|
||||
|
||||
for (const link of links) {
|
||||
const ata = await ctx.db.get(link.ataId);
|
||||
if (!ata) continue;
|
||||
|
||||
const dataProrrogacao = (ata as { dataProrrogacao?: string }).dataProrrogacao ?? null;
|
||||
const dataFimEfetiva = maxYMD(ata.dataFim ?? null, dataProrrogacao);
|
||||
|
||||
const vigenteHoje = isWithinRangeYMD(today, ata.dataInicio ?? null, dataFimEfetiva);
|
||||
const isVencidaRecentemente =
|
||||
!!dataFimEfetiva && dataFimEfetiva < today && dataFimEfetiva >= threeMonthsAgo;
|
||||
|
||||
// Para seleção em pedidos: manter somente vigentes e vencidas recentemente,
|
||||
// mas permitir incluir atas específicas (ex.: já selecionadas em pedido antigo).
|
||||
const shouldForceInclude = includeSet.has(ata._id);
|
||||
if (!shouldForceInclude && !vigenteHoje && !isVencidaRecentemente) {
|
||||
continue;
|
||||
}
|
||||
const isForaDaVigencia = !vigenteHoje;
|
||||
|
||||
let quantidadeUsada = link.quantidadeUsada ?? 0;
|
||||
if (link.quantidadeUsada === undefined) {
|
||||
const items = await ctx.db
|
||||
.query('objetoItems')
|
||||
.withIndex('by_ataId_and_objetoId', (q) =>
|
||||
q.eq('ataId', link.ataId).eq('objetoId', link.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;
|
||||
}
|
||||
}
|
||||
quantidadeUsada = total;
|
||||
}
|
||||
|
||||
const quantidadeTotal = link.quantidadeTotal ?? null;
|
||||
const limitePercentualRaw = link.limitePercentual ?? 50;
|
||||
const limitePercentual = Number.isFinite(limitePercentualRaw)
|
||||
? Math.min(100, Math.max(0, limitePercentualRaw))
|
||||
: 50;
|
||||
|
||||
const limitePermitido =
|
||||
quantidadeTotal && quantidadeTotal > 0
|
||||
? Math.floor(quantidadeTotal * (limitePercentual / 100))
|
||||
: 0;
|
||||
|
||||
const restante = Math.max(0, limitePermitido - quantidadeUsada);
|
||||
|
||||
const lockReason: 'nao_configurada' | 'limite_atingido' | 'vigencia_expirada' | null =
|
||||
!quantidadeTotal || quantidadeTotal <= 0
|
||||
? 'nao_configurada'
|
||||
: quantidadeUsada >= limitePermitido
|
||||
? 'limite_atingido'
|
||||
: isForaDaVigencia
|
||||
? 'vigencia_expirada'
|
||||
: null;
|
||||
|
||||
const isLocked = lockReason !== null;
|
||||
|
||||
results.push({
|
||||
_id: ata._id,
|
||||
numero: ata.numero,
|
||||
numeroSei: ata.numeroSei,
|
||||
dataInicio: ata.dataInicio ?? null,
|
||||
dataFim: ata.dataFim ?? null,
|
||||
dataProrrogacao,
|
||||
dataFimEfetiva,
|
||||
quantidadeTotal,
|
||||
limitePercentual,
|
||||
quantidadeUsada,
|
||||
limitePermitido,
|
||||
restante,
|
||||
isLocked,
|
||||
isVencidaRecentemente,
|
||||
lockReason
|
||||
});
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
export const list = query({
|
||||
args: {
|
||||
@@ -147,89 +294,39 @@ export const getAtas = query({
|
||||
});
|
||||
|
||||
export const getAtasComLimite = query({
|
||||
args: { objetoId: v.id('objetos') },
|
||||
args: {
|
||||
objetoId: v.id('objetos'),
|
||||
// Permite incluir atas específicas (ex.: já selecionadas em um pedido antigo),
|
||||
// mesmo que não estejam vigentes ou não tenham vencido nos últimos 3 meses.
|
||||
includeAtaIds: v.optional(v.array(v.id('atas')))
|
||||
},
|
||||
returns: v.array(ataComLimiteValidator),
|
||||
handler: async (ctx, args) => {
|
||||
return await computeAtasComLimiteForObjeto(ctx, args.objetoId, args.includeAtaIds);
|
||||
}
|
||||
});
|
||||
|
||||
export const getAtasComLimiteBatch = query({
|
||||
args: {
|
||||
objetoIds: v.array(v.id('objetos')),
|
||||
includeAtaIds: v.optional(v.array(v.id('atas')))
|
||||
},
|
||||
returns: v.array(
|
||||
v.object({
|
||||
_id: v.id('atas'),
|
||||
numero: v.string(),
|
||||
numeroSei: v.string(),
|
||||
dataInicio: v.union(v.string(), v.null()),
|
||||
dataFim: v.union(v.string(), v.null()),
|
||||
quantidadeTotal: v.union(v.number(), v.null()),
|
||||
limitePercentual: v.number(),
|
||||
quantidadeUsada: v.number(),
|
||||
limitePermitido: v.number(),
|
||||
restante: v.number(),
|
||||
isLocked: v.boolean()
|
||||
objetoId: v.id('objetos'),
|
||||
atas: v.array(ataComLimiteValidator)
|
||||
})
|
||||
),
|
||||
handler: async (ctx, args) => {
|
||||
const links = await ctx.db
|
||||
.query('atasObjetos')
|
||||
.withIndex('by_objetoId', (q) => q.eq('objetoId', args.objetoId))
|
||||
.collect();
|
||||
if (args.objetoIds.length === 0) return [];
|
||||
const include = args.includeAtaIds ?? [];
|
||||
|
||||
const results = [];
|
||||
for (const link of links) {
|
||||
const ata = await ctx.db.get(link.ataId);
|
||||
if (!ata) continue;
|
||||
|
||||
let quantidadeUsada = link.quantidadeUsada ?? 0;
|
||||
if (link.quantidadeUsada === undefined) {
|
||||
const items = await ctx.db
|
||||
.query('objetoItems')
|
||||
.withIndex('by_ataId_and_objetoId', (q) =>
|
||||
q.eq('ataId', link.ataId).eq('objetoId', link.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;
|
||||
}
|
||||
}
|
||||
quantidadeUsada = total;
|
||||
}
|
||||
|
||||
const quantidadeTotal = link.quantidadeTotal ?? null;
|
||||
const limitePercentualRaw = link.limitePercentual ?? 50;
|
||||
const limitePercentual = Number.isFinite(limitePercentualRaw)
|
||||
? Math.min(100, Math.max(0, limitePercentualRaw))
|
||||
: 50;
|
||||
|
||||
const limitePermitido =
|
||||
quantidadeTotal && quantidadeTotal > 0
|
||||
? Math.floor(quantidadeTotal * (limitePercentual / 100))
|
||||
: 0;
|
||||
|
||||
const restante = Math.max(0, limitePermitido - quantidadeUsada);
|
||||
const isLocked =
|
||||
!quantidadeTotal || quantidadeTotal <= 0 || quantidadeUsada >= limitePermitido;
|
||||
|
||||
results.push({
|
||||
_id: ata._id,
|
||||
numero: ata.numero,
|
||||
numeroSei: ata.numeroSei,
|
||||
dataInicio: ata.dataInicio ?? null,
|
||||
dataFim: ata.dataFim ?? null,
|
||||
quantidadeTotal,
|
||||
limitePercentual,
|
||||
quantidadeUsada,
|
||||
limitePermitido,
|
||||
restante,
|
||||
isLocked
|
||||
});
|
||||
const out = [];
|
||||
for (const objetoId of args.objetoIds) {
|
||||
const atas = await computeAtasComLimiteForObjeto(ctx, objetoId, include);
|
||||
out.push({ objetoId, atas });
|
||||
}
|
||||
|
||||
return results;
|
||||
return out;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 ==========
|
||||
|
||||
@@ -309,6 +310,24 @@ async function assertAtaObjetoCanConsume(
|
||||
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(
|
||||
|
||||
@@ -6,6 +6,7 @@ export const atasTables = {
|
||||
numero: v.string(),
|
||||
dataInicio: v.optional(v.string()),
|
||||
dataFim: v.optional(v.string()),
|
||||
dataProrrogacao: v.optional(v.string()),
|
||||
empresaId: v.id('empresas'),
|
||||
numeroSei: v.string(),
|
||||
criadoPor: v.id('usuarios'),
|
||||
|
||||
@@ -7,10 +7,10 @@
|
||||
* Converte uma string de data no formato YYYY-MM-DD para um objeto Date local
|
||||
* No ambiente Convex, as datas são tratadas como UTC, então precisamos garantir
|
||||
* que a data seja interpretada corretamente.
|
||||
*
|
||||
*
|
||||
* @param dateString - String no formato YYYY-MM-DD
|
||||
* @returns Date objeto representando a data
|
||||
*
|
||||
*
|
||||
* @example
|
||||
* parseLocalDate('2024-01-15') // Retorna Date para 15/01/2024
|
||||
*/
|
||||
@@ -42,13 +42,13 @@ export function parseLocalDate(dateString: string): Date {
|
||||
|
||||
/**
|
||||
* Formata uma data para o formato brasileiro (DD/MM/YYYY)
|
||||
*
|
||||
*
|
||||
* @param date - Date objeto ou string no formato YYYY-MM-DD
|
||||
* @returns String formatada no formato DD/MM/YYYY
|
||||
*/
|
||||
export function formatarDataBR(date: Date | string): string {
|
||||
let dateObj: Date;
|
||||
|
||||
|
||||
if (typeof date === 'string') {
|
||||
dateObj = parseLocalDate(date);
|
||||
} else {
|
||||
@@ -63,3 +63,69 @@ export function formatarDataBR(date: Date | string): string {
|
||||
return `${day}/${month}/${year}`;
|
||||
}
|
||||
|
||||
function pad2(n: number): string {
|
||||
return String(n).padStart(2, '0');
|
||||
}
|
||||
|
||||
/**
|
||||
* Retorna a data atual (UTC) no formato YYYY-MM-DD.
|
||||
* Útil para comparações lexicográficas com strings YYYY-MM-DD persistidas no banco.
|
||||
*/
|
||||
export function getTodayYMD(): string {
|
||||
const now = new Date();
|
||||
const y = now.getUTCFullYear();
|
||||
const m = now.getUTCMonth() + 1;
|
||||
const d = now.getUTCDate();
|
||||
return `${y}-${pad2(m)}-${pad2(d)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Soma meses (calendário) a uma data YYYY-MM-DD, mantendo o dia quando possível,
|
||||
* e fazendo clamp para o último dia do mês quando necessário.
|
||||
*
|
||||
* Ex.: 2025-03-31 + (-1) mês => 2025-02-28 (ou 29 em ano bissexto)
|
||||
*/
|
||||
export function addMonthsClampedYMD(dateString: string, deltaMonths: number): string {
|
||||
const base = parseLocalDate(dateString); // UTC midnight
|
||||
const year = base.getUTCFullYear();
|
||||
const monthIndex = base.getUTCMonth(); // 0..11
|
||||
const day = base.getUTCDate();
|
||||
|
||||
const totalMonths = monthIndex + deltaMonths;
|
||||
const newYear = year + Math.floor(totalMonths / 12);
|
||||
let newMonthIndex = totalMonths % 12;
|
||||
if (newMonthIndex < 0) {
|
||||
newMonthIndex += 12;
|
||||
}
|
||||
|
||||
// Último dia do mês alvo
|
||||
const lastDay = new Date(Date.UTC(newYear, newMonthIndex + 1, 0)).getUTCDate();
|
||||
const newDay = Math.min(day, lastDay);
|
||||
|
||||
return `${newYear}-${pad2(newMonthIndex + 1)}-${pad2(newDay)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retorna o maior (mais recente) entre duas datas YYYY-MM-DD (lexicograficamente).
|
||||
* Se uma delas for null/undefined, retorna a outra.
|
||||
*/
|
||||
export function maxYMD(a?: string | null, b?: string | null): string | null {
|
||||
if (!a && !b) return null;
|
||||
if (!a) return b ?? null;
|
||||
if (!b) return a;
|
||||
return a >= b ? a : b;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checa se `date` está dentro do intervalo [inicio..fim], onde
|
||||
* `inicio` e `fim` são YYYY-MM-DD (ou null para aberto).
|
||||
*/
|
||||
export function isWithinRangeYMD(
|
||||
date: string,
|
||||
inicio?: string | null,
|
||||
fim?: string | null
|
||||
): boolean {
|
||||
const start = inicio ?? '0000-01-01';
|
||||
const end = fim ?? '9999-12-31';
|
||||
return start <= date && date <= end;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user