feat: add exam score management
This commit is contained in:
@@ -39,6 +39,7 @@ import {
|
||||
StudentProfile,
|
||||
StudentEnrollment,
|
||||
ExamScore,
|
||||
Exam,
|
||||
LearningRecord,
|
||||
ExpenseType,
|
||||
ResultArchive,
|
||||
@@ -51,7 +52,8 @@ import {
|
||||
} from './entities';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { InitialSchema1784520727860 } from './migrations/1784520727860-InitialSchema';
|
||||
const allMigrations = [InitialSchema1784520727860];
|
||||
import { AddExamManagement1784600000000 } from './migrations/1784600000000-AddExamManagement';
|
||||
const allMigrations = [InitialSchema1784520727860, AddExamManagement1784600000000];
|
||||
import { AuthorizationModule } from './authorization';
|
||||
import { RbacModule } from './rbac/rbac.module';
|
||||
import { StudentsModule } from './students/students.module';
|
||||
@@ -81,6 +83,7 @@ import { AgentToolsModule } from './agent-tools';
|
||||
import { AiConfigModule } from './ai-config/ai-config.module';
|
||||
import { WalletsModule } from './wallets/wallets.module';
|
||||
import { FinancialOperationsModule } from './financial-operations/financial-operations.module';
|
||||
import { ExamsModule } from './exams/exams.module';
|
||||
|
||||
import {
|
||||
IntegrationConfig,
|
||||
@@ -137,6 +140,7 @@ import { IntegrationConfigModule } from './integration/config/config.module';
|
||||
StudentProfile,
|
||||
StudentEnrollment,
|
||||
ExamScore,
|
||||
Exam,
|
||||
LearningRecord,
|
||||
ExpenseType,
|
||||
ArchiveAttachment,
|
||||
@@ -178,6 +182,7 @@ import { IntegrationConfigModule } from './integration/config/config.module';
|
||||
AuthModule,
|
||||
RbacModule,
|
||||
StudentsModule,
|
||||
ExamsModule,
|
||||
RoomsModule,
|
||||
OccupanciesModule,
|
||||
ExpensesModule,
|
||||
|
||||
@@ -412,11 +412,13 @@ ${this.buildLearningAndResult(learnings, result, now)}
|
||||
const highestName = highestExam?.examName ?? '-';
|
||||
|
||||
// Improvement: last exam score minus first exam score
|
||||
const sortedExams = [...cultureExams].filter((e) => e.score != null);
|
||||
const sortedScores = cultureExams
|
||||
.map((exam) => exam.score)
|
||||
.filter((score): score is number => score !== null && score !== undefined);
|
||||
let improvement = '—';
|
||||
if (sortedExams.length >= 2) {
|
||||
const first = sortedExams[0].score;
|
||||
const last = sortedExams[sortedExams.length - 1].score;
|
||||
if (sortedScores.length >= 2) {
|
||||
const first = sortedScores[0];
|
||||
const last = sortedScores[sortedScores.length - 1];
|
||||
improvement = (last - first).toFixed(1);
|
||||
}
|
||||
|
||||
@@ -511,7 +513,7 @@ ${this.buildLearningAndResult(learnings, result, now)}
|
||||
const cultureExams = exams.filter((e) => e.score != null);
|
||||
if (cultureExams.length === 0) return '';
|
||||
|
||||
const scores = cultureExams.map((e) => e.score);
|
||||
const scores = cultureExams.map((e) => Number(e.score));
|
||||
const labels = cultureExams.map((e) => {
|
||||
const d = e.examDate || '-';
|
||||
return d.length > 7 ? d.slice(5) : d;
|
||||
|
||||
@@ -67,6 +67,22 @@ describe('ArchiveService — resource and relationship boundaries', () => {
|
||||
expect(exam.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps exam-management scores read-only in the student archive', async () => {
|
||||
const exam = {
|
||||
findOne: jest.fn().mockResolvedValue({ id: 3, studentId: 7, examId: 8 }),
|
||||
save: jest.fn(),
|
||||
update: jest.fn(),
|
||||
};
|
||||
const service = createService({ exam });
|
||||
|
||||
await expect(service.updateExamScore(3, { score: 95 })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
await expect(service.deleteExamScore(3)).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(exam.save).not.toHaveBeenCalled();
|
||||
expect(exam.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects a missing attachment upload before writing to disk', async () => {
|
||||
const service = createService({ student: { findOne: jest.fn() } });
|
||||
await expect(service.addAttachment(7, undefined as never, 'other')).rejects.toBeInstanceOf(
|
||||
|
||||
@@ -75,7 +75,11 @@ export class ArchiveService {
|
||||
] = await Promise.all([
|
||||
this.profileRepo.findOne({ where: { studentId } }),
|
||||
this.enrollmentRepo.find({ where: { studentId, status: 'active' }, order: { createdAt: 'DESC' } }),
|
||||
this.examScoreRepo.find({ where: { studentId, status: 'active' }, order: { examDate: 'DESC' } }),
|
||||
this.examScoreRepo.find({
|
||||
where: { studentId, status: 'active' },
|
||||
relations: ['exam', 'exam.class'],
|
||||
order: { examDate: 'DESC' },
|
||||
}),
|
||||
this.learningRecordRepo.find({ where: { studentId, status: 'active' }, order: { recordDate: 'DESC' } }),
|
||||
this.resultRepo.findOne({ where: { studentId } }),
|
||||
this.attachmentRepo.find({ where: { studentId, status: 'active' }, order: { createdAt: 'DESC' } }),
|
||||
@@ -154,6 +158,7 @@ export class ArchiveService {
|
||||
async updateExamScore(id: number, dto: UpdateExamScoreDto) {
|
||||
const entity = await this.examScoreRepo.findOne({ where: { id } });
|
||||
if (!entity) throw new NotFoundException('考试成绩不存在');
|
||||
if (entity.examId) throw new BadRequestException('考试管理同步成绩请在考试管理中修改');
|
||||
await this.assertEnrollmentBelongsToStudent(entity.studentId, dto.enrollmentId);
|
||||
Object.assign(entity, dto);
|
||||
return this.examScoreRepo.save(entity);
|
||||
@@ -162,6 +167,7 @@ export class ArchiveService {
|
||||
async deleteExamScore(id: number) {
|
||||
const entity = await this.examScoreRepo.findOne({ where: { id } });
|
||||
if (!entity) throw new NotFoundException('考试成绩不存在');
|
||||
if (entity.examId) throw new BadRequestException('考试管理同步成绩不能在学生档案中归档');
|
||||
if (entity.status === 'archived') throw new BadRequestException('考试成绩已归档');
|
||||
await this.examScoreRepo.update(id, { status: 'archived' });
|
||||
return { message: '已归档' };
|
||||
|
||||
@@ -9,12 +9,20 @@ import {
|
||||
} from 'typeorm';
|
||||
import { Student } from './student.entity';
|
||||
import { StudentEnrollment } from './student-enrollment.entity';
|
||||
import { Exam } from './exam.entity';
|
||||
|
||||
@Entity('exam_scores')
|
||||
export class ExamScore {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ name: 'exam_id', type: 'integer', nullable: true })
|
||||
examId: number | null;
|
||||
|
||||
@ManyToOne(() => Exam, (exam) => exam.scores, { nullable: true, onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'exam_id' })
|
||||
exam: Exam | null;
|
||||
|
||||
@Column({ name: 'student_id', type: 'integer' })
|
||||
studentId: number;
|
||||
|
||||
@@ -39,13 +47,13 @@ export class ExamScore {
|
||||
subject: string;
|
||||
|
||||
@Column({ type: 'decimal', precision: 5, scale: 2, nullable: true })
|
||||
score: number;
|
||||
score: number | null;
|
||||
|
||||
@Column({ name: 'class_avg', type: 'decimal', precision: 5, scale: 2, nullable: true })
|
||||
classAvg: number;
|
||||
classAvg: number | null;
|
||||
|
||||
@Column({ type: 'integer', nullable: true })
|
||||
rank: number;
|
||||
rank: number | null;
|
||||
|
||||
@Column({ name: 'exam_date', type: 'date', nullable: true })
|
||||
examDate: string;
|
||||
|
||||
49
apps/server/src/entities/exam.entity.ts
Normal file
49
apps/server/src/entities/exam.entity.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { Class } from './class.entity';
|
||||
import { ExamScore } from './exam-score.entity';
|
||||
|
||||
@Entity('exams')
|
||||
export class Exam {
|
||||
@PrimaryGeneratedColumn()
|
||||
id: number;
|
||||
|
||||
@Column({ name: 'exam_type', length: 50 })
|
||||
examType: string;
|
||||
|
||||
@Column({ name: 'exam_name', length: 100 })
|
||||
examName: string;
|
||||
|
||||
@Column({ length: 50 })
|
||||
subject: string;
|
||||
|
||||
@Column({ name: 'exam_date', type: 'date' })
|
||||
examDate: string;
|
||||
|
||||
@Column({ name: 'class_id', type: 'integer' })
|
||||
classId: number;
|
||||
|
||||
@ManyToOne(() => Class)
|
||||
@JoinColumn({ name: 'class_id' })
|
||||
class: Class;
|
||||
|
||||
@OneToMany(() => ExamScore, (score) => score.exam)
|
||||
scores: ExamScore[];
|
||||
|
||||
@Column({ type: 'varchar', length: 20, default: 'active' })
|
||||
status: 'active' | 'archived';
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
@@ -32,6 +32,7 @@ export { Notification, NotificationType } from './notification.entity';
|
||||
export { StudentProfile } from './student-profile.entity';
|
||||
export { StudentEnrollment } from './student-enrollment.entity';
|
||||
export { ExamScore } from './exam-score.entity';
|
||||
export { Exam } from './exam.entity';
|
||||
export { LearningRecord } from './learning-record.entity';
|
||||
export { ResultArchive } from './result-archive.entity';
|
||||
export { ArchiveAttachment } from './archive-attachment.entity';
|
||||
|
||||
29
apps/server/src/exams/dto/exam.dto.ts
Normal file
29
apps/server/src/exams/dto/exam.dto.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
IsDateString,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Max,
|
||||
Min,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateExamDto {
|
||||
@IsString() @IsNotEmpty() examType: string;
|
||||
@IsString() @IsNotEmpty() examName: string;
|
||||
@IsString() @IsNotEmpty() subject: string;
|
||||
@IsDateString() examDate: string;
|
||||
@IsInt() @Min(1) classId: number;
|
||||
}
|
||||
|
||||
export class QueryExamDto {
|
||||
@IsOptional() @IsString() keyword?: string;
|
||||
@IsOptional() @IsString() examType?: string;
|
||||
@IsOptional() @Type(() => Number) @IsInt() @Min(1) classId?: number;
|
||||
}
|
||||
|
||||
export class UpdateExamScoreValueDto {
|
||||
@IsOptional() @IsNumber() @Min(0) @Max(999.99) score?: number | null;
|
||||
}
|
||||
104
apps/server/src/exams/exams.controller.ts
Normal file
104
apps/server/src/exams/exams.controller.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
Request,
|
||||
UseGuards,
|
||||
UsePipes,
|
||||
ValidationPipe,
|
||||
} from '@nestjs/common';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||
import { extractRequestInfo } from '../common/request-utils';
|
||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||
import type { AuthenticatedUser } from '../authorization';
|
||||
import { CreateExamDto, QueryExamDto, UpdateExamScoreValueDto } from './dto/exam.dto';
|
||||
import { ExamsService } from './exams.service';
|
||||
|
||||
interface AuthenticatedRequest {
|
||||
user: AuthenticatedUser;
|
||||
ip?: string;
|
||||
headers?: Record<string, string | string[] | undefined>;
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }))
|
||||
@Controller('exams')
|
||||
export class ExamsController {
|
||||
constructor(
|
||||
private readonly service: ExamsService,
|
||||
private readonly logService: OperationLogsService,
|
||||
) {}
|
||||
|
||||
private canManageAll(req: AuthenticatedRequest) {
|
||||
return req.user.isSuperAdmin || req.user.permissions.includes('exam:edit');
|
||||
}
|
||||
|
||||
@Get()
|
||||
@RequirePermission('exam:view')
|
||||
async findAll(@Query() query: QueryExamDto, @Request() req: AuthenticatedRequest) {
|
||||
const classIds = await this.service.getAccessibleClassIds(req.user.id, this.canManageAll(req));
|
||||
return this.service.findAll(query, classIds);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('exam:view')
|
||||
findOne(@Param('id', ParseIntPipe) id: number, @Request() req: AuthenticatedRequest) {
|
||||
return this.service.findOne(id, req.user.id, this.canManageAll(req));
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequirePermission('exam:view')
|
||||
async create(@Body() dto: CreateExamDto, @Request() req: AuthenticatedRequest) {
|
||||
const result = await this.service.create(dto, req.user.id, this.canManageAll(req));
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
await this.logService.log({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
module: '考试管理',
|
||||
action: '创建考试',
|
||||
targetId: result.id,
|
||||
targetType: 'exam',
|
||||
detail: `${dto.examName} - ${dto.subject}`,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Put(':examId/scores/:scoreId')
|
||||
@RequirePermission('exam:view')
|
||||
async updateScore(
|
||||
@Param('examId', ParseIntPipe) examId: number,
|
||||
@Param('scoreId', ParseIntPipe) scoreId: number,
|
||||
@Body() dto: UpdateExamScoreValueDto,
|
||||
@Request() req: AuthenticatedRequest,
|
||||
) {
|
||||
const result = await this.service.updateScore(
|
||||
examId,
|
||||
scoreId,
|
||||
dto.score,
|
||||
req.user.id,
|
||||
this.canManageAll(req),
|
||||
);
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
await this.logService.log({
|
||||
userId: req.user.id,
|
||||
username: req.user.username,
|
||||
module: '考试管理',
|
||||
action: dto.score === null || dto.score === undefined ? '清空成绩' : '录入成绩',
|
||||
targetId: scoreId,
|
||||
targetType: 'exam_score',
|
||||
detail: dto.score === null || dto.score === undefined ? '成绩已清空' : `成绩:${dto.score}`,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
}
|
||||
16
apps/server/src/exams/exams.module.ts
Normal file
16
apps/server/src/exams/exams.module.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Class, ClassStudent, ClassTeacher, Exam, ExamScore, Student } from '../entities';
|
||||
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
|
||||
import { ExamsController } from './exams.controller';
|
||||
import { ExamsService } from './exams.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([Exam, ExamScore, Class, ClassStudent, ClassTeacher, Student]),
|
||||
OperationLogsModule,
|
||||
],
|
||||
controllers: [ExamsController],
|
||||
providers: [ExamsService],
|
||||
})
|
||||
export class ExamsModule {}
|
||||
127
apps/server/src/exams/exams.service.spec.ts
Normal file
127
apps/server/src/exams/exams.service.spec.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { ExamScore } from '../entities';
|
||||
import { ExamsService } from './exams.service';
|
||||
|
||||
function createService(transaction: (run: (manager: any) => Promise<unknown>) => Promise<unknown>) {
|
||||
return new ExamsService(
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ findOne: jest.fn().mockResolvedValue({ id: 1 }) } as never,
|
||||
{ transaction } as never,
|
||||
);
|
||||
}
|
||||
|
||||
describe('ExamsService', () => {
|
||||
it('creates score rows from the active class roster snapshot', async () => {
|
||||
const members = [
|
||||
{ studentId: 11, status: 'active' },
|
||||
{ studentId: 12, status: 'active' },
|
||||
];
|
||||
const manager = {
|
||||
findOne: jest.fn().mockResolvedValue({ id: 3, isArchived: false }),
|
||||
find: jest.fn().mockResolvedValue(members),
|
||||
create: jest.fn((_entity, value) => value),
|
||||
save: jest.fn(async (entity, value) =>
|
||||
entity.name === 'Exam' ? { ...value, id: 9 } : value,
|
||||
),
|
||||
};
|
||||
const service = createService(async (run) => run(manager));
|
||||
|
||||
await service.create(
|
||||
{
|
||||
examType: '月考',
|
||||
examName: '七月月考',
|
||||
subject: '数学',
|
||||
examDate: '2026-07-21',
|
||||
classId: 3,
|
||||
},
|
||||
1,
|
||||
true,
|
||||
);
|
||||
|
||||
expect(manager.find).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ where: { classId: 3, status: 'active' } }),
|
||||
);
|
||||
expect(manager.save).toHaveBeenCalledWith(
|
||||
ExamScore,
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ examId: 9, studentId: 11, score: null }),
|
||||
expect.objectContaining({ examId: 9, studentId: 12, score: null }),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it('rejects creating an exam for an empty class', async () => {
|
||||
const manager = {
|
||||
findOne: jest.fn().mockResolvedValue({ id: 3, isArchived: false }),
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
};
|
||||
const service = createService(async (run) => run(manager));
|
||||
|
||||
await expect(
|
||||
service.create(
|
||||
{
|
||||
examType: '月考',
|
||||
examName: '七月月考',
|
||||
subject: '数学',
|
||||
examDate: '2026-07-21',
|
||||
classId: 3,
|
||||
},
|
||||
1,
|
||||
true,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
|
||||
it('counts zero, ignores empty scores, and uses competition ranking', async () => {
|
||||
const rows = [
|
||||
{ id: 1, score: 90, classAvg: null, rank: null },
|
||||
{ id: 2, score: 90, classAvg: null, rank: null },
|
||||
{ id: 3, score: 60, classAvg: null, rank: null },
|
||||
{ id: 4, score: 0, classAvg: null, rank: null },
|
||||
{ id: 5, score: null, classAvg: null, rank: null },
|
||||
];
|
||||
const manager = {
|
||||
findOne: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: 8, classId: 3, status: 'active' })
|
||||
.mockResolvedValueOnce(rows[0])
|
||||
.mockResolvedValueOnce(rows[0]),
|
||||
find: jest.fn().mockResolvedValue(rows),
|
||||
save: jest.fn(async (_entity, value) => value),
|
||||
};
|
||||
const service = createService(async (run) => run(manager));
|
||||
|
||||
await service.updateScore(8, 1, 90, 1, true);
|
||||
|
||||
expect(rows.map((row) => row.rank)).toEqual([1, 1, 3, 4, null]);
|
||||
expect(rows.map((row) => row.classAvg)).toEqual([60, 60, 60, 60, 60]);
|
||||
});
|
||||
|
||||
it('clears a score and recalculates the remaining rows', async () => {
|
||||
const rows = [
|
||||
{ id: 1, score: 90, classAvg: 80, rank: 1 },
|
||||
{ id: 2, score: 70, classAvg: 80, rank: 2 },
|
||||
];
|
||||
const manager = {
|
||||
findOne: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: 8, classId: 3, status: 'active' })
|
||||
.mockResolvedValueOnce(rows[0])
|
||||
.mockResolvedValueOnce(rows[0]),
|
||||
find: jest.fn().mockResolvedValue(rows),
|
||||
save: jest.fn(async (_entity, value) => value),
|
||||
};
|
||||
const service = createService(async (run) => run(manager));
|
||||
|
||||
await service.updateScore(8, 1, null, 1, true);
|
||||
|
||||
expect(rows).toEqual([
|
||||
expect.objectContaining({ score: null, classAvg: 70, rank: null }),
|
||||
expect.objectContaining({ score: 70, classAvg: 70, rank: 1 }),
|
||||
]);
|
||||
});
|
||||
});
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { InitialSchema1784520727860 } from './migrations/1784520727860-InitialSchema';
|
||||
import { AddExamManagement1784600000000 } from './migrations/1784600000000-AddExamManagement';
|
||||
import { config } from 'dotenv';
|
||||
|
||||
config();
|
||||
@@ -18,7 +19,7 @@ export async function runMigrationsOnStartup(): Promise<void> {
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
database: process.env.DB_DATABASE || 'dorm_billing',
|
||||
charset: 'utf8mb4',
|
||||
migrations: [InitialSchema1784520727860],
|
||||
migrations: [InitialSchema1784520727860, AddExamManagement1784600000000],
|
||||
});
|
||||
|
||||
await ds.initialize();
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
import { MigrationInterface, QueryRunner, Table, TableColumn, TableForeignKey, TableIndex } from 'typeorm';
|
||||
|
||||
export class AddExamManagement1784600000000 implements MigrationInterface {
|
||||
async up(queryRunner: QueryRunner): Promise<void> {
|
||||
if (!(await queryRunner.hasTable('exams'))) {
|
||||
await queryRunner.createTable(
|
||||
new Table({
|
||||
name: 'exams',
|
||||
columns: [
|
||||
{ name: 'id', type: 'integer', isPrimary: true, isGenerated: true, generationStrategy: 'increment' },
|
||||
{ name: 'exam_type', type: 'varchar', length: '50' },
|
||||
{ name: 'exam_name', type: 'varchar', length: '100' },
|
||||
{ name: 'subject', type: 'varchar', length: '50' },
|
||||
{ name: 'exam_date', type: 'date' },
|
||||
{ name: 'class_id', type: 'integer' },
|
||||
{ name: 'status', type: 'varchar', length: '20', default: "'active'" },
|
||||
{ name: 'created_at', type: 'datetime', default: 'CURRENT_TIMESTAMP' },
|
||||
{ name: 'updated_at', type: 'datetime', default: 'CURRENT_TIMESTAMP' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
await queryRunner.createForeignKey(
|
||||
'exams',
|
||||
new TableForeignKey({
|
||||
columnNames: ['class_id'],
|
||||
referencedTableName: 'classes',
|
||||
referencedColumnNames: ['id'],
|
||||
}),
|
||||
);
|
||||
await queryRunner.createIndex('exams', new TableIndex({ columnNames: ['class_id', 'status'] }));
|
||||
}
|
||||
|
||||
if (!(await queryRunner.hasColumn('exam_scores', 'exam_id'))) {
|
||||
await queryRunner.addColumn(
|
||||
'exam_scores',
|
||||
new TableColumn({ name: 'exam_id', type: 'integer', isNullable: true }),
|
||||
);
|
||||
await queryRunner.createForeignKey(
|
||||
'exam_scores',
|
||||
new TableForeignKey({
|
||||
columnNames: ['exam_id'],
|
||||
referencedTableName: 'exams',
|
||||
referencedColumnNames: ['id'],
|
||||
onDelete: 'CASCADE',
|
||||
}),
|
||||
);
|
||||
await queryRunner.createIndex('exam_scores', new TableIndex({ columnNames: ['exam_id'] }));
|
||||
}
|
||||
}
|
||||
|
||||
async down(queryRunner: QueryRunner): Promise<void> {
|
||||
if (await queryRunner.hasColumn('exam_scores', 'exam_id')) {
|
||||
const table = await queryRunner.getTable('exam_scores');
|
||||
const foreignKey = table?.foreignKeys.find((key) => key.columnNames.includes('exam_id'));
|
||||
if (foreignKey) await queryRunner.dropForeignKey('exam_scores', foreignKey);
|
||||
const index = table?.indices.find((item) => item.columnNames.includes('exam_id'));
|
||||
if (index) await queryRunner.dropIndex('exam_scores', index);
|
||||
await queryRunner.dropColumn('exam_scores', 'exam_id');
|
||||
}
|
||||
if (await queryRunner.hasTable('exams')) await queryRunner.dropTable('exams');
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,7 @@ const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> =
|
||||
{ code: 'student:delete', name: '归档学生', group: 'student' },
|
||||
{ code: 'student:import', name: '导入学生', group: 'student' },
|
||||
{ code: 'student:export', name: '导出学生', group: 'student' },
|
||||
{ code: 'exam:view', name: '查看和录入考试成绩', group: 'exam' },
|
||||
{ code: 'room:view', name: '查看宿舍', group: 'room' },
|
||||
{ code: 'room:create', name: '新增宿舍', group: 'room' },
|
||||
{ code: 'room:edit', name: '编辑宿舍', group: 'room' },
|
||||
@@ -203,6 +204,7 @@ export const PRESET_ROLES: Array<{
|
||||
isSystem: true,
|
||||
permissionGroups: [
|
||||
'student',
|
||||
'exam',
|
||||
'class',
|
||||
'schedule',
|
||||
'attendance',
|
||||
|
||||
Reference in New Issue
Block a user