Files
sgse-app/apps/web/src/lib/utils/ponto/processamento.ts

700 lines
22 KiB
TypeScript

import type { ConvexClient } from 'convex-svelte';
import type { Id } from '@sgse-app/backend/convex/_generated/dataModel';
import { api } from '@sgse-app/backend/convex/_generated/api';
import type { DiaFichaPonto, ResumoPeriodo, RegistroPonto, TipoDia } from './tipos';
import { calcularSaldoComparativoPorPar } from './calculos';
import { registroFoiMarcado } from './validacao';
import { formatarDataDDMMAAAA } from '../ponto';
import { formatarMinutos, formatarHoras } from './formatacao';
/**
* Gera array de todas as datas do período selecionado
*/
export function gerarDiasPeriodo(dataInicio: string, dataFim: string): string[] {
const dias: string[] = [];
const inicio = new Date(dataInicio);
const fim = new Date(dataFim);
for (let d = new Date(inicio); d <= fim; d.setDate(d.getDate() + 1)) {
dias.push(d.toISOString().split('T')[0]!);
}
return dias;
}
/**
* Gera registros esperados para um dia baseado na configuração
*/
export function gerarRegistrosEsperados(
data: string,
config: {
horarioEntrada: string;
horarioSaidaAlmoco: string;
horarioRetornoAlmoco: string;
horarioSaida: string;
}
): Array<{ tipo: string; hora: number; minuto: number; data: string }> {
const [horaEntrada, minutoEntrada] = config.horarioEntrada.split(':').map(Number);
const [horaSaidaAlmoco, minutoSaidaAlmoco] = config.horarioSaidaAlmoco.split(':').map(Number);
const [horaRetornoAlmoco, minutoRetornoAlmoco] = config.horarioRetornoAlmoco.split(':').map(Number);
const [horaSaida, minutoSaida] = config.horarioSaida.split(':').map(Number);
return [
{ tipo: 'entrada', hora: horaEntrada, minuto: minutoEntrada, data },
{ tipo: 'saida_almoco', hora: horaSaidaAlmoco, minuto: minutoSaidaAlmoco, data },
{ tipo: 'retorno_almoco', hora: horaRetornoAlmoco, minuto: minutoRetornoAlmoco, data },
{ tipo: 'saida', hora: horaSaida, minuto: minutoSaida, data }
];
}
/**
* Agrupa registros por funcionário e data
*/
export function agruparRegistrosPorFuncionario(
registros: Array<{
_id: Id<'registrosPonto'>;
funcionarioId: Id<'funcionarios'>;
data: string;
funcionario?: { nome: string; matricula?: string; descricaoCargo?: string } | null;
[key: string]: any;
}>,
config?: {
horarioEntrada: string;
horarioSaidaAlmoco: string;
horarioRetornoAlmoco: string;
horarioSaida: string;
}
): Array<{
funcionario: { nome: string; matricula?: string; descricaoCargo?: string } | null;
funcionarioId: Id<'funcionarios'>;
registrosPorData: Record<
string,
{
data: string;
registros: typeof registros;
saldoDiario?: {
saldoMinutos: number;
horas: number;
minutos: number;
positivo: boolean;
};
saldoDiarioComparativo?: {
trabalhadoMinutos: number;
esperadoMinutos: number;
diferencaMinutos: number;
};
}
>;
}> {
const agrupados: Record<
string,
{
funcionario: { nome: string; matricula?: string; descricaoCargo?: string } | null;
funcionarioId: Id<'funcionarios'>;
registrosPorData: Record<
string,
{
data: string;
registros: typeof registros;
saldoDiario?: {
saldoMinutos: number;
horas: number;
minutos: number;
positivo: boolean;
};
saldoDiarioComparativo?: {
trabalhadoMinutos: number;
esperadoMinutos: number;
diferencaMinutos: number;
};
}
>;
}
> = {};
const registrosProcessados = new Set<string>();
if (!Array.isArray(registros) || registros.length === 0) {
return [];
}
for (const registro of registros) {
if (!registro || !registro._id || !registro.funcionarioId || !registro.data) {
continue;
}
const chaveUnica = `${registro._id}`;
if (registrosProcessados.has(chaveUnica)) {
continue;
}
registrosProcessados.add(chaveUnica);
const key = registro.funcionarioId;
if (!agrupados[key]) {
agrupados[key] = {
funcionario: registro.funcionario,
funcionarioId: registro.funcionarioId,
registrosPorData: {}
};
}
const dataKey = registro.data;
if (!agrupados[key]!.registrosPorData[dataKey]) {
agrupados[key]!.registrosPorData[dataKey] = {
data: dataKey,
registros: [],
saldoDiario: undefined
};
}
const jaExiste = agrupados[key]!.registrosPorData[dataKey]!.registros.some(
(r) => r._id === registro._id
);
if (!jaExiste) {
agrupados[key]!.registrosPorData[dataKey]!.registros.push(registro);
}
}
const resultado = Object.values(agrupados);
resultado.sort((a, b) => {
const nomeA = a.funcionario?.nome || '';
const nomeB = b.funcionario?.nome || '';
return nomeA.localeCompare(nomeB, 'pt-BR');
});
for (const grupo of resultado) {
const datasOrdenadas = Object.keys(grupo.registrosPorData).sort((a, b) => {
return new Date(b).getTime() - new Date(a).getTime();
});
const registrosPorDataOrdenado: Record<string, (typeof grupo.registrosPorData)[string]> = {};
for (const dataKey of datasOrdenadas) {
registrosPorDataOrdenado[dataKey] = grupo.registrosPorData[dataKey]!;
}
grupo.registrosPorData = registrosPorDataOrdenado;
for (const dataKey in grupo.registrosPorData) {
const grupoData = grupo.registrosPorData[dataKey];
if (grupoData && grupoData.registros.length > 0) {
grupoData.registros.sort((a, b) => {
if (a.hora !== b.hora) {
return a.hora - b.hora;
}
return a.minuto - b.minuto;
});
if (config) {
const regsReaisOrdenados = [...grupoData.registros].sort((a, b) => {
if (a.hora !== b.hora) return a.hora - b.hora;
return a.minuto - b.minuto;
});
const saldosComparativosPorPar = calcularSaldoComparativoPorPar(regsReaisOrdenados, config);
let totalTrabalhado = 0;
const paresProcessados = new Set<number>();
for (const [, saldo] of saldosComparativosPorPar.entries()) {
if (!paresProcessados.has(saldo.parIndex)) {
totalTrabalhado += saldo.trabalhadoMinutos;
paresProcessados.add(saldo.parIndex);
}
}
const [horaEntradaConfig, minutoEntradaConfig] = config.horarioEntrada.split(':').map(Number);
const [horaSaidaAlmocoConfig, minutoSaidaAlmocoConfig] = config.horarioSaidaAlmoco
.split(':')
.map(Number);
const [horaRetornoAlmocoConfig, minutoRetornoAlmocoConfig] =
config.horarioRetornoAlmoco.split(':').map(Number);
const [horaSaidaConfig, minutoSaidaConfig] = config.horarioSaida.split(':').map(Number);
const minutosPar1EsperadoConfig =
horaSaidaAlmocoConfig * 60 +
minutoSaidaAlmocoConfig -
(horaEntradaConfig * 60 + minutoEntradaConfig);
const minutosPar1EsperadoAjustadoConfig =
minutosPar1EsperadoConfig < 0
? minutosPar1EsperadoConfig + 24 * 60
: minutosPar1EsperadoConfig;
const minutosPar2EsperadoConfig =
horaSaidaConfig * 60 +
minutoSaidaConfig -
(horaRetornoAlmocoConfig * 60 + minutoRetornoAlmocoConfig);
const minutosPar2EsperadoAjustadoConfig =
minutosPar2EsperadoConfig < 0
? minutosPar2EsperadoConfig + 24 * 60
: minutosPar2EsperadoConfig;
const cargaHorariaDiariaEsperadaMinutos =
minutosPar1EsperadoAjustadoConfig + minutosPar2EsperadoAjustadoConfig;
const diferencaMinutos = totalTrabalhado - cargaHorariaDiariaEsperadaMinutos;
grupoData.saldoDiarioComparativo = {
trabalhadoMinutos: totalTrabalhado,
esperadoMinutos: cargaHorariaDiariaEsperadaMinutos,
diferencaMinutos: diferencaMinutos
};
}
}
}
}
return resultado.filter((grupo) => {
const temRegistros = Object.values(grupo.registrosPorData).some(
(grupoData) => grupoData.registros && grupoData.registros.length > 0
);
return temRegistros;
});
}
/**
* Processa dados para ficha de ponto
* Esta é uma função grande que processa todos os dados necessários para gerar a ficha
*/
export async function processarDadosFichaPonto(
client: ConvexClient,
funcionarioId: Id<'funcionarios'>,
dataInicio: string,
dataFim: string
): Promise<{
dias: DiaFichaPonto[];
resumo: ResumoPeriodo;
config: {
horarioEntrada: string;
horarioSaidaAlmoco: string;
horarioRetornoAlmoco: string;
horarioSaida: string;
};
}> {
// Buscar todos os dados necessários
const [
registrosFuncionario,
atestadosLicencas,
ausenciasTodas,
ajustes,
inconsistencias,
homologacoes,
dispensas,
config
] = await Promise.all([
client.query(api.pontos.listarRegistrosPeriodo, {
funcionarioId,
dataInicio,
dataFim
}),
client.query(api.atestadosLicencas.listarPorFuncionario, {
funcionarioId
}),
client.query(api.ausencias.listarTodas, {}),
client.query(api.pontos.listarAjustesBancoHoras, {
funcionarioId
}),
client.query(api.pontos.listarInconsistenciasBancoHoras, {}),
client.query(api.pontos.listarHomologacoes, {
funcionarioId
}),
client.query(api.pontos.listarDispensas, {
funcionarioId,
apenasAtivas: false
}),
client.query(api.configuracaoPonto.obterConfiguracao, {})
]);
const atestados = atestadosLicencas?.atestados || [];
const licencas = atestadosLicencas?.licencas || [];
const ausencias = (ausenciasTodas || []).filter((a) => a.funcionarioId === funcionarioId);
if (!config) {
throw new Error('Configuração de ponto não encontrada');
}
// Filtrar dados pelo período
const dataInicioObj = new Date(dataInicio + 'T00:00:00');
const dataFimObj = new Date(dataFim + 'T23:59:59');
const atestadosPeriodo = (atestados || []).filter((a) => {
const inicio = new Date(a.dataInicio);
const fim = new Date(a.dataFim);
return inicio <= dataFimObj && fim >= dataInicioObj;
});
const ausenciasPeriodo = (ausencias || []).filter((a) => {
const inicio = new Date(a.dataInicio);
const fim = new Date(a.dataFim);
return inicio <= dataFimObj && fim >= dataInicioObj;
});
const licencasPeriodo = (licencas || []).filter((l) => {
const inicio = new Date(l.dataInicio);
const fim = new Date(l.dataFim);
return inicio <= dataFimObj && fim >= dataInicioObj;
});
const ajustesPeriodo = (ajustes || []).filter((a) => {
const dataAjuste = new Date(a.dataAplicacao);
return dataAjuste >= dataInicioObj && dataAjuste <= dataFimObj;
});
const inconsistenciasPeriodo = (inconsistencias || []).filter((i) => {
if (i.funcionarioId !== funcionarioId) return false;
const dataInconsistencia = new Date(i.dataDetectada);
return dataInconsistencia >= dataInicioObj && dataInconsistencia <= dataFimObj;
});
const dataInicioTimestamp = dataInicioObj.getTime();
const dataFimTimestamp = dataFimObj.getTime();
const homologacoesPeriodo = (homologacoes || []).filter((h) => {
return h.criadoEm >= dataInicioTimestamp && h.criadoEm <= dataFimTimestamp;
});
const dispensasPeriodo = (dispensas || []).filter((d) => {
const dispensaInicio = new Date(d.dataInicio + 'T00:00:00');
const dispensaFim = new Date(d.dataFim + 'T23:59:59');
return dispensaInicio <= dataFimObj && dispensaFim >= dataInicioObj;
});
// Gerar todos os dias do período
const diasPeriodo = gerarDiasPeriodo(dataInicio, dataFim);
const diasProcessados: DiaFichaPonto[] = [];
// Agrupar registros por data
const registrosPorData: Record<string, RegistroPonto[]> = {};
for (const r of registrosFuncionario || []) {
if (!registrosPorData[r.data]) {
registrosPorData[r.data] = [];
}
registrosPorData[r.data]!.push(r);
}
// Processar cada dia
for (const data of diasPeriodo) {
const dataObj = new Date(data);
const regsReais = registrosPorData[data] || [];
const regsEsperados = gerarRegistrosEsperados(data, config);
// Verificar atestado
const atestadoDia =
atestadosPeriodo.find((a) => {
const inicio = new Date(a.dataInicio);
const fim = new Date(a.dataFim);
return dataObj >= inicio && dataObj <= fim;
}) || null;
// Verificar ausência
const ausenciaDia =
ausenciasPeriodo.find((a) => {
const inicio = new Date(a.dataInicio);
const fim = new Date(a.dataFim);
return dataObj >= inicio && dataObj <= fim;
}) || null;
// Verificar licença
const licencaDia =
licencasPeriodo.find((l) => {
const inicio = new Date(l.dataInicio);
const fim = new Date(l.dataFim);
return dataObj >= inicio && dataObj <= fim;
}) || null;
// Verificar ajustes do dia
const ajustesDia = ajustesPeriodo.filter((a) => a.dataAplicacao === data);
// Verificar inconsistências do dia
const inconsistenciasDia = inconsistenciasPeriodo.filter((i) => i.dataDetectada === data);
// Verificar homologações do dia
const homologacoesDia = homologacoesPeriodo.filter((h) => {
if (h.registroId) {
const registro = regsReais.find((r) => r._id === h.registroId);
return registro !== undefined;
}
return false;
});
// Verificar dispensa
const dispensaDia =
dispensasPeriodo.find((d) => {
const dispensaInicio = new Date(d.dataInicio + 'T00:00:00');
const dispensaFim = new Date(d.dataFim + 'T23:59:59');
return dataObj >= dispensaInicio && dataObj <= dispensaFim;
}) || null;
// Calcular saldo diário
const regsReaisOrdenados = [...regsReais].sort((a, b) => {
if (a.hora !== b.hora) return a.hora - b.hora;
return a.minuto - b.minuto;
});
const saldosComparativosPorPar = calcularSaldoComparativoPorPar(regsReaisOrdenados, config);
let saldoDiario: { diferencaMinutos: number; trabalhadoMinutos: number; esperadoMinutos: number } | null = null;
let saldoDiarioTotalDiferencaMinutos = 0;
let saldoDiarioTotalTrabalhadoMinutos = 0;
let saldoDiarioTotalEsperadoMinutos = 0;
// Somar saldos dos pares
const paresProcessados = new Set<number>();
for (const [, saldo] of saldosComparativosPorPar.entries()) {
if (!paresProcessados.has(saldo.parIndex)) {
saldoDiarioTotalDiferencaMinutos += saldo.diferencaMinutos;
saldoDiarioTotalTrabalhadoMinutos += saldo.trabalhadoMinutos;
saldoDiarioTotalEsperadoMinutos += saldo.esperadoMinutos;
paresProcessados.add(saldo.parIndex);
}
}
// Calcular saldo para pares não marcados
const todosRegistros: Array<{ tipo: string; hora: number; minuto: number; real: boolean }> = [];
for (const reg of regsReais) {
todosRegistros.push({
tipo: reg.tipo,
hora: reg.hora,
minuto: reg.minuto,
real: true
});
}
for (const regEsperado of regsEsperados) {
if (!registroFoiMarcado(regEsperado, regsReais)) {
todosRegistros.push({
tipo: regEsperado.tipo,
hora: regEsperado.hora,
minuto: regEsperado.minuto,
real: false
});
}
}
// Identificar pares não marcados e calcular saldo negativo
for (let i = 0; i < todosRegistros.length; i++) {
const reg = todosRegistros[i];
if ((reg.tipo === 'entrada' || reg.tipo === 'retorno_almoco') && !reg.real) {
const tipoSaidaEsperado = reg.tipo === 'entrada' ? 'saida_almoco' : 'saida';
const saidaEsperada = todosRegistros.find((r, idx) => {
if (idx <= i) return false;
if (r.tipo !== tipoSaidaEsperado || r.real) return false;
const minutosEntrada = reg.hora * 60 + reg.minuto;
const minutosSaidaEsperada = r.hora * 60 + r.minuto;
const temRegistroRealNoIntervalo = regsReais.some((real) => {
if (real.tipo !== tipoSaidaEsperado) return false;
const minutosReal = real.hora * 60 + real.minuto;
return minutosReal >= minutosEntrada && minutosReal < minutosSaidaEsperada;
});
return !temRegistroRealNoIntervalo;
});
if (saidaEsperada) {
let esperadoMinutos: number;
if (reg.tipo === 'entrada') {
const [horaEntradaEsperada, minutoEntradaEsperado] = config.horarioEntrada
.split(':')
.map(Number);
const [horaSaidaAlmocoEsperada, minutoSaidaAlmocoEsperado] = config.horarioSaidaAlmoco
.split(':')
.map(Number);
const minutosEntradaEsperada = horaEntradaEsperada * 60 + minutoEntradaEsperado;
const minutosSaidaEsperadaConfig =
horaSaidaAlmocoEsperada * 60 + minutoSaidaAlmocoEsperado;
esperadoMinutos = minutosSaidaEsperadaConfig - minutosEntradaEsperada;
if (esperadoMinutos < 0) esperadoMinutos += 24 * 60;
} else {
const [horaRetornoAlmocoEsperado, minutoRetornoAlmocoEsperado] =
config.horarioRetornoAlmoco.split(':').map(Number);
const [horaSaidaEsperada, minutoSaidaEsperado] = config.horarioSaida
.split(':')
.map(Number);
const minutosEntradaEsperada =
horaRetornoAlmocoEsperado * 60 + minutoRetornoAlmocoEsperado;
const minutosSaidaEsperadaConfig = horaSaidaEsperada * 60 + minutoSaidaEsperado;
esperadoMinutos = minutosSaidaEsperadaConfig - minutosEntradaEsperada;
if (esperadoMinutos < 0) esperadoMinutos += 24 * 60;
}
saldoDiarioTotalDiferencaMinutos -= esperadoMinutos;
saldoDiarioTotalEsperadoMinutos += esperadoMinutos;
}
}
}
// Aplicar ajustes manuais
for (const ajuste of ajustesDia) {
if (ajuste.tipo === 'abonar') {
saldoDiarioTotalDiferencaMinutos += ajuste.valorMinutos;
} else if (ajuste.tipo === 'descontar') {
saldoDiarioTotalDiferencaMinutos -= ajuste.valorMinutos;
}
}
// Calcular diferença final
const diferencaDiariaCorrigida =
saldoDiarioTotalTrabalhadoMinutos - saldoDiarioTotalEsperadoMinutos;
saldoDiario = {
diferencaMinutos: diferencaDiariaCorrigida,
trabalhadoMinutos: saldoDiarioTotalTrabalhadoMinutos,
esperadoMinutos: saldoDiarioTotalEsperadoMinutos
};
// Determinar tipo de dia
let tipoDia: TipoDia = 'normal';
let computado = true;
if (dispensaDia) {
tipoDia = 'nao_computado';
computado = false;
} else if (licencaDia) {
tipoDia = 'licenca';
computado = false;
} else if (atestadoDia) {
tipoDia = 'atestado';
computado = false;
} else if (ausenciaDia) {
tipoDia = 'ausencia';
computado = false;
} else if (ajustesDia.some((a) => a.tipo === 'abonar' && a.valorMinutos >= 240)) {
tipoDia = 'abonado';
}
if (inconsistenciasDia.length > 0) {
tipoDia = 'inconsistente';
}
diasProcessados.push({
data,
dataFormatada: formatarDataDDMMAAAA(data),
tipoDia,
registros: regsReais,
registrosEsperados: regsEsperados,
saldoDiario,
saldoAcumulado: 0, // Será calculado depois
atestado: atestadoDia
? {
_id: atestadoDia._id,
tipo: atestadoDia.tipo,
dataInicio: atestadoDia.dataInicio,
dataFim: atestadoDia.dataFim,
motivo: atestadoDia.observacoes
}
: null,
ausencia: ausenciaDia
? {
_id: ausenciaDia._id,
motivo: ausenciaDia.motivo,
dataInicio: ausenciaDia.dataInicio,
dataFim: ausenciaDia.dataFim,
status: ausenciaDia.status
}
: null,
licenca: licencaDia
? {
_id: licencaDia._id,
tipo: licencaDia.tipo || 'licenca',
dataInicio: licencaDia.dataInicio,
dataFim: licencaDia.dataFim
}
: null,
ajustes: ajustesDia.map((a) => ({
_id: a._id,
tipo: a.tipo,
valorMinutos: a.valorMinutos,
motivoDescricao: a.motivoDescricao,
gestorId: a.gestorId,
dataInicio: a.dataInicio,
horaInicio: a.horaInicio,
minutoInicio: a.minutoInicio,
dataFim: a.dataFim,
horaFim: a.horaFim,
minutoFim: a.minutoFim
})),
inconsistencias: inconsistenciasDia.map((i) => ({
_id: i._id,
tipo: i.tipo,
descricao: i.descricao,
dataDetectada: i.dataDetectada,
status: i.status,
resolvidoPor: i.resolvidoPor,
resolvidoEm: i.resolvidoEm
})),
homologacoes: homologacoesDia.map((h) => ({
_id: h._id,
motivoDescricao: h.motivoDescricao,
gestorId: h.gestorId
})),
dispensa: dispensaDia
? {
_id: dispensaDia._id,
motivo: dispensaDia.motivo,
dataInicio: dispensaDia.dataInicio,
dataFim: dispensaDia.dataFim,
ativo: dispensaDia.ativo
}
: null,
computado
});
}
// Calcular saldo acumulado para cada dia
// Agora consideramos todos os dias que possuem saldo diário, inclusive
// atestados, ausências e dias não computados, para que o resumo do período
// reflita qualquer trabalho realizado e a carga horária esperada.
let saldoAcumulado = 0;
for (const dia of diasProcessados) {
if (dia.saldoDiario) {
saldoAcumulado += dia.saldoDiario.diferencaMinutos;
}
dia.saldoAcumulado = saldoAcumulado;
}
// Calcular resumo com formatações
// Total de horas trabalhadas e esperadas passa a considerar todos os dias,
// não apenas os marcados como "computados", para que trechos trabalhados
// em dias de ausência/dispensa também apareçam no resumo.
const totalHorasTrabalhadas = diasProcessados.reduce(
(acc, d) => acc + (d.saldoDiario?.trabalhadoMinutos || 0),
0
);
const totalHorasEsperadas = diasProcessados.reduce(
(acc, d) => acc + (d.saldoDiario?.esperadoMinutos || 0),
0
);
const diferencaTotal = diasProcessados.reduce(
(acc, d) => acc + (d.saldoDiario?.diferencaMinutos || 0),
0
);
const saldoPeriodo = diferencaTotal;
const saldoFinal =
diasProcessados.length > 0 ? diasProcessados[diasProcessados.length - 1]!.saldoAcumulado : 0;
const resumo: ResumoPeriodo = {
totalDias: diasProcessados.length,
diasTrabalhados: diasProcessados.filter((d) => d.computado && d.registros.length > 0).length,
diasComAtestado: diasProcessados.filter((d) => d.atestado !== null).length,
diasAusentes: diasProcessados.filter((d) => d.ausencia !== null).length,
diasComLicenca: diasProcessados.filter((d) => d.licenca !== null).length,
diasAbonados: diasProcessados.filter((d) => d.tipoDia === 'abonado').length,
diasNaoComputados: diasProcessados.filter((d) => !d.computado).length,
diasComInconsistencia: diasProcessados.filter((d) => d.inconsistencias.length > 0).length,
totalHorasTrabalhadas,
totalHorasEsperadas,
diferencaTotal,
saldoInicial: 0,
saldoFinal,
saldoPeriodo,
totalInconsistencias: inconsistenciasPeriodo.length,
saldoInicialFormatado: formatarMinutos(0),
saldoPeriodoFormatado: formatarMinutos(saldoPeriodo),
saldoFinalFormatado: formatarMinutos(saldoFinal),
totalHorasTrabalhadasFormatado: formatarHoras(totalHorasTrabalhadas),
totalHorasEsperadasFormatado: formatarHoras(totalHorasEsperadas),
diferencaTotalFormatado: formatarMinutos(diferencaTotal)
};
return {
dias: diasProcessados,
resumo,
config
};
}