fix attendance edge cases

This commit is contained in:
2026-07-18 16:28:39 +08:00
parent 92d303ed01
commit 7f66e9b894
5 changed files with 77 additions and 24 deletions

View File

@@ -113,6 +113,8 @@ export class AttendanceSettlementService {
const userIds = await this.attendanceService.getTeacherClassDingUserIds(
schedule.teacherId,
schedule.classId,
false,
lessonDate,
);
const importRange = this.attendanceService.getLessonAttendanceImportDateRange(
schedule,

View File

@@ -117,6 +117,7 @@ export class AttendanceController {
req.user.id,
schedule.schedule.classId!,
this.canManageAllAttendance(req),
dto.date,
);
const importRange = this.service.getLessonAttendanceImportDateRange(
schedule.schedule,
@@ -128,6 +129,9 @@ export class AttendanceController {
autoMatch: true,
userId: req.user.id,
});
if (!importResult.success || importResult.errors.length > 0) {
throw new BadRequestException(importResult.errors.join('; ') || '钉钉考勤拉取失败');
}
const result = await this.service.createLessonAttendanceFromDingTalk(
scheduleId,
dto.date,
@@ -522,6 +526,7 @@ export class AttendanceController {
req.user.id,
dto.classId,
canManageAll,
dto.start,
);
}

View File

@@ -144,6 +144,28 @@ export class AttendanceService {
if (!assignment) throw new BadRequestException('只能访问自己任教班级的考勤');
}
private isClassStudentActiveOnDate(classStudent: Pick<ClassStudent, 'joinDate' | 'leaveDate' | 'status'>, lessonDate: string): boolean {
const status = classStudent.status ?? 'active';
if (!['active', 'left'].includes(status)) return false;
if (classStudent.joinDate && classStudent.joinDate > lessonDate) return false;
if (classStudent.leaveDate && classStudent.leaveDate < lessonDate) return false;
return true;
}
private async getClassStudentsForLesson(
classId: number,
lessonDate: string,
relations: string[] = [],
): Promise<ClassStudent[]> {
const classStudents = await this.classStudentRepo.find({
where: { classId, status: In(['active', 'left']) },
relations,
});
return classStudents.filter((classStudent) =>
this.isClassStudentActiveOnDate(classStudent, lessonDate),
);
}
/** List classes the current user may select for DingTalk attendance import. */
async getImportableClasses(userId: number, isSuperAdmin = false) {
if (isSuperAdmin) {
@@ -174,6 +196,7 @@ export class AttendanceService {
userId: number,
classId: number,
isSuperAdmin = false,
lessonDate?: string,
): Promise<string[]> {
if (!isSuperAdmin) {
const assignment = await this.classTeacherRepo.findOne({
@@ -187,9 +210,11 @@ export class AttendanceService {
if (!cls) throw new NotFoundException(`Class ${classId} not found`);
}
const classStudents = await this.classStudentRepo.find({
where: { classId, status: 'active' },
});
const classStudents = lessonDate
? await this.getClassStudentsForLesson(classId, lessonDate)
: await this.classStudentRepo.find({
where: { classId, status: 'active' },
});
const studentIds = [...new Set(classStudents.map((item) => item.studentId))];
if (studentIds.length === 0) {
throw new BadRequestException('该班级暂无在读学生');
@@ -375,10 +400,11 @@ export class AttendanceService {
where: { attendanceSessionId: existing.id },
order: { studentId: 'ASC' },
});
const classStudents = await this.classStudentRepo.find({
where: { classId: schedule.classId!, status: 'active' },
relations: ['student'],
});
const classStudents = await this.getClassStudentsForLesson(
schedule.classId!,
lessonDate,
['student'],
);
const studentsById = new Map(
classStudents.map((classStudent) => [classStudent.studentId, classStudent.student]),
);
@@ -456,10 +482,11 @@ export class AttendanceService {
const recordRepo = manager.getRepository(AttendanceRecord);
const rawByStudent = await this.fetchDingTalkRawByStudent(schedule.classId!, schedule, lessonDate);
const classStudents = await this.classStudentRepo.find({
where: { classId: schedule.classId!, status: 'active' },
relations: ['student'],
});
const classStudents = await this.getClassStudentsForLesson(
schedule.classId!,
lessonDate,
['student'],
);
if (classStudents.length === 0) throw new BadRequestException('该班级暂无在读学生');
let session: AttendanceSession;
@@ -539,9 +566,7 @@ export class AttendanceService {
schedule: Pick<ClassSchedule, 'startTime' | 'endTime' | 'attendanceAdvanceMinutes'>,
lessonDate: string,
): Promise<Map<number, DingAttendanceRaw[]>> {
const classStudents = await this.classStudentRepo.find({
where: { classId, status: 'active' },
});
const classStudents = await this.getClassStudentsForLesson(classId, lessonDate);
if (classStudents.length === 0) return new Map();
const studentIds = classStudents.map((cs) => cs.studentId);
const window = this.getLessonAttendanceWindow(schedule, lessonDate);
@@ -654,7 +679,7 @@ export class AttendanceService {
});
const classStudents = await this.classStudentRepo.find({
where: { classId, status: 'active' },
where: { classId, status: In(['active', 'left']) },
relations: ['student'],
});
@@ -680,7 +705,10 @@ export class AttendanceService {
if (dateStr < sched.startDate || dateStr > sched.endDate) continue;
const session = this.mapScheduleTimeToSession(sched.startTime);
for (const cs of classStudents) {
const classStudentsForDate = classStudents.filter((cs) =>
this.isClassStudentActiveOnDate(cs, dateStr),
);
for (const cs of classStudentsForDate) {
const key = `${cs.studentId}|${dateStr}|${session}`;
if (existingKeys.has(key)) continue;
@@ -771,7 +799,7 @@ export class AttendanceService {
qb.andWhere('ar.classId = :classId', { classId: query.classId });
} else if (accessibleClassIds) {
if (accessibleClassIds.length === 0)
return { total: 0, present: 0, late: 0, absent: 0, leave: 0, presentRate: 0 };
return { total: 0, present: 0, late: 0, absent: 0, leave: 0, pending: 0, presentRate: 0 };
qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds });
}
if (query.dateFrom) {
@@ -788,9 +816,10 @@ export class AttendanceService {
const late = rows.filter((r) => r.status === 'late').length;
const absent = rows.filter((r) => r.status === 'absent').length;
const leave = rows.filter((r) => r.status === 'leave').length;
const pending = rows.filter((r) => r.status === 'pending').length;
const presentRate = total > 0 ? Number(((present / total) * 100).toFixed(1)) : 0;
return { total, present, late, absent, leave, presentRate };
return { total, present, late, absent, leave, pending, presentRate };
}
// ── Attendance calendar ──

View File

@@ -137,7 +137,7 @@ export class QueryAttendanceRecordsDto {
@IsOptional()
@IsString()
@IsIn(['present', 'late', 'absent', 'leave'])
@IsIn(['present', 'late', 'absent', 'leave', 'pending'])
status?: string;
@IsOptional()

View File

@@ -143,6 +143,26 @@ const DEPRECATED_PERMISSION_CODES = [
const DEPRECATED_PERMISSION_CODE_SET = new Set<string>(DEPRECATED_PERMISSION_CODES);
function getChinaDateParts(date = new Date()): { date: string; weekDay: number } {
const parts = Object.fromEntries(
new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
weekday: 'short',
})
.formatToParts(date)
.filter((part) => part.type !== 'literal')
.map((part) => [part.type, part.value]),
);
const weekDays: Record<string, number> = { Mon: 1, Tue: 2, Wed: 3, Thu: 4, Fri: 5, Sat: 6, Sun: 7 };
return {
date: `${parts.year}-${parts.month}-${parts.day}`,
weekDay: weekDays[parts.weekday],
};
}
export const PRESET_ROLES: Array<{
name: string;
code: string;
@@ -684,11 +704,8 @@ export class RbacService {
subject: t.subject,
}));
// Get today's day of week (1=Monday, 7=Sunday)
const today = new Date();
const weekDay = today.getDay(); // 0=Sun → convert to 1-7
const adjustedWeekDay = weekDay === 0 ? 7 : weekDay;
const todayStr = today.toISOString().slice(0, 10);
// Get today's China business date and day of week (1=Monday, 7=Sunday)
const { date: todayStr, weekDay: adjustedWeekDay } = getChinaDateParts();
// Get today's schedules for assigned classes
const todaySchedules = await this.classScheduleRepo