import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, In, DataSource } from 'typeorm'; import { AttendanceRecord, AttendanceSession, AttendanceDevice, AttendancePeriodConfig, DingAttendanceRaw, Class, Student, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping, } from '../entities'; import { AttendanceQueryService } from './attendance-query.service'; import { AttendanceLessonService } from './attendance-lesson.service'; import { AttendanceGenerationService } from './attendance-generation.service'; import { SessionMutex } from './attendance-mutex'; interface AgentAttendanceSummaryRow { date: string; classId: string | number; className: string; status: string; count: string | number; } /** Keyed mutex serializing operations on the same attendance session. */ @Injectable() export class AttendanceService { constructor( @InjectRepository(AttendanceRecord) private attendanceRepo: Repository, @InjectRepository(DingAttendanceRaw) private dingRawRepo: Repository, @InjectRepository(Class) private classRepo: Repository, @InjectRepository(Student) private studentRepo: Repository, @InjectRepository(ClassSchedule) private scheduleRepo: Repository, @InjectRepository(ClassStudent) private classStudentRepo: Repository, @InjectRepository(StudentDingMapping) private studentDingMappingRepo: Repository, @InjectRepository(ClassTeacher) private classTeacherRepo: Repository, @InjectRepository(AttendanceSession) private attendanceSessionRepo: Repository, @InjectRepository(AttendanceDevice) private attendanceDeviceRepo: Repository, @InjectRepository(AttendancePeriodConfig) private attendancePeriodConfigRepo: Repository, private dataSource: DataSource, ) {} private sessionMutex = new SessionMutex(); private queryService?: AttendanceQueryService; private lessonService?: AttendanceLessonService; private generationService?: AttendanceGenerationService; private get lessons(): AttendanceLessonService { if (!this.lessonService) { this.lessonService = new AttendanceLessonService( this.attendanceRepo, this.dingRawRepo, this.classRepo, this.studentRepo, this.scheduleRepo, this.classStudentRepo, this.studentDingMappingRepo, this.classTeacherRepo, this.attendanceSessionRepo, this.attendanceDeviceRepo, this.dataSource, ); } return this.lessonService; } private get generation(): AttendanceGenerationService { if (!this.generationService) { this.generationService = new AttendanceGenerationService( this.attendanceRepo, this.scheduleRepo, this.classRepo, this.classStudentRepo, this.attendanceSessionRepo, this.attendancePeriodConfigRepo, this.dataSource, ); } return this.generationService; } private get queries(): AttendanceQueryService { if (!this.queryService) { this.queryService = new AttendanceQueryService( this.attendanceRepo, this.dingRawRepo, this.classRepo, this.scheduleRepo, this.classStudentRepo, this.studentDingMappingRepo, this.classTeacherRepo, this.attendanceDeviceRepo, this.dataSource, ); } return this.queryService; } async getAccessibleClassIds(userId: number, canManageAll = false): Promise { if (canManageAll) return undefined; const assignments = await this.classTeacherRepo.find({ where: { userId } }); return [...new Set(assignments.map((assignment) => assignment.classId))]; } async assertClassAccess(userId: number, classId: number, canManageAll = false): Promise { if (canManageAll) return; const assignment = await this.classTeacherRepo.findOne({ where: { userId, classId } }); if (!assignment) throw new BadRequestException('只能访问自己任教班级的考勤'); } async agentGetAttendanceSummary( userId: number, canManageAll: boolean, query: { classId?: number; dateFrom?: string; dateTo?: string; limit?: number }, ) { const accessibleClassIds = await this.getAccessibleClassIds(userId, canManageAll); if (accessibleClassIds?.length === 0) return []; if (query.classId && accessibleClassIds && !accessibleClassIds.includes(query.classId)) return []; const qb = this.attendanceRepo .createQueryBuilder('attendance') .leftJoin('attendance.class', 'class') .select('attendance.attendanceDate', 'date') .addSelect('attendance.classId', 'classId') .addSelect('class.name', 'className') .addSelect('attendance.status', 'status') .addSelect('COUNT(attendance.id)', 'count') .where('attendance.classId IS NOT NULL'); if (query.classId) qb.andWhere('attendance.classId = :classId', { classId: query.classId }); else if (accessibleClassIds) qb.andWhere('attendance.classId IN (:...accessibleClassIds)', { accessibleClassIds }); if (query.dateFrom) qb.andWhere('attendance.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom }); if (query.dateTo) qb.andWhere('attendance.attendanceDate <= :dateTo', { dateTo: query.dateTo }); const rows = await qb .groupBy('attendance.attendanceDate') .addGroupBy('attendance.classId') .addGroupBy('class.name') .addGroupBy('attendance.status') .orderBy('attendance.attendanceDate', 'DESC') .addOrderBy('class.name', 'ASC') .limit(query.limit ?? 30) .getRawMany(); return rows.map((row) => ({ ...row, classId: Number(row.classId), count: Number(row.count || 0) })); } async getImportableClasses(userId: number, isSuperAdmin = false) { if (isSuperAdmin) { const classes = await this.classRepo.find({ where: { isArchived: false }, order: { name: 'ASC' }, }); return classes.map((item) => ({ classId: item.id, className: item.name })); } const assignments = await this.classTeacherRepo.find({ where: { userId }, relations: ['class'], }); const classes = new Map(); for (const assignment of assignments) { if (assignment.class && !assignment.class.isArchived) { classes.set(assignment.classId, assignment.class.name); } } return [...classes.entries()] .map(([classId, className]) => ({ classId, className })) .sort((left, right) => left.className.localeCompare(right.className, 'zh-CN')); } /** Resolve the DingTalk users a teacher may import for one assigned class. */ async getTeacherClassDingUserIds( userId: number, classId: number, isSuperAdmin = false, lessonDate?: string, ): Promise { if (!isSuperAdmin) { const assignment = await this.classTeacherRepo.findOne({ where: { userId, classId }, }); if (!assignment) { throw new BadRequestException('只能拉取自己任教班级的考勤记录'); } } else { const cls = await this.classRepo.findOne({ where: { id: classId } }); if (!cls) throw new NotFoundException(`Class ${classId} not found`); } const classStudents = lessonDate ? await this.lessons.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('该班级暂无在读学生'); } const mappings = await this.studentDingMappingRepo.find({ where: { studentId: In(studentIds) }, }); const userIds = [...new Set(mappings.map((mapping) => mapping.dingUserId).filter(Boolean))]; if (userIds.length === 0) { throw new BadRequestException('该班级学生尚未同步钉钉账号'); } return userIds.sort(); } async getSummary(...args: Parameters) { return this.queries.getSummary(...args); } async getCalendar(...args: Parameters) { return this.queries.getCalendar(...args); } async getScheduleOptionsForAttendance( ...args: Parameters ) { return this.queries.getScheduleOptionsForAttendance(...args); } async findAll(...args: Parameters) { return this.queries.findAll(...args); } async getClasses(...args: Parameters) { return this.queries.getClasses(...args); } async getDingRaw(...args: Parameters) { return this.queries.getDingRaw(...args); } async matchDingRecord(...args: Parameters) { return this.queries.matchDingRecord(...args); } async autoMatchDingRecords(): Promise<{ matched: number; total: number }> { return this.queries.autoMatchDingRecords(); } async findAllForExport(...args: Parameters) { return this.queries.findAllForExport(...args); } async findAttendanceRecord(...args: Parameters) { return this.queries.findAttendanceRecord(...args); } async update(...args: Parameters) { return this.queries.update(...args); } async remove(...args: Parameters) { return this.queries.remove(...args); } async getReport(...args: Parameters) { return this.queries.getReport(...args); } async getAlerts(...args: Parameters) { return this.queries.getAlerts(...args); } async getLessonAttendance(...args: Parameters) { return this.lessons.getLessonAttendance(...args); } getLessonAttendanceImportDateRange( ...args: Parameters ) { return this.lessons.getLessonAttendanceImportDateRange(...args); } async createLessonAttendanceFromDingTalk( ...args: Parameters ) { return this.lessons.createLessonAttendanceFromDingTalk(...args); } async completeLessonAttendance( ...args: Parameters ) { return this.lessons.completeLessonAttendance(...args); } async findAttendanceSession( ...args: Parameters ) { return this.lessons.findAttendanceSession(...args); } async getRefreshableSchedules( ...args: Parameters ) { return this.generation.getRefreshableSchedules(...args); } async batchCreate(...args: Parameters) { return this.generation.batchCreate(...args); } async generateFromSchedules( ...args: Parameters ) { return this.generation.generateFromSchedules(...args); } async getAttendancePeriodConfigs( ...args: Parameters ) { return this.generation.getAttendancePeriodConfigs(...args); } async saveAttendancePeriodConfigs( ...args: Parameters ) { return this.generation.saveAttendancePeriodConfigs(...args); } async resetAttendancePeriodConfigs( ...args: Parameters ) { return this.generation.resetAttendancePeriodConfigs(...args); } }