Files
gongxue-base/apps/server/src/classes/classes.service.ts

395 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In, Like } from 'typeorm';
import { Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, Department, 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<Class>,
@InjectRepository(ClassStudent)
private classStudentRepo: Repository<ClassStudent>,
@InjectRepository(ClassTeacher)
private classTeacherRepo: Repository<ClassTeacher>,
@InjectRepository(ClassSchedule)
private scheduleRepo: Repository<ClassSchedule>,
@InjectRepository(AttendanceRecord)
private attendanceRepo: Repository<AttendanceRecord>,
@InjectRepository(Department)
private deptRepo: Repository<Department>,
@InjectRepository(Student)
private studentRepo: Repository<Student>,
@InjectRepository(StudentDingMapping)
private studentDingMappingRepo: Repository<StudentDingMapping>,
) {}
async findAll(query: QueryClassDto) {
let where: Record<string, unknown> = {};
if (query.departmentId) where.departmentId = query.departmentId;
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, dingUserIds, ...classData } = dto;
// Resolve departmentId: frontend may pass DingTalk dept ID, map to local
if (classData.departmentId) {
const localDept = await this.deptRepo.findOne({
where: { source: 'dingtalk', sourceId: String(classData.departmentId) },
});
if (localDept) {
classData.departmentId = localDept.id;
} else {
// Verify it's a valid local department ID
const exists = await this.deptRepo.findOne({ where: { id: classData.departmentId } });
if (!exists) {
throw new BadRequestException(`部门 ID ${classData.departmentId} 不存在`);
}
}
}
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 (dingUserIds?.length) {
await this.batchImportStudents(saved.id, dingUserIds);
}
return this.findOne(saved.id);
}
async batchImportStudents(classId: number, dingUserIds: string[]): Promise<{ imported: number; skipped: number }> {
const classEntity = await this.classRepo.findOne({ where: { id: classId } });
if (!classEntity) throw new NotFoundException('班级不存在');
if (dingUserIds.length === 0) return { imported: 0, skipped: 0 };
// 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 newDingUserIds = dingUserIds.filter(id => !dingToStudentId.has(id));
if (newDingUserIds.length > 0) {
const newStudents = newDingUserIds.map(dingUserId =>
this.studentRepo.create({
name: `dd_${dingUserId}`,
status: 'active',
departmentId: classEntity.departmentId ?? undefined,
})
);
const savedStudents = await this.studentRepo.save(newStudents);
const newMappings = savedStudents.map((s, i) =>
this.studentDingMappingRepo.create({ dingUserId: newDingUserIds[i], studentId: s.id })
);
await this.studentDingMappingRepo.save(newMappings);
for (let i = 0; i < newDingUserIds.length; i++) {
dingToStudentId.set(newDingUserIds[i], savedStudents[i].id);
}
}
// 3. Fetch existing class-student links in one query
const allStudentIds = Array.from(dingToStudentId.values());
const alreadyInClass = new Set<number>();
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 createFromDepartment(dto: { departmentId: number; name?: string; classType?: string }) {
const dept = await this.deptRepo.findOne({
where: { id: dto.departmentId, source: 'dingtalk' },
});
if (!dept) {
throw new BadRequestException('所选部门不存在或非钉钉同步部门');
}
const className = dto.name || dept.name;
const code = `DT_${dto.departmentId}`;
const existing = await this.classRepo.findOne({ where: { code } });
if (existing) {
throw new BadRequestException(`班级"${className}"已存在(编码: ${code}`);
}
const cls = this.classRepo.create({
name: className,
code,
departmentId: dto.departmentId,
classType: dto.classType || 'culture',
status: 'enrolling',
});
return this.classRepo.save(cls);
}
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<string, number> = {};
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,
};
}
}