forked from wangziqi/gongxue-base
507 lines
17 KiB
TypeScript
507 lines
17 KiB
TypeScript
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<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(AttendanceSession)
|
|
private attendanceSessionRepo: Repository<AttendanceSession>,
|
|
@InjectRepository(Student)
|
|
private studentRepo: Repository<Student>,
|
|
@InjectRepository(StudentDingMapping)
|
|
private studentDingMappingRepo: Repository<StudentDingMapping>,
|
|
private dataSource: DataSource,
|
|
) {}
|
|
|
|
async getAccessibleClassIds(userId: number, canManageAll = false): Promise<number[] | undefined> {
|
|
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<void> {
|
|
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<AgentClassRow>();
|
|
return rows.map((row) => ({ ...row, id: Number(row.id), studentCount: Number(row.studentCount || 0) }));
|
|
}
|
|
|
|
async findAll(query: QueryClassDto, accessibleClassIds?: number[]) {
|
|
const where: Record<string, unknown> = {};
|
|
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<Class>);
|
|
}
|
|
|
|
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,
|
|
};
|
|
}
|
|
}
|