feat: add exam score management
This commit is contained in:
182
apps/server/src/exams/exams.service.ts
Normal file
182
apps/server/src/exams/exams.service.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import { BadRequestException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource, EntityManager, In, Like, Repository } from 'typeorm';
|
||||
import { Class, ClassStudent, ClassTeacher, Exam, ExamScore } from '../entities';
|
||||
import { CreateExamDto, QueryExamDto } from './dto/exam.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ExamsService {
|
||||
constructor(
|
||||
@InjectRepository(Exam) private examRepo: Repository<Exam>,
|
||||
@InjectRepository(ExamScore) private scoreRepo: Repository<ExamScore>,
|
||||
@InjectRepository(Class) private classRepo: Repository<Class>,
|
||||
@InjectRepository(ClassStudent) private classStudentRepo: Repository<ClassStudent>,
|
||||
@InjectRepository(ClassTeacher) private classTeacherRepo: Repository<ClassTeacher>,
|
||||
private dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
async getAccessibleClassIds(userId: number, canManageAll: boolean) {
|
||||
if (canManageAll) return undefined;
|
||||
const rows = await this.classTeacherRepo.find({ where: { userId } });
|
||||
return [...new Set(rows.map((row) => row.classId))];
|
||||
}
|
||||
|
||||
async assertClassAccess(userId: number, classId: number, canManageAll: boolean) {
|
||||
if (canManageAll) return;
|
||||
const assignment = await this.classTeacherRepo.findOne({ where: { userId, classId } });
|
||||
if (!assignment) throw new ForbiddenException('只能访问自己被分配的班级');
|
||||
}
|
||||
|
||||
async findAll(query: QueryExamDto, accessibleClassIds?: number[]) {
|
||||
const where: Record<string, unknown> = {
|
||||
status: 'active',
|
||||
};
|
||||
if (query.keyword) where.examName = Like(`%${query.keyword}%`);
|
||||
if (query.examType) where.examType = query.examType;
|
||||
if (query.classId) where.classId = query.classId;
|
||||
if (accessibleClassIds) {
|
||||
if (accessibleClassIds.length === 0) return [];
|
||||
where.classId = query.classId
|
||||
? accessibleClassIds.includes(query.classId)
|
||||
? query.classId
|
||||
: -1
|
||||
: In(accessibleClassIds);
|
||||
}
|
||||
|
||||
const exams = await this.examRepo.find({
|
||||
where,
|
||||
relations: ['class'],
|
||||
order: { examDate: 'DESC', createdAt: 'DESC' },
|
||||
});
|
||||
if (exams.length === 0) return [];
|
||||
const scoreRows = await this.scoreRepo.find({
|
||||
where: { examId: In(exams.map((exam) => exam.id)), status: 'active' },
|
||||
});
|
||||
const progress = new Map<number, { total: number; entered: number }>();
|
||||
for (const row of scoreRows) {
|
||||
const item = progress.get(row.examId!) ?? { total: 0, entered: 0 };
|
||||
item.total++;
|
||||
if (row.score !== null && row.score !== undefined) item.entered++;
|
||||
progress.set(row.examId!, item);
|
||||
}
|
||||
return exams.map((exam) => ({
|
||||
...exam,
|
||||
className: exam.class?.name,
|
||||
totalStudents: progress.get(exam.id)?.total ?? 0,
|
||||
enteredScores: progress.get(exam.id)?.entered ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
async findOne(id: number, userId: number, canManageAll: boolean) {
|
||||
const exam = await this.examRepo.findOne({ where: { id }, relations: ['class'] });
|
||||
if (!exam) throw new NotFoundException('考试不存在');
|
||||
await this.assertClassAccess(userId, exam.classId, canManageAll);
|
||||
const scores = await this.scoreRepo.find({
|
||||
where: { examId: id, status: 'active' },
|
||||
relations: ['student'],
|
||||
order: { id: 'ASC' },
|
||||
});
|
||||
return {
|
||||
...exam,
|
||||
className: exam.class?.name,
|
||||
totalStudents: scores.length,
|
||||
enteredScores: scores.filter((row) => row.score !== null && row.score !== undefined).length,
|
||||
scores: scores.map((row) => ({
|
||||
id: row.id,
|
||||
studentId: row.studentId,
|
||||
phone: row.student?.phone,
|
||||
name: row.student?.name,
|
||||
score: row.score === null ? null : Number(row.score),
|
||||
classAvg: row.classAvg === null ? null : Number(row.classAvg),
|
||||
rank: row.rank,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async create(dto: CreateExamDto, userId: number, canManageAll: boolean) {
|
||||
await this.assertClassAccess(userId, dto.classId, canManageAll);
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const cls = await manager.findOne(Class, { where: { id: dto.classId } });
|
||||
if (!cls) throw new NotFoundException('班级不存在');
|
||||
if (cls.isArchived) throw new BadRequestException('归档班级不能创建考试');
|
||||
const members = await manager.find(ClassStudent, {
|
||||
where: { classId: dto.classId, status: 'active' },
|
||||
order: { createdAt: 'ASC' },
|
||||
});
|
||||
if (members.length === 0) throw new BadRequestException('班级暂无在读学员,不能创建考试');
|
||||
const exam = await manager.save(Exam, manager.create(Exam, { ...dto, status: 'active' }));
|
||||
await this.createScoreRows(manager, exam, members);
|
||||
return exam;
|
||||
});
|
||||
}
|
||||
|
||||
async updateScore(
|
||||
examId: number,
|
||||
scoreId: number,
|
||||
score: number | null | undefined,
|
||||
userId: number,
|
||||
canManageAll: boolean,
|
||||
) {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const exam = await manager.findOne(Exam, { where: { id: examId } });
|
||||
if (!exam) throw new NotFoundException('考试不存在');
|
||||
await this.assertClassAccess(userId, exam.classId, canManageAll);
|
||||
if (exam.status === 'archived') throw new BadRequestException('已归档考试不能录入成绩');
|
||||
const row = await manager.findOne(ExamScore, { where: { id: scoreId, examId } });
|
||||
if (!row) throw new NotFoundException('成绩记录不存在');
|
||||
row.score = score === undefined ? null : score;
|
||||
await manager.save(ExamScore, row);
|
||||
await this.recalculate(manager, examId);
|
||||
return manager.findOne(ExamScore, { where: { id: scoreId } });
|
||||
});
|
||||
}
|
||||
|
||||
private async createScoreRows(manager: EntityManager, exam: Exam, members: ClassStudent[]) {
|
||||
const rows = members.map((member) =>
|
||||
manager.create(ExamScore, {
|
||||
examId: exam.id,
|
||||
studentId: member.studentId,
|
||||
examType: exam.examType,
|
||||
examName: exam.examName,
|
||||
subject: exam.subject,
|
||||
score: null,
|
||||
classAvg: null,
|
||||
rank: null,
|
||||
examDate: exam.examDate,
|
||||
status: 'active',
|
||||
}),
|
||||
);
|
||||
await manager.save(ExamScore, rows);
|
||||
}
|
||||
|
||||
private async recalculate(manager: EntityManager, examId: number) {
|
||||
const rows = await manager.find(ExamScore, { where: { examId, status: 'active' } });
|
||||
const entered = rows
|
||||
.filter((row) => row.score !== null && row.score !== undefined)
|
||||
.sort((a, b) => Number(b.score) - Number(a.score));
|
||||
const average =
|
||||
entered.length === 0
|
||||
? null
|
||||
: Math.round((entered.reduce((sum, row) => sum + Number(row.score), 0) / entered.length) * 100) /
|
||||
100;
|
||||
let previousScore: number | null = null;
|
||||
let previousRank = 0;
|
||||
for (let index = 0; index < entered.length; index++) {
|
||||
const row = entered[index];
|
||||
const value = Number(row.score);
|
||||
const rank = previousScore !== null && value === previousScore ? previousRank : index + 1;
|
||||
row.classAvg = average;
|
||||
row.rank = rank;
|
||||
previousScore = value;
|
||||
previousRank = rank;
|
||||
}
|
||||
const enteredIds = new Set(entered.map((row) => row.id));
|
||||
for (const row of rows) {
|
||||
if (!enteredIds.has(row.id)) {
|
||||
row.classAvg = average;
|
||||
row.rank = null;
|
||||
}
|
||||
}
|
||||
await manager.save(ExamScore, rows);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user