import { Injectable, NotFoundException, BadRequestException, ConflictException, ForbiddenException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, In, Like } from 'typeorm'; import { Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, AttendanceSession, Classroom, Student, StudentDingMapping, } from '../entities'; import { normalizeDateOnly } from '../database/date-normalization'; import { CreateClassDto, UpdateClassDto, QueryClassDto, AddTeacherDto, QueryClassScheduleDto, QueryClassAttendanceSummaryDto, BatchImportStudentsDto, } from './dto/class.dto'; interface RawStudentCount { classId: string; count: string; } @Injectable() export class ClassesService { constructor( @InjectRepository(Class) private classRepo: Repository, @InjectRepository(ClassStudent) private classStudentRepo: Repository, @InjectRepository(ClassTeacher) private classTeacherRepo: Repository, @InjectRepository(ClassSchedule) private scheduleRepo: Repository, @InjectRepository(AttendanceRecord) private attendanceRepo: Repository, @InjectRepository(AttendanceSession) private attendanceSessionRepo: Repository, @InjectRepository(Student) private studentRepo: Repository, @InjectRepository(StudentDingMapping) private studentDingMappingRepo: Repository, ) {} 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 ForbiddenException('只能访问自己被分配的班级'); } async findAll(query: QueryClassDto, accessibleClassIds?: number[]) { let where: Record = {}; if (query.status) where.status = query.status; if (query.classType) where.classType = query.classType; if (query.keyword) where.name = Like(`%${query.keyword}%`); // Default: hide archived, unless explicitly requested where.isArchived = query.isArchived ?? false; if (accessibleClassIds) { if (accessibleClassIds.length === 0) return []; where.id = In(accessibleClassIds); } const classes = await this.classRepo.find({ where, order: { createdAt: 'DESC' as const }, }); // count students per class const studentCounts: RawStudentCount[] = await this.classStudentRepo .createQueryBuilder('cs') .select('cs.class_id', 'classId') .addSelect('COUNT(cs.id)', 'count') .where('cs.status = :status', { status: 'active' }) .groupBy('cs.class_id') .getRawMany(); const countMap = new Map(studentCounts.map((r) => [Number(r.classId), Number(r.count)])); return classes.map((c) => ({ ...c, studentCount: countMap.get(c.id) || 0, })); } async findOne(id: number) { const cls = await this.classRepo.findOne({ where: { id } }); if (!cls) throw new NotFoundException('班级不存在'); const students = await this.classStudentRepo.find({ where: { classId: id }, relations: ['student'], }); const teachers = await this.classTeacherRepo.find({ where: { classId: id }, relations: ['user'], }); return { ...cls, students: students.map((s) => ({ id: s.id, studentId: s.studentId, studentName: s.student?.name, studentNo: s.student?.studentNo, joinDate: s.joinDate, leaveDate: s.leaveDate, status: s.status, })), teachers: teachers.map((t) => ({ id: t.id, userId: t.userId, username: t.user?.username, roleType: t.roleType, subject: t.subject, })), studentCount: students.filter((s) => s.status === 'active').length, }; } async create(dto: CreateClassDto) { const { studentIds, teachers, users, ...classData } = dto; const cls = this.classRepo.create({ ...classData, startDate: normalizeDateOnly(classData.startDate) ?? undefined, endDate: normalizeDateOnly(classData.endDate) ?? undefined, }); const saved = await this.classRepo.save(cls); // add students if (studentIds?.length) { const entries = studentIds.map((sid: number) => this.classStudentRepo.create({ classId: saved.id, studentId: sid, joinDate: new Date().toISOString().split('T')[0], }), ); await this.classStudentRepo.save(entries); } // add teachers if (teachers?.length) { const entries = teachers.map((t) => this.classTeacherRepo.create({ classId: saved.id, userId: t.userId, roleType: t.roleType, subject: t.subject, }), ); await this.classTeacherRepo.save(entries); // sync head/life/academic teacher IDs await this.syncClassTeacherIds(saved.id); } // batch import students by dingUserIds if (users?.length) { await this.batchImportStudents(saved.id, users); } return this.findOne(saved.id); } async batchImportStudents( classId: number, users: Array<{ dingUserId: string; name: string; mobile?: string; }>, ): Promise<{ imported: number; skipped: number }> { const classEntity = await this.classRepo.findOne({ where: { id: classId } }); if (!classEntity) throw new NotFoundException('班级不存在'); if (users.length === 0) return { imported: 0, skipped: 0 }; const dingUserIds = users.map((u) => u.dingUserId); // 1. Fetch all existing ding mappings in one query const existingMappings = await this.studentDingMappingRepo.find({ where: { dingUserId: In(dingUserIds) }, }); const dingToStudentId = new Map(existingMappings.map((m) => [m.dingUserId, m.studentId])); // 2. Batch create students for new dingUserIds const newUsers = users.filter((u) => !dingToStudentId.has(u.dingUserId)); if (newUsers.length > 0) { const newStudents = newUsers.map((u) => this.studentRepo.create({ name: u.name, phone: u.mobile || `dt_${u.dingUserId}`, status: 'active', }), ); const savedStudents = await this.studentRepo.save(newStudents); const newMappings = savedStudents.map((s, i) => this.studentDingMappingRepo.create({ dingUserId: newUsers[i].dingUserId, studentId: s.id }), ); await this.studentDingMappingRepo.save(newMappings); for (let i = 0; i < newUsers.length; i++) { dingToStudentId.set(newUsers[i].dingUserId, savedStudents[i].id); } } // 3. Fetch existing class-student links in one query const allStudentIds = Array.from(dingToStudentId.values()); const alreadyInClass = new Set(); if (allStudentIds.length > 0) { const existingClassStudents = await this.classStudentRepo.find({ where: { classId, studentId: In(allStudentIds) }, }); for (const cs of existingClassStudents) { alreadyInClass.add(cs.studentId); } } // 4. Batch insert new class-student records const newClassStudents = allStudentIds .filter((sid) => !alreadyInClass.has(sid)) .map((studentId) => this.classStudentRepo.create({ classId, studentId, status: 'active', joinDate: new Date().toISOString().slice(0, 10), }), ); if (newClassStudents.length > 0) { await this.classStudentRepo.save(newClassStudents); } return { imported: newClassStudents.length, skipped: alreadyInClass.size }; } async update(id: number, dto: UpdateClassDto) { const cls = await this.classRepo.findOne({ where: { id } }); if (!cls) throw new NotFoundException('班级不存在'); await this.classRepo.update(id, { ...dto, ...(dto.startDate !== undefined ? { startDate: normalizeDateOnly(dto.startDate) ?? undefined } : {}), ...(dto.endDate !== undefined ? { endDate: normalizeDateOnly(dto.endDate) ?? undefined } : {}), }); return this.findOne(id); } /** 归档班级(软删除) */ async archive(id: number) { const cls = await this.classRepo.findOne({ where: { id } }); if (!cls) throw new NotFoundException('班级不存在'); await this.classRepo.update(id, { isArchived: true }); return { success: true }; } /** 取消归档 */ async restore(id: number) { const cls = await this.classRepo.findOne({ where: { id } }); if (!cls) throw new NotFoundException('班级不存在'); await this.classRepo.update(id, { isArchived: false }); return { success: true }; } /** 物理删除班级(已归档的才能删除) */ async remove(id: number) { const cls = await this.classRepo.findOne({ where: { id } }); if (!cls) throw new NotFoundException('班级不存在'); if (!cls.isArchived) throw new BadRequestException('请先归档再删除'); const sessionCount = await this.attendanceSessionRepo.count({ where: { classId: id }, }); if (sessionCount > 0) { throw new ConflictException( `无法删除已产生 ${sessionCount} 个考勤场次的班级。请先取消或停用班级以保护历史考勤数据。`, ); } await this.classRepo.remove(cls); return { success: true }; } async getStudents(classId: number) { return this.classStudentRepo.find({ where: { classId }, relations: ['student'], order: { createdAt: 'ASC' as const }, }); } async addStudents(classId: number, studentIds: number[]) { const existing = await this.classStudentRepo.find({ where: { classId, studentId: In(studentIds) }, }); const existingIds = new Set(existing.map((e) => e.studentId)); const newIds = studentIds.filter((id) => !existingIds.has(id)); const entries = newIds.map((sid) => this.classStudentRepo.create({ classId, studentId: sid, joinDate: new Date().toISOString().split('T')[0], }), ); if (entries.length) await this.classStudentRepo.save(entries); return { added: entries.length, skipped: studentIds.length - entries.length }; } async removeStudent(classId: number, studentId: number) { await this.classStudentRepo.delete({ classId, studentId }); return { success: true }; } async getTeachers(classId: number) { return this.classTeacherRepo.find({ where: { classId }, relations: ['user'], }); } async addTeacher(classId: number, dto: AddTeacherDto) { const existing = await this.classTeacherRepo.findOne({ where: { classId, userId: dto.userId, roleType: dto.roleType }, }); if (existing) throw new BadRequestException('该教师已分配此角色'); const entry = this.classTeacherRepo.create({ classId, userId: dto.userId, roleType: dto.roleType, subject: dto.subject, }); await this.classTeacherRepo.save(entry); await this.syncClassTeacherIds(classId); return entry; } async removeTeacher(classId: number, userId: number) { await this.classTeacherRepo.delete({ classId, userId }); await this.syncClassTeacherIds(classId); return { success: true }; } async removeTeacherAssignment(classId: number, assignmentId: number) { await this.classTeacherRepo.delete({ id: assignmentId, classId }); await this.syncClassTeacherIds(classId); return { success: true }; } private async syncClassTeacherIds(classId: number) { const teachers = await this.classTeacherRepo.find({ where: { classId } }); const head = teachers.find((t) => t.roleType === 'head_teacher'); const life = teachers.find((t) => t.roleType === 'life_teacher'); const academic = teachers.find((t) => t.roleType === 'academic_teacher'); await this.classRepo.update(classId, { headTeacherId: head?.userId ?? null, lifeTeacherId: life?.userId ?? null, academicTeacherId: academic?.userId ?? null, } as Partial); } async getSchedule(classId: number, query: QueryClassScheduleDto) { const qb = this.scheduleRepo .createQueryBuilder('cs') .leftJoinAndSelect('cs.classroom', 'classroom') .where('cs.classId = :classId', { classId }); if (query.startDate) { qb.andWhere('cs.endDate >= :startDate', { startDate: query.startDate }); } if (query.endDate) { qb.andWhere('cs.startDate <= :endDate', { endDate: query.endDate }); } const schedules = await qb .orderBy('cs.weekDay', 'ASC') .addOrderBy('cs.startTime', 'ASC') .getMany(); return schedules.map((s) => ({ ...s, classroomName: (s.classroom as Classroom | undefined)?.name || null, })); } async getAttendanceSummary(classId: number, query: QueryClassAttendanceSummaryDto) { const qb = this.attendanceRepo .createQueryBuilder('ar') .where('ar.classId = :classId', { classId }); if (query.startDate) { qb.andWhere('ar.attendanceDate >= :startDate', { startDate: query.startDate }); } if (query.endDate) { qb.andWhere('ar.attendanceDate <= :endDate', { endDate: query.endDate }); } 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; return { total, present, late, absent, leave, presentRate: total > 0 ? Number(((present / total) * 100).toFixed(1)) : 0, absentRate: total > 0 ? Number(((absent / total) * 100).toFixed(1)) : 0, lateRate: total > 0 ? Number(((late / total) * 100).toFixed(1)) : 0, leaveRate: total > 0 ? Number(((leave / total) * 100).toFixed(1)) : 0, }; } }