fix(attendance): resolve biometric presence showing as absence, fix timezone offset in processAutoAbsences, deduplicate auto-generated absences, prioritize presence in frontend matching, and sync aula_id

This commit is contained in:
Sidney 2026-05-23 16:20:39 -03:00
parent 2f2dec63d5
commit 9fe6882174
6 changed files with 58 additions and 13 deletions

View File

@ -51,6 +51,7 @@
- [x] **Resolução de Condição de Corrida:** Implementado `await` no update duplo de cobranças do SQL na edição de cobranças do Manager (`Finance.tsx`), assegurando consistência na re-busca de dados. - [x] **Resolução de Condição de Corrida:** Implementado `await` no update duplo de cobranças do SQL na edição de cobranças do Manager (`Finance.tsx`), assegurando consistência na re-busca de dados.
- [x] **Ordem de Exclusão e Notificação (Regra 34):** Removida a exclusão local imediata no endpoint `/api/excluir_cobranca`. A remoção local é delegada ao webhook do Asaas, permitindo que a mensagem de WhatsApp acesse os dados necessários antes da deleção. - [x] **Ordem de Exclusão e Notificação (Regra 34):** Removida a exclusão local imediata no endpoint `/api/excluir_cobranca`. A remoção local é delegada ao webhook do Asaas, permitindo que a mensagem de WhatsApp acesse os dados necessários antes da deleção.
- [x] **Limpeza de Faltas no Reagendamento:** Ajustada a função `handleRescheduleLesson` no `LessonSchedule.tsx` para filtrar e deletar automaticamente do banco de dados/estado todas as faltas geradas de forma automática para uma aula que estava no passado e foi reagendada para o futuro. - [x] **Limpeza de Faltas no Reagendamento:** Ajustada a função `handleRescheduleLesson` no `LessonSchedule.tsx` para filtrar e deletar automaticamente do banco de dados/estado todas as faltas geradas de forma automática para uma aula que estava no passado e foi reagendada para o futuro.
- [x] **Correção das Presenças por Biometria:** Resolvido o bug onde as presenças por biometria apareciam como faltas. Corrigido o fuso horário de comparação do fim da aula no servidor (`processAutoAbsences`), adicionada a rotina de auto-limpeza de faltas duplicadas auto-geradas ao salvar ou confirmar presenças, priorizado a exibição de presenças sobre faltas nas interfaces (Manager e Portal), e garantida a gravação do ID da aula (`aula_id`) na sincronização relacional das frequências.
- [x] **Validação de Build e Git Push:** Confirmada compilação bem-sucedida do frontend/backend e efetuado o push para a branch remota `main` sob autorização do usuário. - [x] **Validação de Build e Git Push:** Confirmada compilação bem-sucedida do frontend/backend e efetuado o push para a branch remota `main` sob autorização do usuário.
## 📋 Próximos Passos ## 📋 Próximos Passos

View File

@ -260,6 +260,15 @@ const AttendanceCapture: React.FC<AttendanceCaptureProps> = ({ data, updateData
String(nowLocal.getMinutes()).padStart(2, '0') + ':' + String(nowLocal.getMinutes()).padStart(2, '0') + ':' +
String(nowLocal.getSeconds()).padStart(2, '0'); String(nowLocal.getSeconds()).padStart(2, '0');
// Limpar qualquer falta auto-gerada para o mesmo aluno nesta mesma aula/dia
const filteredAttendance = (data.attendance || []).filter(a => {
const isAutoAbsence = a.studentId === detectedStudentId &&
a.type === 'absence' &&
(a.autoGenerated || (a.id && a.id.startsWith('auto-abs-'))) &&
(a.lessonId === activeLesson.id || (a.date && a.date.startsWith(activeLesson.date)));
return !isAutoAbsence;
});
const newAttendance: Attendance = { const newAttendance: Attendance = {
id: crypto.randomUUID(), id: crypto.randomUUID(),
studentId: detectedStudentId, studentId: detectedStudentId,
@ -271,7 +280,7 @@ const AttendanceCapture: React.FC<AttendanceCaptureProps> = ({ data, updateData
verified: true verified: true
}; };
const updatedAttendance = [...(data.attendance || []), newAttendance]; const updatedAttendance = [...filteredAttendance, newAttendance];
updateData({ attendance: updatedAttendance }); updateData({ attendance: updatedAttendance });
// Sincronização em duas etapas: Local e Servidor (SQL) // Sincronização em duas etapas: Local e Servidor (SQL)

View File

@ -410,13 +410,17 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
const lessonStart = new Date(lesson.date + 'T' + (lesson.startTime || '00:00') + ':00'); const lessonStart = new Date(lesson.date + 'T' + (lesson.startTime || '00:00') + ':00');
const lessonEnd = new Date(lesson.date + 'T' + (lesson.endTime || '23:59') + ':00'); const lessonEnd = new Date(lesson.date + 'T' + (lesson.endTime || '23:59') + ':00');
const matchedRecord = studentActualRecords.find(a => { const matchingRecords = studentActualRecords.filter(a => {
if ((a as any).lessonId === lesson.id) return true; if ((a as any).lessonId === lesson.id) return true;
if (a.date === `${lesson.date}T${lesson.startTime || '00:00'}:00`) return true; if (a.date === `${lesson.date}T${lesson.startTime || '00:00'}:00`) return true;
const recordTime = new Date(a.date); const recordTime = new Date(a.date);
return recordTime >= lessonStart && recordTime <= lessonEnd; return recordTime >= lessonStart && recordTime <= lessonEnd;
}); });
const matchedRecord = matchingRecords.find(a => a.type === 'presence' || !a.type) ||
matchingRecords.find(a => a.type === 'absence' && a.justificationAccepted) ||
matchingRecords[0];
if (matchedRecord) { if (matchedRecord) {
if (matchedRecord.type === 'absence') { if (matchedRecord.type === 'absence') {
if (matchedRecord.justificationAccepted) justified++; if (matchedRecord.justificationAccepted) justified++;
@ -524,13 +528,17 @@ const AttendanceQuery: React.FC<AttendanceQueryProps> = ({ data, updateData, dee
const lessonStart = new Date(lesson.date + 'T' + (lesson.startTime || '00:00') + ':00'); const lessonStart = new Date(lesson.date + 'T' + (lesson.startTime || '00:00') + ':00');
const lessonEnd = new Date(lesson.date + 'T' + (lesson.endTime || '23:59') + ':00'); const lessonEnd = new Date(lesson.date + 'T' + (lesson.endTime || '23:59') + ':00');
// Regra Estrita: Comparação exata com o horário da aula (sem 30 min de tolerância) // Regra Estrita: Comparação exata com o horário da aula (sem 30 min de tolerância)
let record = actualRecords.find(a => { const matchingRecords = actualRecords.filter(a => {
if ((a as any).lessonId === lesson.id) return true; if ((a as any).lessonId === lesson.id) return true;
if (a.date === `${lesson.date}T${lesson.startTime || '00:00'}:00`) return true; if (a.date === `${lesson.date}T${lesson.startTime || '00:00'}:00`) return true;
const recordTime = new Date(a.date); const recordTime = new Date(a.date);
return recordTime >= lessonStart && recordTime <= lessonEnd; return recordTime >= lessonStart && recordTime <= lessonEnd;
}); });
let record = matchingRecords.find(a => a.type === 'presence' || !a.type) ||
matchingRecords.find(a => a.type === 'absence' && a.justificationAccepted) ||
matchingRecords[0];
if (!record && now >= lessonStart) { if (!record && now >= lessonStart) {
const isFinished = now > lessonEnd; const isFinished = now > lessonEnd;
record = { record = {

View File

@ -42,11 +42,29 @@ export async function processAutoAbsences(data) {
const now = new Date(); const now = new Date();
let updated = false; let updated = false;
// 1. Auto-correção/Autolimpeza: remove faltas auto-geradas duplicadas se o aluno tiver uma presença registrada no mesmo dia/aula
const initialLength = data.attendance.length;
data.attendance = data.attendance.filter(a => {
if (a.type === 'absence' && (a.autoGenerated || (a.id && a.id.startsWith('auto-abs-')))) {
const hasPresence = data.attendance.some(p =>
p.studentId === a.studentId &&
(p.type === 'presence' || !p.type) &&
(p.lessonId === a.lessonId || (p.date && a.date && p.date.substring(0, 10) === a.date.substring(0, 10)))
);
return !hasPresence;
}
return true;
});
if (data.attendance.length !== initialLength) {
updated = true;
}
// Cache de alunos por turma para performance // Cache de alunos por turma para performance
const studentsByClass = {}; const studentsByClass = {};
data.lessons.forEach(lesson => { data.lessons.forEach(lesson => {
const lessonEndStr = `${lesson.date}T${lesson.endTime || '23:59'}:00`; // RESOLUÇÃO DE FUSO HORÁRIO: Força fuso horário América/São_Paulo (-03:00) ao analisar a hora de término da aula no servidor (geralmente UTC)
const lessonEndStr = `${lesson.date}T${lesson.endTime || '23:59'}:00-03:00`;
const lessonEnd = new Date(lessonEndStr); const lessonEnd = new Date(lessonEndStr);
if (now > lessonEnd && lesson.status !== 'cancelled') { if (now > lessonEnd && lesson.status !== 'cancelled') {
@ -537,13 +555,13 @@ export async function syncJsonToRelationalTables() {
for (const f of data.attendance) { for (const f of data.attendance) {
if (!f.id || !f.studentId || !f.classId) continue; if (!f.id || !f.studentId || !f.classId) continue;
await client.query( await client.query(
`INSERT INTO frequencias (id, aluno_id, turma_id, data, foto, verificado, tipo, justificativa, justificativa_aceita) `INSERT INTO frequencias (id, aluno_id, turma_id, aula_id, data, foto, verificado, tipo, justificativa, justificativa_aceita)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
ON CONFLICT (id) DO UPDATE SET ON CONFLICT (id) DO UPDATE SET
aluno_id = EXCLUDED.aluno_id, turma_id = EXCLUDED.turma_id, data = EXCLUDED.data, aluno_id = EXCLUDED.aluno_id, turma_id = EXCLUDED.turma_id, aula_id = EXCLUDED.aula_id, data = EXCLUDED.data,
foto = EXCLUDED.foto, verificado = EXCLUDED.verificado, tipo = EXCLUDED.tipo, foto = EXCLUDED.foto, verificado = EXCLUDED.verificado, tipo = EXCLUDED.tipo,
justificativa = EXCLUDED.justificativa, justificativa_aceita = EXCLUDED.justificativa_aceita`, justificativa = EXCLUDED.justificativa, justificativa_aceita = EXCLUDED.justificativa_aceita`,
[f.id, f.studentId, f.classId, f.date, f.photo || '', f.verified || false, f.type || 'presence', f.justification || null, f.justificationAccepted || false] [f.id, f.studentId, f.classId, f.lessonId || null, f.date, f.photo || '', f.verified || false, f.type || 'presence', f.justification || null, f.justificationAccepted || false]
); );
} }
} }

View File

@ -88,7 +88,7 @@ export default function Dashboard() {
const lessonEnd = new Date(lesson.date + 'T' + (lesson.endTime || '23:59') + ':00'); const lessonEnd = new Date(lesson.date + 'T' + (lesson.endTime || '23:59') + ':00');
const presenceStartWindow = new Date(lessonStart.getTime() - 30 * 60 * 1000); const presenceStartWindow = new Date(lessonStart.getTime() - 30 * 60 * 1000);
const att = data.attendance.find(a => { const matchingAtts = data.attendance.filter(a => {
if (!a.date || typeof a.date !== 'string') return false; if (!a.date || typeof a.date !== 'string') return false;
if ((a as any).lessonId === lesson.id) return true; if ((a as any).lessonId === lesson.id) return true;
if (a.date === `${lesson.date}T${lesson.startTime || '00:00'}:00`) return true; if (a.date === `${lesson.date}T${lesson.startTime || '00:00'}:00`) return true;
@ -96,6 +96,10 @@ export default function Dashboard() {
return recordTime >= presenceStartWindow && recordTime <= lessonEnd; return recordTime >= presenceStartWindow && recordTime <= lessonEnd;
}); });
const att = matchingAtts.find(a => a.type === 'presence' || (a.verified === true && a.type !== 'absence')) ||
matchingAtts.find(a => a.type === 'absence' && a.justificationAccepted) ||
matchingAtts[0];
// Mesma lógica de presença da página de Frequência (exclui type: 'absence') // Mesma lógica de presença da página de Frequência (exclui type: 'absence')
const isPresent = att && (att.type === 'presence' || (att.verified === true && att.type !== 'absence')); const isPresent = att && (att.type === 'presence' || (att.verified === true && att.type !== 'absence'));
if (isPresent) presencesCount++; if (isPresent) presencesCount++;

View File

@ -149,8 +149,8 @@ export default function Frequencia() {
const lessonStartMs = parseLessonDateTime(lesson.date, lesson.startTime || '00:00', 0); const lessonStartMs = parseLessonDateTime(lesson.date, lesson.startTime || '00:00', 0);
const lessonEndMs = parseLessonDateTime(lesson.date, lesson.endTime || '23:59', 23); const lessonEndMs = parseLessonDateTime(lesson.date, lesson.endTime || '23:59', 23);
// Buscar registro com a MESMA lógica do Manager (find, não filter) // Buscar registros com a lógica priorizando presença
let record = attendance.find(a => { const matchingRecords = attendance.filter(a => {
if (!a.date || typeof a.date !== 'string') return false; if (!a.date || typeof a.date !== 'string') return false;
// 1. Match por lessonId (se existir) // 1. Match por lessonId (se existir)
if ((a as any).lessonId === lesson.id) return true; if ((a as any).lessonId === lesson.id) return true;
@ -161,14 +161,19 @@ export default function Frequencia() {
return recordTimeMs >= lessonStartMs && recordTimeMs <= lessonEndMs; return recordTimeMs >= lessonStartMs && recordTimeMs <= lessonEndMs;
}); });
let record = matchingRecords.find(a => a.type === 'presence' || (a.verified === true && a.type !== 'absence')) ||
matchingRecords.find(a => a.type === 'absence' && a.justificationAccepted) ||
matchingRecords[0];
// Se não encontrou registro real, verificar se precisa de justificativa associada // Se não encontrou registro real, verificar se precisa de justificativa associada
if (!record) { if (!record) {
record = attendance.find(a => { const matchingJustifications = attendance.filter(a => {
if (!a.date || typeof a.date !== 'string' || !a.justification) return false; if (!a.date || typeof a.date !== 'string' || !a.justification) return false;
if (a.date === `${lesson.date}T${lesson.startTime || '00:00'}:00`) return true; if (a.date === `${lesson.date}T${lesson.startTime || '00:00'}:00`) return true;
const recordTimeMs = new Date(a.date).getTime(); const recordTimeMs = new Date(a.date).getTime();
return recordTimeMs >= lessonStartMs && recordTimeMs <= lessonEndMs; return recordTimeMs >= lessonStartMs && recordTimeMs <= lessonEndMs;
}) || undefined; });
record = matchingJustifications[0];
} }
const { isInProgress, isCompleted } = getLessonTimeStatus(lesson, now); const { isInProgress, isCompleted } = getLessonTimeStatus(lesson, now);