import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, In, Like } from 'typeorm'; import { Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, Classroom, Student, StudentDingMapping } from '../entities'; 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(Student) private studentRepo: Repository, @InjectRepository(StudentDingMapping) private studentDingMappingRepo: Repository, ) {} async findAll(query: QueryClassDto) { 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; 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); 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); 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('请先归档再删除'); 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 }; } private async syncClassTeacherIds(classId: number) { const teachers = await this.classTeacherRepo.find({ where: { classId } }); const updates: Record = {}; 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'); if (head) updates.headTeacherId = head.userId; if (life) updates.lifeTeacherId = life.userId; if (academic) updates.academicTeacherId = academic.userId; if (Object.keys(updates).length > 0) { await this.classRepo.update(classId, updates); } } 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, }; } }