fix attendance edge cases
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 ──
|
||||
|
||||
@@ -137,7 +137,7 @@ export class QueryAttendanceRecordsDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsIn(['present', 'late', 'absent', 'leave'])
|
||||
@IsIn(['present', 'late', 'absent', 'leave', 'pending'])
|
||||
status?: string;
|
||||
|
||||
@IsOptional()
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user