diff --git a/apps/server/src/attendance/attendance-settlement.service.ts b/apps/server/src/attendance/attendance-settlement.service.ts index 0c15735..d4393aa 100644 --- a/apps/server/src/attendance/attendance-settlement.service.ts +++ b/apps/server/src/attendance/attendance-settlement.service.ts @@ -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, diff --git a/apps/server/src/attendance/attendance.controller.ts b/apps/server/src/attendance/attendance.controller.ts index 6f04a1e..c009fbb 100644 --- a/apps/server/src/attendance/attendance.controller.ts +++ b/apps/server/src/attendance/attendance.controller.ts @@ -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, ); } diff --git a/apps/server/src/attendance/attendance.service.ts b/apps/server/src/attendance/attendance.service.ts index f0c24bc..7e18789 100644 --- a/apps/server/src/attendance/attendance.service.ts +++ b/apps/server/src/attendance/attendance.service.ts @@ -144,6 +144,28 @@ export class AttendanceService { if (!assignment) throw new BadRequestException('只能访问自己任教班级的考勤'); } + private isClassStudentActiveOnDate(classStudent: Pick, 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 { + 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 { 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, lessonDate: string, ): Promise> { - 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 ── diff --git a/apps/server/src/attendance/dto/attendance.dto.ts b/apps/server/src/attendance/dto/attendance.dto.ts index 22a01c8..f3cee39 100644 --- a/apps/server/src/attendance/dto/attendance.dto.ts +++ b/apps/server/src/attendance/dto/attendance.dto.ts @@ -137,7 +137,7 @@ export class QueryAttendanceRecordsDto { @IsOptional() @IsString() - @IsIn(['present', 'late', 'absent', 'leave']) + @IsIn(['present', 'late', 'absent', 'leave', 'pending']) status?: string; @IsOptional() diff --git a/apps/server/src/rbac/rbac.service.ts b/apps/server/src/rbac/rbac.service.ts index 6187a8e..a76dd6a 100644 --- a/apps/server/src/rbac/rbac.service.ts +++ b/apps/server/src/rbac/rbac.service.ts @@ -143,6 +143,26 @@ const DEPRECATED_PERMISSION_CODES = [ const DEPRECATED_PERMISSION_CODE_SET = new Set(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 = { 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