import { Injectable, NotFoundException, BadRequestException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, In, Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; import { AttendanceRecord, DingAttendanceRaw, Class, Student, ClassSchedule, ClassStudent, ScheduleType, UserDingMapping } from '../entities'; import { BatchCreateAttendanceDto, AttendanceSummaryQueryDto, AttendanceCalendarQueryDto, QueryDingRawDto, MatchDingRecordDto, AttendanceReportQueryDto, UpdateAttendanceRecordDto, GenerateAttendanceFromSchedulesDto, GenerateFromSchedulesDto, } from './dto/attendance.dto'; @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(UserDingMapping) private userDingMappingRepo: Repository, ) {} // ── Batch create attendance records ── async batchCreate(dto: BatchCreateAttendanceDto) { if (!dto.records || dto.records.length === 0) { throw new BadRequestException('records array must not be empty'); } // Batch-load student departmentIds const ids = [...new Set(dto.records.map((r) => r.studentId))]; const students = await this.studentRepo.find({ where: { id: In(ids) } }); const deptMap = new Map(students.map((s) => [s.id, s.departmentId])); const entities = dto.records.map((r) => { const entity = this.attendanceRepo.create({ studentId: r.studentId, classId: r.classId ?? undefined, attendanceDate: r.attendanceDate, session: r.session, status: r.status, remark: r.remark, source: r.source || 'manual', }); entity.departmentId = deptMap.get(r.studentId)!; return entity; }); const saved = await this.attendanceRepo.save(entities); return { count: saved.length, records: saved }; } // ── Generate attendance records from class schedules ── async generateAttendanceFromSchedules(dto: GenerateAttendanceFromSchedulesDto) { const { classId, dateFrom, dateTo } = dto; if (dateFrom > dateTo) { throw new BadRequestException('dateFrom must not be later than dateTo'); } const cls = await this.classRepo.findOne({ where: { id: classId } }); if (!cls) { throw new NotFoundException(`Class ${classId} not found`); } const schedules = await this.scheduleRepo.find({ where: { classId, scheduleType: ScheduleType.INTERNAL, status: 'active', startDate: LessThanOrEqual(dateTo), endDate: MoreThanOrEqual(dateFrom), }, }); const classStudents = await this.classStudentRepo.find({ where: { classId, status: 'active' }, relations: ['student'], }); if (schedules.length === 0 || classStudents.length === 0) { return { count: 0, records: [] }; } const existingRecords = await this.attendanceRepo.find({ where: { classId, attendanceDate: Between(dateFrom, dateTo) }, }); const existingKeys = new Set( existingRecords.map((r) => `${r.studentId}|${r.attendanceDate}|${r.session}`), ); const entities: AttendanceRecord[] = []; const end = new Date(dateTo); for (let d = new Date(dateFrom); d <= end; d.setDate(d.getDate() + 1)) { const dateStr = d.toISOString().slice(0, 10); const weekDay = d.getDay() === 0 ? 7 : d.getDay(); for (const sched of schedules) { if (sched.weekDay !== weekDay) continue; if (dateStr < sched.startDate || dateStr > sched.endDate) continue; const session = this.mapScheduleTimeToSession(sched.startTime); for (const cs of classStudents) { const key = `${cs.studentId}|${dateStr}|${session}`; if (existingKeys.has(key)) continue; const entity = this.attendanceRepo.create({ studentId: cs.studentId, classId, attendanceDate: dateStr, session, status: 'pending', source: 'schedule', }); entity.departmentId = cs.student?.departmentId ?? cls.departmentId ?? undefined; entities.push(entity); existingKeys.add(key); } } } const saved = await this.attendanceRepo.save(entities); return { count: saved.length, records: saved }; } // ── Generate attendance records from schedules (optional date range, defaults to current week) ── async generateFromSchedules(dto: GenerateFromSchedulesDto): Promise<{ count: number; records: AttendanceRecord[] }> { const { classId, startDate, endDate } = dto; // Default to current week (Monday–Sunday) const now = new Date(); const dayOfWeek = now.getDay(); const mondayOffset = dayOfWeek === 0 ? -6 : 1 - dayOfWeek; const monday = new Date(now); monday.setDate(now.getDate() + mondayOffset); monday.setHours(0, 0, 0, 0); const sunday = new Date(monday); sunday.setDate(monday.getDate() + 6); sunday.setHours(23, 59, 59, 999); const dateFrom = startDate ?? monday.toISOString().slice(0, 10); const dateTo = endDate ?? sunday.toISOString().slice(0, 10); return this.generateAttendanceFromSchedules({ classId, dateFrom, dateTo, }); } private mapScheduleTimeToSession(startTime: string): string { const hour = parseInt(startTime.slice(0, 2), 10); if (hour < 8) return 'morning_reading'; if (hour < 12) return 'morning'; if (hour < 17) return 'afternoon'; if (hour < 20) return 'evening_study'; return 'night_check'; } // ── Attendance summary ── async getSummary(query: AttendanceSummaryQueryDto) { const qb = this.attendanceRepo.createQueryBuilder('ar'); if (query.classId) { qb.andWhere('ar.classId = :classId', { classId: query.classId }); } if (query.dateFrom) { qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom }); } if (query.dateTo) { qb.andWhere('ar.attendanceDate <= :dateTo', { dateTo: query.dateTo }); } 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 presentRate = total > 0 ? Number(((present / total) * 100).toFixed(1)) : 0; return { total, present, late, absent, leave, presentRate }; } // ── Attendance calendar ── async getCalendar(query: AttendanceCalendarQueryDto) { const { classId, weekStart } = query; if (!weekStart) { // Default to the Monday of the current week const now = new Date(); const day = now.getDay(); const diff = day === 0 ? -6 : 1 - day; // Monday offset const monday = new Date(now); monday.setDate(now.getDate() + diff); const mondayStr = monday.toISOString().slice(0, 10); return this.buildCalendar(classId, mondayStr); } return this.buildCalendar(classId, weekStart); } private async buildCalendar(classId: number, weekStart: string) { // Compute weekEnd (Sunday = weekStart + 6 days) const start = new Date(weekStart); const end = new Date(start); end.setDate(start.getDate() + 6); const endStr = end.toISOString().slice(0, 10); // Fetch attendance records for the week const records = await this.attendanceRepo.find({ where: { classId, attendanceDate: Between(weekStart, endStr), }, relations: ['student'], order: { attendanceDate: 'ASC', session: 'ASC' }, }); // Group by studentId const studentMap = new Map< number, { studentId: number; studentName: string; days: Array<{ date: string; session: string; status: string }>; } >(); for (const r of records) { if (!studentMap.has(r.studentId)) { studentMap.set(r.studentId, { studentId: r.studentId, studentName: r.student?.name ?? `Student#${r.studentId}`, days: [], }); } studentMap.get(r.studentId)!.days.push({ date: r.attendanceDate, session: r.session, status: r.status, }); } return Array.from(studentMap.values()); } // ── List attendance records with filters ── async findAll(query: { classId?: number; dateFrom?: string; dateTo?: string; session?: string; status?: string; source?: string; page?: number; pageSize?: number; }) { const page = query.page || 1; const pageSize = query.pageSize || 20; const qb = this.attendanceRepo.createQueryBuilder('ar'); qb.leftJoinAndSelect('ar.student', 'student') .leftJoinAndSelect('ar.class', 'class'); if (query.classId) { qb.andWhere('ar.classId = :classId', { classId: query.classId }); } 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, total, page, pageSize }; } // ── Get distinct classes with attendance records ── async getClasses() { const qb = this.attendanceRepo .createQueryBuilder('ar') .select('DISTINCT ar.classId', 'classId') .where('ar.classId IS NOT NULL'); const rows = await qb .orderBy('ar.classId', 'ASC') .getRawMany(); const classIds = rows.map((r) => r.classId).filter(Boolean) as number[]; if (classIds.length === 0) return []; const where = { id: In(classIds) }; const classes = await this.classRepo.find({ where }); const nameMap = new Map(classes.map((c) => [c.id, c.name])); return classIds.map((id) => ({ classId: id, className: nameMap.get(id) || `班级${id}` })); } // ── DingAttendance raw records ── async getDingRaw(query: QueryDingRawDto) { const where: any = {}; if (query.matchStatus) { where.matchStatus = query.matchStatus; } return this.dingRawRepo.find({ where, relations: ['matchedStudent'], order: { attendanceDate: 'DESC', checkInTime: 'ASC' }, }); } // ── Match a dingtalk record to a student ── async matchDingRecord(id: number, dto: MatchDingRecordDto) { const record = await this.dingRawRepo.findOne({ where: { id } }); if (!record) { throw new NotFoundException(`DingAttendanceRaw ${id} not found`); } record.matchedStudentId = dto.studentId; record.matchStatus = '已匹配'; return this.dingRawRepo.save(record); } // ── Auto-match unmatched dingtalk records via dingUserId → userId mapping chain ── async autoMatchDingRecords(): Promise<{ matched: number; total: number }> { const unmatched = await this.dingRawRepo.find({ where: { matchStatus: '未处理' }, }); if (unmatched.length === 0) return { matched: 0, total: 0 }; // Build dingUserId → userId map from the mapping table const mappings = await this.userDingMappingRepo.find(); const dingToUserId = new Map(); for (const m of mappings) { dingToUserId.set(m.dingUserId, m.userId); } // Build userId → studentId map (only students linked to a user) const students = await this.studentRepo.find({ where: { userId: In([...dingToUserId.values()]) }, select: ['id', 'userId'], }); const userIdToStudentId = new Map(); for (const s of students) { if (s.userId != null) userIdToStudentId.set(s.userId, s.id); } let matched = 0; for (const record of unmatched) { const userId = dingToUserId.get(record.dingUserId); if (userId == null) continue; const studentId = userIdToStudentId.get(userId); if (studentId == null) continue; record.matchedStudentId = studentId; record.matchStatus = '已匹配'; await this.dingRawRepo.save(record); matched++; } return { matched, total: unmatched.length }; } // ── Export all attendance records with filters (no pagination) ── async findAllForExport(query: { classId?: number; dateFrom?: string; dateTo?: string; session?: string; status?: string; source?: string; }) { const qb = this.attendanceRepo.createQueryBuilder('ar'); qb.leftJoinAndSelect('ar.student', 'student') .leftJoinAndSelect('ar.class', 'class'); if (query.classId) { qb.andWhere('ar.classId = :classId', { classId: query.classId }); } 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'); return qb.getMany(); } // ── Update a single attendance record ── async update(id: number, dto: UpdateAttendanceRecordDto) { const record = await this.attendanceRepo.findOne({ where: { id } }); if (!record) { throw new NotFoundException(`AttendanceRecord ${id} not found`); } if (dto.status !== undefined) { record.status = dto.status; } if (dto.remark !== undefined) { record.remark = dto.remark; } return this.attendanceRepo.save(record); } // ── Delete a single attendance record ── async remove(id: number) { const record = await this.attendanceRepo.findOne({ where: { id } }); if (!record) { throw new NotFoundException(`AttendanceRecord ${id} not found`); } await this.attendanceRepo.remove(record); return { deleted: true }; } // ── Class-based attendance report ── async getReport(query: AttendanceReportQueryDto) { const qb = this.attendanceRepo.createQueryBuilder('ar'); qb.leftJoin('ar.class', 'class') .select('class.id', 'classId') .addSelect('class.name', 'className') .addSelect('ar.status', 'status') .addSelect('COUNT(*)', 'count'); if (query.classId) { qb.andWhere('ar.classId = :classId', { classId: query.classId }); } if (query.dateFrom) { qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom }); } if (query.dateTo) { qb.andWhere('ar.attendanceDate <= :dateTo', { dateTo: query.dateTo }); } qb.groupBy('class.id').addGroupBy('class.name').addGroupBy('ar.status'); const rawRows = await qb.getRawMany(); // Aggregate by class const classMap = new Map(); for (const row of rawRows) { if (!row.classId) continue; if (!classMap.has(row.classId)) { classMap.set(row.classId, { classId: row.classId, className: row.className || `班级#${row.classId}`, present: 0, absent: 0, late: 0, leave: 0, }); } const entry = classMap.get(row.classId)!; const count = parseInt(row.count, 10); if (row.status === 'present') entry.present += count; else if (row.status === 'absent') entry.absent += count; else if (row.status === 'late') entry.late += count; else if (row.status === 'leave') entry.leave += count; } return Array.from(classMap.values()).map((entry) => { const total = entry.present + entry.absent + entry.late + entry.leave; return { ...entry, total, presentRate: total > 0 ? ((entry.present / total) * 100).toFixed(1) : '0.0', absentRate: total > 0 ? ((entry.absent / total) * 100).toFixed(1) : '0.0', lateRate: total > 0 ? ((entry.late / total) * 100).toFixed(1) : '0.0', leaveRate: total > 0 ? ((entry.leave / total) * 100).toFixed(1) : '0.0', }; }); } // ── Attendance alerts: detect consecutive absences/late ── async getAlerts(days: number = 14, threshold: number = 3) { const cutoff = new Date(); cutoff.setDate(cutoff.getDate() - days); const cutoffStr = cutoff.toISOString().slice(0, 10); const qb = this.attendanceRepo .createQueryBuilder('a') .leftJoinAndSelect('a.student', 'student') .leftJoinAndSelect('a.class', 'class'); const records = await qb .where('a.attendanceDate >= :cutoff', { cutoff: cutoffStr }) .andWhere('a.status IN (:...statuses)', { statuses: ['absent', 'late'] }) .orderBy('a.studentId', 'ASC') .addOrderBy('a.attendanceDate', 'DESC') .getMany(); const alerts: Array<{ studentId: number; studentName: string; className: string; type: string; count: number; lastDate: string; }> = []; let current: typeof alerts[0] | null = null; for (const r of records) { const name = (r.student as any)?.name || ''; const className = (r.class as any)?.name || ''; const status = r.status === 'absent' ? '缺勤' : '迟到'; if (current && current.studentId === r.studentId && current.type === status) { current.count++; if (r.attendanceDate > current.lastDate) current.lastDate = r.attendanceDate; } else { if (current && current.count >= threshold) alerts.push({ ...current }); current = { studentId: r.studentId, studentName: name, className, type: status, count: 1, lastDate: r.attendanceDate }; } } if (current && current.count >= threshold) alerts.push(current); return alerts; } }