import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, In, DataSource } from 'typeorm'; import { AttendanceRecord, DingAttendanceRaw, Class, ClassSchedule, ClassStudent, StudentDingMapping, ClassTeacher, AttendanceDevice, TeacherRoleType, } from '../entities'; import type { AttendanceSummaryQueryDto, QueryDingRawDto, } from './dto/attendance.dto'; import { attachAttendanceDeviceMappings } from './attendance-device'; import { AttendanceCalendarService } from './attendance-calendar.service'; import { AttendanceRecordMutationService } from './attendance-record-mutation.service'; import { AttendanceReportService } from './attendance-report.service'; @Injectable() export class AttendanceQueryService { constructor( @InjectRepository(AttendanceRecord) private attendanceRepo: Repository, @InjectRepository(DingAttendanceRaw) private dingRawRepo: Repository, @InjectRepository(Class) private classRepo: Repository, @InjectRepository(ClassSchedule) private scheduleRepo: Repository, @InjectRepository(ClassStudent) private classStudentRepo: Repository, @InjectRepository(StudentDingMapping) private studentDingMappingRepo: Repository, @InjectRepository(ClassTeacher) private classTeacherRepo: Repository, @InjectRepository(AttendanceDevice) private attendanceDeviceRepo: Repository, private dataSource: DataSource, ) {} private calendarService?: AttendanceCalendarService; private mutationService?: AttendanceRecordMutationService; private reportService?: AttendanceReportService; private get calendar(): AttendanceCalendarService { if (!this.calendarService) { this.calendarService = new AttendanceCalendarService(this.attendanceRepo, this.scheduleRepo); } return this.calendarService; } private get mutations(): AttendanceRecordMutationService { if (!this.mutationService) { this.mutationService = new AttendanceRecordMutationService( this.attendanceRepo, this.dingRawRepo, this.studentDingMappingRepo, this.dataSource, ); } return this.mutationService; } private get reports(): AttendanceReportService { if (!this.reportService) { this.reportService = new AttendanceReportService( this.attendanceRepo, this.attendanceDeviceRepo, ); } return this.reportService; } async getSummary(query: AttendanceSummaryQueryDto, accessibleClassIds?: number[]) { const qb = this.attendanceRepo.createQueryBuilder('ar'); if (query.classId) { qb.andWhere('ar.classId = :classId', { classId: query.classId }); } if (query.scheduleId) { qb.andWhere('ar.scheduleId = :scheduleId', { scheduleId: query.scheduleId }); } if (!query.classId && accessibleClassIds) { if (accessibleClassIds.length === 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) { qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom }); } if (query.dateTo) { qb.andWhere('ar.attendanceDate <= :dateTo', { dateTo: query.dateTo }); } if (query.session) { qb.andWhere('ar.session = :session', { session: query.session }); } const rows = await qb.getMany(); const total = rows.length; const present = rows.filter((r) => r.status === 'present').length; 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, pending, presentRate }; } // ── Attendance calendar ── async getCalendar(...args: Parameters) { return this.calendar.getCalendar(...args); } async getScheduleOptionsForAttendance( ...args: Parameters ) { return this.calendar.getScheduleOptionsForAttendance(...args); } // ── List attendance records with filters ── async findAll( query: { classId?: number; scheduleId?: number; dateFrom?: string; dateTo?: string; session?: string; status?: string; source?: string; page?: number; pageSize?: number; }, accessibleClassIds?: number[], ) { const page = Math.max(1, Math.floor(Number(query.page) || 1)); const pageSize = Math.min(200, Math.max(1, Math.floor(Number(query.pageSize) || 20))); const qb = this.attendanceRepo.createQueryBuilder('ar'); qb.leftJoinAndSelect('ar.student', 'student').leftJoinAndSelect('ar.class', 'class'); if (query.scheduleId) { qb.andWhere('ar.scheduleId = :scheduleId', { scheduleId: query.scheduleId }); } if (query.classId) { qb.andWhere('ar.classId = :classId', { classId: query.classId }); } else if (accessibleClassIds) { if (accessibleClassIds.length === 0) return { list: [], total: 0, page, pageSize }; qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds }); } if (query.dateFrom) { qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom }); } if (query.dateTo) { qb.andWhere('ar.attendanceDate <= :dateTo', { dateTo: query.dateTo }); } if (query.session) { qb.andWhere('ar.session = :session', { session: query.session }); } if (query.status) { qb.andWhere('ar.status = :status', { status: query.status }); } if (query.source) { qb.andWhere('ar.source = :source', { source: query.source }); } qb.orderBy('ar.attendanceDate', 'DESC').addOrderBy('ar.createdAt', 'DESC'); qb.skip((page - 1) * pageSize).take(pageSize); const [list, total] = await qb.getManyAndCount(); return { list: await attachAttendanceDeviceMappings(list, this.attendanceDeviceRepo), total, page, pageSize }; } // ── Get distinct classes with attendance records ── async getClasses(accessibleClassIds?: number[]) { const qb = this.attendanceRepo .createQueryBuilder('ar') .select('DISTINCT ar.classId', 'classId') .where('ar.classId IS NOT NULL'); const rows: Array<{ classId: string | number }> = accessibleClassIds ? accessibleClassIds.map((classId) => ({ classId })) : await qb.orderBy('ar.classId', 'ASC').getRawMany(); const classIds = [...new Set(rows.map((r) => Number(r.classId)).filter(Boolean))]; if (classIds.length === 0) return []; const where = { id: In(classIds) }; const [classes, teachers] = await Promise.all([ this.classRepo.find({ where }), this.classTeacherRepo.find({ where: { classId: In(classIds), roleType: In([ TeacherRoleType.HEAD_TEACHER, TeacherRoleType.LIFE_TEACHER, TeacherRoleType.SUBJECT_TEACHER, ]), }, relations: ['user'], order: { roleType: 'ASC', id: 'ASC' }, }), ]); const nameMap = new Map(classes.map((c) => [c.id, c.name])); const teacherMap = new Map< number, Array<{ userId: number; username: string | null; name: string | null; roleType: string; subject: string | null; }> >(); for (const teacher of teachers) { const user = teacher.user as { username?: string | null; name?: string | null } | undefined; const items = teacherMap.get(teacher.classId) ?? []; items.push({ userId: teacher.userId, username: user?.username || null, name: user?.name || null, roleType: teacher.roleType, subject: teacher.subject || null, }); teacherMap.set(teacher.classId, items); } return classIds.map((id) => ({ classId: id, className: nameMap.get(id) || `班级${id}`, teachers: teacherMap.get(id) ?? [], })); } // ── DingAttendance raw records ── async getDingRaw(query: QueryDingRawDto, accessibleClassIds?: number[]) { const page = Math.max(1, Math.floor(Number(query.page) || 1)); const pageSize = Math.min(200, Math.max(1, Math.floor(Number(query.pageSize) || 20))); const qb = this.dingRawRepo.createQueryBuilder('ar'); qb.leftJoinAndSelect('ar.matchedStudent', 'matchedStudent'); if (query.matchStatus) { qb.andWhere('ar.matchStatus = :matchStatus', { matchStatus: query.matchStatus }); } if (query.dateFrom) { qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom }); } if (query.dateTo) { qb.andWhere('ar.attendanceDate <= :dateTo', { dateTo: query.dateTo }); } const scopedClassIds = query.classId ? [query.classId] : accessibleClassIds; if (scopedClassIds) { if (scopedClassIds.length === 0) return { list: [], total: 0, page, pageSize }; const classStudents = await this.classStudentRepo.find({ where: { classId: In(scopedClassIds), status: 'active' }, }); const studentIds = [...new Set(classStudents.map((item) => item.studentId))]; if (studentIds.length === 0) return { list: [], total: 0, page, pageSize }; const mappings = await this.studentDingMappingRepo.find({ where: { studentId: In(studentIds) }, }); const dingUserIds = [ ...new Set(mappings.map((mapping) => mapping.dingUserId).filter(Boolean)), ]; if (dingUserIds.length === 0) return { list: [], total: 0, page, pageSize }; qb.andWhere('ar.dingUserId IN (:...dingUserIds)', { dingUserIds }); } qb.orderBy('ar.attendanceDate', 'DESC') .addOrderBy('ar.checkInTime', 'ASC') .skip((page - 1) * pageSize) .take(pageSize); const [list, total] = await qb.getManyAndCount(); return { list, total, page, pageSize }; } // ── Match a dingtalk record to a student ── async matchDingRecord(...args: Parameters) { return this.mutations.matchDingRecord(...args); } // ── Auto-match unmatched dingtalk records via dingUserId → userId mapping chain ── async autoMatchDingRecords(): Promise<{ matched: number; total: number }> { return this.mutations.autoMatchDingRecords(); } // ── Export all attendance records with filters (no pagination) ── async findAllForExport( ...args: Parameters ) { return this.reports.findAllForExport(...args); } async findAttendanceRecord( ...args: Parameters ) { return this.mutations.findAttendanceRecord(...args); } // ── Update a single attendance record ── async update(...args: Parameters) { return this.mutations.update(...args); } // ── Delete a single attendance record ── async remove(...args: Parameters) { return this.mutations.remove(...args); } // ── Class-based attendance report ── async getReport(...args: Parameters) { return this.reports.getReport(...args); } // ── Attendance alerts: detect consecutive absences/late ── async getAlerts(...args: Parameters) { return this.reports.getAlerts(...args); } }