fix attendance edge cases
This commit is contained in:
@@ -113,6 +113,8 @@ export class AttendanceSettlementService {
|
|||||||
const userIds = await this.attendanceService.getTeacherClassDingUserIds(
|
const userIds = await this.attendanceService.getTeacherClassDingUserIds(
|
||||||
schedule.teacherId,
|
schedule.teacherId,
|
||||||
schedule.classId,
|
schedule.classId,
|
||||||
|
false,
|
||||||
|
lessonDate,
|
||||||
);
|
);
|
||||||
const importRange = this.attendanceService.getLessonAttendanceImportDateRange(
|
const importRange = this.attendanceService.getLessonAttendanceImportDateRange(
|
||||||
schedule,
|
schedule,
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ export class AttendanceController {
|
|||||||
req.user.id,
|
req.user.id,
|
||||||
schedule.schedule.classId!,
|
schedule.schedule.classId!,
|
||||||
this.canManageAllAttendance(req),
|
this.canManageAllAttendance(req),
|
||||||
|
dto.date,
|
||||||
);
|
);
|
||||||
const importRange = this.service.getLessonAttendanceImportDateRange(
|
const importRange = this.service.getLessonAttendanceImportDateRange(
|
||||||
schedule.schedule,
|
schedule.schedule,
|
||||||
@@ -128,6 +129,9 @@ export class AttendanceController {
|
|||||||
autoMatch: true,
|
autoMatch: true,
|
||||||
userId: req.user.id,
|
userId: req.user.id,
|
||||||
});
|
});
|
||||||
|
if (!importResult.success || importResult.errors.length > 0) {
|
||||||
|
throw new BadRequestException(importResult.errors.join('; ') || '钉钉考勤拉取失败');
|
||||||
|
}
|
||||||
const result = await this.service.createLessonAttendanceFromDingTalk(
|
const result = await this.service.createLessonAttendanceFromDingTalk(
|
||||||
scheduleId,
|
scheduleId,
|
||||||
dto.date,
|
dto.date,
|
||||||
@@ -522,6 +526,7 @@ export class AttendanceController {
|
|||||||
req.user.id,
|
req.user.id,
|
||||||
dto.classId,
|
dto.classId,
|
||||||
canManageAll,
|
canManageAll,
|
||||||
|
dto.start,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -144,6 +144,28 @@ export class AttendanceService {
|
|||||||
if (!assignment) throw new BadRequestException('只能访问自己任教班级的考勤');
|
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. */
|
/** List classes the current user may select for DingTalk attendance import. */
|
||||||
async getImportableClasses(userId: number, isSuperAdmin = false) {
|
async getImportableClasses(userId: number, isSuperAdmin = false) {
|
||||||
if (isSuperAdmin) {
|
if (isSuperAdmin) {
|
||||||
@@ -174,6 +196,7 @@ export class AttendanceService {
|
|||||||
userId: number,
|
userId: number,
|
||||||
classId: number,
|
classId: number,
|
||||||
isSuperAdmin = false,
|
isSuperAdmin = false,
|
||||||
|
lessonDate?: string,
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
if (!isSuperAdmin) {
|
if (!isSuperAdmin) {
|
||||||
const assignment = await this.classTeacherRepo.findOne({
|
const assignment = await this.classTeacherRepo.findOne({
|
||||||
@@ -187,9 +210,11 @@ export class AttendanceService {
|
|||||||
if (!cls) throw new NotFoundException(`Class ${classId} not found`);
|
if (!cls) throw new NotFoundException(`Class ${classId} not found`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const classStudents = await this.classStudentRepo.find({
|
const classStudents = lessonDate
|
||||||
where: { classId, status: 'active' },
|
? await this.getClassStudentsForLesson(classId, lessonDate)
|
||||||
});
|
: await this.classStudentRepo.find({
|
||||||
|
where: { classId, status: 'active' },
|
||||||
|
});
|
||||||
const studentIds = [...new Set(classStudents.map((item) => item.studentId))];
|
const studentIds = [...new Set(classStudents.map((item) => item.studentId))];
|
||||||
if (studentIds.length === 0) {
|
if (studentIds.length === 0) {
|
||||||
throw new BadRequestException('该班级暂无在读学生');
|
throw new BadRequestException('该班级暂无在读学生');
|
||||||
@@ -375,10 +400,11 @@ export class AttendanceService {
|
|||||||
where: { attendanceSessionId: existing.id },
|
where: { attendanceSessionId: existing.id },
|
||||||
order: { studentId: 'ASC' },
|
order: { studentId: 'ASC' },
|
||||||
});
|
});
|
||||||
const classStudents = await this.classStudentRepo.find({
|
const classStudents = await this.getClassStudentsForLesson(
|
||||||
where: { classId: schedule.classId!, status: 'active' },
|
schedule.classId!,
|
||||||
relations: ['student'],
|
lessonDate,
|
||||||
});
|
['student'],
|
||||||
|
);
|
||||||
const studentsById = new Map(
|
const studentsById = new Map(
|
||||||
classStudents.map((classStudent) => [classStudent.studentId, classStudent.student]),
|
classStudents.map((classStudent) => [classStudent.studentId, classStudent.student]),
|
||||||
);
|
);
|
||||||
@@ -456,10 +482,11 @@ export class AttendanceService {
|
|||||||
const recordRepo = manager.getRepository(AttendanceRecord);
|
const recordRepo = manager.getRepository(AttendanceRecord);
|
||||||
const rawByStudent = await this.fetchDingTalkRawByStudent(schedule.classId!, schedule, lessonDate);
|
const rawByStudent = await this.fetchDingTalkRawByStudent(schedule.classId!, schedule, lessonDate);
|
||||||
|
|
||||||
const classStudents = await this.classStudentRepo.find({
|
const classStudents = await this.getClassStudentsForLesson(
|
||||||
where: { classId: schedule.classId!, status: 'active' },
|
schedule.classId!,
|
||||||
relations: ['student'],
|
lessonDate,
|
||||||
});
|
['student'],
|
||||||
|
);
|
||||||
if (classStudents.length === 0) throw new BadRequestException('该班级暂无在读学生');
|
if (classStudents.length === 0) throw new BadRequestException('该班级暂无在读学生');
|
||||||
|
|
||||||
let session: AttendanceSession;
|
let session: AttendanceSession;
|
||||||
@@ -539,9 +566,7 @@ export class AttendanceService {
|
|||||||
schedule: Pick<ClassSchedule, 'startTime' | 'endTime' | 'attendanceAdvanceMinutes'>,
|
schedule: Pick<ClassSchedule, 'startTime' | 'endTime' | 'attendanceAdvanceMinutes'>,
|
||||||
lessonDate: string,
|
lessonDate: string,
|
||||||
): Promise<Map<number, DingAttendanceRaw[]>> {
|
): Promise<Map<number, DingAttendanceRaw[]>> {
|
||||||
const classStudents = await this.classStudentRepo.find({
|
const classStudents = await this.getClassStudentsForLesson(classId, lessonDate);
|
||||||
where: { classId, status: 'active' },
|
|
||||||
});
|
|
||||||
if (classStudents.length === 0) return new Map();
|
if (classStudents.length === 0) return new Map();
|
||||||
const studentIds = classStudents.map((cs) => cs.studentId);
|
const studentIds = classStudents.map((cs) => cs.studentId);
|
||||||
const window = this.getLessonAttendanceWindow(schedule, lessonDate);
|
const window = this.getLessonAttendanceWindow(schedule, lessonDate);
|
||||||
@@ -654,7 +679,7 @@ export class AttendanceService {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const classStudents = await this.classStudentRepo.find({
|
const classStudents = await this.classStudentRepo.find({
|
||||||
where: { classId, status: 'active' },
|
where: { classId, status: In(['active', 'left']) },
|
||||||
relations: ['student'],
|
relations: ['student'],
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -680,7 +705,10 @@ export class AttendanceService {
|
|||||||
if (dateStr < sched.startDate || dateStr > sched.endDate) continue;
|
if (dateStr < sched.startDate || dateStr > sched.endDate) continue;
|
||||||
|
|
||||||
const session = this.mapScheduleTimeToSession(sched.startTime);
|
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}`;
|
const key = `${cs.studentId}|${dateStr}|${session}`;
|
||||||
if (existingKeys.has(key)) continue;
|
if (existingKeys.has(key)) continue;
|
||||||
|
|
||||||
@@ -771,7 +799,7 @@ export class AttendanceService {
|
|||||||
qb.andWhere('ar.classId = :classId', { classId: query.classId });
|
qb.andWhere('ar.classId = :classId', { classId: query.classId });
|
||||||
} else if (accessibleClassIds) {
|
} else if (accessibleClassIds) {
|
||||||
if (accessibleClassIds.length === 0)
|
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 });
|
qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds });
|
||||||
}
|
}
|
||||||
if (query.dateFrom) {
|
if (query.dateFrom) {
|
||||||
@@ -788,9 +816,10 @@ export class AttendanceService {
|
|||||||
const late = rows.filter((r) => r.status === 'late').length;
|
const late = rows.filter((r) => r.status === 'late').length;
|
||||||
const absent = rows.filter((r) => r.status === 'absent').length;
|
const absent = rows.filter((r) => r.status === 'absent').length;
|
||||||
const leave = rows.filter((r) => r.status === 'leave').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;
|
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 ──
|
// ── Attendance calendar ──
|
||||||
|
|||||||
@@ -137,7 +137,7 @@ export class QueryAttendanceRecordsDto {
|
|||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@IsIn(['present', 'late', 'absent', 'leave'])
|
@IsIn(['present', 'late', 'absent', 'leave', 'pending'])
|
||||||
status?: string;
|
status?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
|
|||||||
@@ -143,6 +143,26 @@ const DEPRECATED_PERMISSION_CODES = [
|
|||||||
|
|
||||||
const DEPRECATED_PERMISSION_CODE_SET = new Set<string>(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<{
|
export const PRESET_ROLES: Array<{
|
||||||
name: string;
|
name: string;
|
||||||
code: string;
|
code: string;
|
||||||
@@ -684,11 +704,8 @@ export class RbacService {
|
|||||||
subject: t.subject,
|
subject: t.subject,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Get today's day of week (1=Monday, 7=Sunday)
|
// Get today's China business date and day of week (1=Monday, 7=Sunday)
|
||||||
const today = new Date();
|
const { date: todayStr, weekDay: adjustedWeekDay } = getChinaDateParts();
|
||||||
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 schedules for assigned classes
|
// Get today's schedules for assigned classes
|
||||||
const todaySchedules = await this.classScheduleRepo
|
const todaySchedules = await this.classScheduleRepo
|
||||||
|
|||||||
Reference in New Issue
Block a user