import { Injectable, NotFoundException, BadRequestException, ForbiddenException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, Repository, In, Like } from 'typeorm'; import { Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, AttendanceSession, Classroom, Student, StudentDingMapping, } from '../entities'; import { syncDingTalkStudents } from '../integration/dingtalk-student-sync'; import { normalizeDateOnly } from '../database/date-normalization'; import { CreateClassDto, UpdateClassDto, QueryClassDto, AddTeacherDto, QueryClassScheduleDto, QueryClassAttendanceSummaryDto, } from './dto/class.dto'; interface RawStudentCount { classId: string; count: string; } interface AgentClassRow { id: string | number; name: string; code: string; classType: string; status: string; startDate: string | null; endDate: string | null; studentCount: string | number; } @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, private dataSource: DataSource, ) {} 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 agentSearchClasses( userId: number, canManageAll: boolean, query: { keyword?: string; status?: string; limit?: number }, ) { const accessibleClassIds = await this.getAccessibleClassIds(userId, canManageAll); if (accessibleClassIds?.length === 0) return []; const qb = this.classRepo .createQueryBuilder('class') .leftJoin( ClassStudent, 'classStudent', 'classStudent.classId = class.id AND classStudent.status = :activeStudent', { activeStudent: 'active' }, ) .select('class.id', 'id') .addSelect('class.name', 'name') .addSelect('class.code', 'code') .addSelect('class.classType', 'classType') .addSelect('class.status', 'status') .addSelect('class.startDate', 'startDate') .addSelect('class.endDate', 'endDate') .addSelect('COUNT(classStudent.id)', 'studentCount') .where('class.isArchived = :isArchived', { isArchived: false }); if (accessibleClassIds) qb.andWhere('class.id IN (:...accessibleClassIds)', { accessibleClassIds }); if (query.keyword) qb.andWhere('(class.name LIKE :keyword OR class.code LIKE :keyword)', { keyword: `%${query.keyword}%` }); if (query.status) qb.andWhere('class.status = :status', { status: query.status }); const rows = await qb.groupBy('class.id').orderBy('class.name', 'ASC').limit(query.limit ?? 20).getRawMany(); return rows.map((row) => ({ ...row, id: Number(row.id), studentCount: Number(row.studentCount || 0) })); } async findAll(query: QueryClassDto, accessibleClassIds?: number[]) { const 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; conflicts: number }> { if (users.length === 0) return { imported: 0, skipped: 0, conflicts: 0 }; return this.dataSource.transaction(async (manager) => { const classEntity = await manager.findOne(Class, { where: { id: classId } }); if (!classEntity) throw new NotFoundException('班级不存在'); const synced = await syncDingTalkStudents(manager, users); const studentIds = [...new Set(synced.studentIds.values())]; if (studentIds.length === 0) { return { imported: 0, skipped: 0, conflicts: synced.conflicts.length }; } const existingClassStudents = await manager.find(ClassStudent, { where: { classId, studentId: In(studentIds) }, }); const existingByStudentId = new Map( existingClassStudents.map((classStudent) => [classStudent.studentId, classStudent]), ); const today = new Date().toISOString().slice(0, 10); let skipped = 0; const memberships = studentIds.flatMap((studentId) => { const existing = existingByStudentId.get(studentId); if (existing?.status === 'active') { skipped++; return []; } if (existing) { existing.status = 'active'; existing.joinDate = today; existing.leaveDate = null; return [existing]; } return [ manager.create(ClassStudent, { classId, studentId, status: 'active', joinDate: today, }), ]; }); if (memberships.length > 0) await manager.save(ClassStudent, memberships); return { imported: memberships.length, skipped, conflicts: synced.conflicts.length, }; }); } 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('班级不存在'); if (cls.isArchived) throw new BadRequestException('班级已归档'); 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('班级不存在'); if (!cls.isArchived) throw new BadRequestException('班级未归档'); await this.classRepo.update(id, { isArchived: false }); return { success: true }; } /** 归档班级(兼容旧删除入口,不物理删除) */ async remove(id: number) { return this.archive(id); } async getStudents(classId: number) { return this.classStudentRepo.find({ where: { classId }, relations: ['student'], order: { createdAt: 'ASC' as const }, }); } async addStudents(classId: number, studentIds: number[]) { const uniqueStudentIds = [...new Set(studentIds)]; if (uniqueStudentIds.length === 0) return { added: 0, skipped: 0 }; const cls = await this.classRepo.findOne({ where: { id: classId } }); if (!cls) throw new NotFoundException('班级不存在'); const students = await this.studentRepo.find({ where: { id: In(uniqueStudentIds) } }); if (students.length !== uniqueStudentIds.length) { throw new NotFoundException('部分学生不存在'); } const existing = await this.classStudentRepo.find({ where: { classId, studentId: In(uniqueStudentIds) }, }); const existingByStudentId = new Map( existing.map((classStudent) => [classStudent.studentId, classStudent]), ); const today = new Date().toISOString().split('T')[0]; let skipped = 0; const memberships = uniqueStudentIds.flatMap((studentId) => { const current = existingByStudentId.get(studentId); if (current?.status === 'active') { skipped++; return []; } if (current) { current.status = 'active'; current.joinDate = today; current.leaveDate = null; return [current]; } return [ this.classStudentRepo.create({ classId, studentId, status: 'active', joinDate: today, }), ]; }); if (memberships.length) await this.classStudentRepo.save(memberships); return { added: memberships.length, skipped }; } async removeStudent(classId: number, studentId: number) { const membership = await this.classStudentRepo.findOne({ where: { classId, studentId }, }); if (!membership) throw new NotFoundException('学生不在该班级'); if (membership.status !== 'active') throw new BadRequestException('学生已离班'); membership.status = 'left'; membership.leaveDate = new Date().toISOString().split('T')[0]; await this.classStudentRepo.save(membership); return { success: true }; } async getTeachers(classId: number) { return this.classTeacherRepo.find({ where: { classId }, relations: ['user'], }); } async addTeacher(classId: number, dto: AddTeacherDto) { const cls = await this.classRepo.findOne({ where: { id: classId } }); if (!cls) throw new NotFoundException('班级不存在'); 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) { const assignments = await this.classTeacherRepo.find({ where: { classId, userId } }); if (assignments.length === 0) throw new NotFoundException('教师未分配到该班级'); await this.classTeacherRepo.delete({ classId, userId }); await this.syncClassTeacherIds(classId); return { success: true }; } async removeTeacherAssignment(classId: number, assignmentId: number) { const assignment = await this.classTeacherRepo.findOne({ where: { id: assignmentId, classId }, }); if (!assignment) throw new NotFoundException('教师角色分配不存在'); 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, }; } }