refactor(classes): preserve student membership history
This commit is contained in:
@@ -19,6 +19,7 @@ interface ClassStudent {
|
||||
studentName: string;
|
||||
studentNo: string;
|
||||
joinDate: string;
|
||||
leaveDate: string | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
@@ -305,6 +306,7 @@ const ClassDetailPage: React.FC = () => {
|
||||
{ title: '姓名', dataIndex: 'studentName' },
|
||||
{ title: '学号', dataIndex: 'studentNo' },
|
||||
{ title: '加入日期', dataIndex: 'joinDate' },
|
||||
{ title: '离班日期', dataIndex: 'leaveDate', render: (v: string | null) => v || '-' },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
@@ -316,11 +318,12 @@ const ClassDetailPage: React.FC = () => {
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
render: (_: unknown, r: ClassStudent) => (
|
||||
<Popconfirm title="确认移除?" onConfirm={() => handleRemoveStudent(r.studentId)}>
|
||||
<PermissionButton permission="class:edit" size="small" danger>移除</PermissionButton>
|
||||
</Popconfirm>
|
||||
),
|
||||
render: (_: unknown, r: ClassStudent) =>
|
||||
r.status === 'active' ? (
|
||||
<Popconfirm title="确认移除?" onConfirm={() => handleRemoveStudent(r.studentId)}>
|
||||
<PermissionButton permission="class:edit" size="small" danger>移除</PermissionButton>
|
||||
</Popconfirm>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import { ClassesService } from './classes.service';
|
||||
import { ClassStudent } from '../entities';
|
||||
|
||||
describe('ClassesService — DingTalk class import membership lifecycle', () => {
|
||||
it('reactivates left memberships and skips active memberships', async () => {
|
||||
const left = {
|
||||
classId: 3,
|
||||
studentId: 8,
|
||||
status: 'left',
|
||||
joinDate: '2026-01-01',
|
||||
leaveDate: '2026-02-01',
|
||||
} as ClassStudent;
|
||||
const active = { classId: 3, studentId: 9, status: 'active' } as ClassStudent;
|
||||
const classStudentRepo = {
|
||||
find: jest.fn().mockResolvedValue([left, active]),
|
||||
create: jest.fn().mockImplementation((value: Partial<ClassStudent>) => value),
|
||||
save: jest.fn().mockImplementation(async (value: ClassStudent[]) => value),
|
||||
};
|
||||
const service = new ClassesService(
|
||||
{ findOne: jest.fn().mockResolvedValue({ id: 3 }) } as never,
|
||||
classStudentRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ create: jest.fn(), save: jest.fn() } as never,
|
||||
{
|
||||
find: jest.fn().mockResolvedValue([
|
||||
{ dingUserId: 'd8', studentId: 8 },
|
||||
{ dingUserId: 'd9', studentId: 9 },
|
||||
]),
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
} as never,
|
||||
);
|
||||
|
||||
const result = await service.batchImportStudents(3, [
|
||||
{ dingUserId: 'd8', name: '学生8' },
|
||||
{ dingUserId: 'd9', name: '学生9' },
|
||||
]);
|
||||
|
||||
expect(result).toEqual({ imported: 1, skipped: 1 });
|
||||
expect(left).toMatchObject({ status: 'active', leaveDate: null });
|
||||
expect(left.joinDate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(classStudentRepo.save).toHaveBeenCalledWith([left]);
|
||||
});
|
||||
});
|
||||
95
apps/server/src/classes/classes.membership.spec.ts
Normal file
95
apps/server/src/classes/classes.membership.spec.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { ClassesService } from './classes.service';
|
||||
import { ClassStudent } from '../entities';
|
||||
|
||||
function createService(
|
||||
classStudentRepo: Record<string, jest.Mock>,
|
||||
classRepo: Record<string, jest.Mock> = { findOne: jest.fn().mockResolvedValue({ id: 3 }) },
|
||||
studentRepo: Record<string, jest.Mock> = {
|
||||
find: jest.fn().mockResolvedValue([{ id: 8 }, { id: 9 }, { id: 10 }]),
|
||||
},
|
||||
) {
|
||||
return new ClassesService(
|
||||
classRepo as never,
|
||||
classStudentRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
studentRepo as never,
|
||||
{} as never,
|
||||
);
|
||||
}
|
||||
|
||||
describe('ClassesService — student membership lifecycle', () => {
|
||||
it('marks an active membership as left instead of deleting it', async () => {
|
||||
const membership = {
|
||||
classId: 3,
|
||||
studentId: 8,
|
||||
status: 'active',
|
||||
leaveDate: null,
|
||||
} as ClassStudent;
|
||||
const repo = {
|
||||
findOne: jest.fn().mockResolvedValue(membership),
|
||||
save: jest.fn().mockImplementation(async (value: ClassStudent) => value),
|
||||
};
|
||||
const service = createService(repo);
|
||||
|
||||
await service.removeStudent(3, 8);
|
||||
|
||||
expect(membership.status).toBe('left');
|
||||
expect(membership.leaveDate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(repo.save).toHaveBeenCalledWith(membership);
|
||||
});
|
||||
|
||||
it('rejects removing an already-left membership', async () => {
|
||||
const repo = {
|
||||
findOne: jest.fn().mockResolvedValue({ status: 'left' }),
|
||||
save: jest.fn(),
|
||||
};
|
||||
const service = createService(repo);
|
||||
|
||||
await expect(service.removeStudent(3, 8)).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(repo.save).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rejects removing a student without a membership', async () => {
|
||||
const repo = {
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
save: jest.fn(),
|
||||
};
|
||||
const service = createService(repo);
|
||||
|
||||
await expect(service.removeStudent(3, 8)).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('reactivates left memberships, creates new ones, and skips active ones', async () => {
|
||||
const left = {
|
||||
id: 1,
|
||||
classId: 3,
|
||||
studentId: 8,
|
||||
status: 'left',
|
||||
joinDate: '2026-01-01',
|
||||
leaveDate: '2026-02-01',
|
||||
} as ClassStudent;
|
||||
const active = { id: 2, classId: 3, studentId: 9, status: 'active' } as ClassStudent;
|
||||
const repo = {
|
||||
find: jest.fn().mockResolvedValue([left, active]),
|
||||
create: jest.fn().mockImplementation((value: Partial<ClassStudent>) => value),
|
||||
save: jest.fn().mockImplementation(async (value: ClassStudent[]) => value),
|
||||
};
|
||||
const service = createService(repo);
|
||||
|
||||
const result = await service.addStudents(3, [8, 9, 10, 10]);
|
||||
|
||||
expect(result).toEqual({ added: 2, skipped: 1 });
|
||||
expect(left).toMatchObject({ status: 'active', leaveDate: null });
|
||||
expect(left.joinDate).toMatch(/^\d{4}-\d{2}-\d{2}$/);
|
||||
expect(repo.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ classId: 3, studentId: 10, status: 'active' }),
|
||||
);
|
||||
expect(repo.save).toHaveBeenCalledWith(
|
||||
expect.arrayContaining([left, expect.objectContaining({ studentId: 10 })]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -227,34 +227,45 @@ export class ClassesService {
|
||||
}
|
||||
|
||||
// 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);
|
||||
const allStudentIds = Array.from(new Set(dingToStudentId.values()));
|
||||
const existingClassStudents =
|
||||
allStudentIds.length > 0
|
||||
? await this.classStudentRepo.find({
|
||||
where: { classId, studentId: In(allStudentIds) },
|
||||
})
|
||||
: [];
|
||||
const existingByStudentId = new Map(
|
||||
existingClassStudents.map((classStudent) => [classStudent.studentId, classStudent]),
|
||||
);
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
let skipped = 0;
|
||||
const memberships = allStudentIds.flatMap((studentId) => {
|
||||
const existing = existingByStudentId.get(studentId);
|
||||
if (existing?.status === 'active') {
|
||||
skipped++;
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Batch insert new class-student records
|
||||
const newClassStudents = allStudentIds
|
||||
.filter((sid) => !alreadyInClass.has(sid))
|
||||
.map((studentId) =>
|
||||
if (existing) {
|
||||
existing.status = 'active';
|
||||
existing.joinDate = today;
|
||||
existing.leaveDate = null;
|
||||
return [existing];
|
||||
}
|
||||
return [
|
||||
this.classStudentRepo.create({
|
||||
classId,
|
||||
studentId,
|
||||
status: 'active',
|
||||
joinDate: new Date().toISOString().slice(0, 10),
|
||||
joinDate: today,
|
||||
}),
|
||||
);
|
||||
];
|
||||
});
|
||||
|
||||
if (newClassStudents.length > 0) {
|
||||
await this.classStudentRepo.save(newClassStudents);
|
||||
if (memberships.length > 0) {
|
||||
await this.classStudentRepo.save(memberships);
|
||||
}
|
||||
|
||||
return { imported: newClassStudents.length, skipped: alreadyInClass.size };
|
||||
return { imported: memberships.length, skipped };
|
||||
}
|
||||
async update(id: number, dto: UpdateClassDto) {
|
||||
const cls = await this.classRepo.findOne({ where: { id } });
|
||||
@@ -315,26 +326,61 @@ export class ClassesService {
|
||||
}
|
||||
|
||||
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(studentIds) },
|
||||
where: { classId, studentId: In(uniqueStudentIds) },
|
||||
});
|
||||
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],
|
||||
}),
|
||||
const existingByStudentId = new Map(
|
||||
existing.map((classStudent) => [classStudent.studentId, classStudent]),
|
||||
);
|
||||
if (entries.length) await this.classStudentRepo.save(entries);
|
||||
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: entries.length, skipped: studentIds.length - entries.length };
|
||||
return { added: memberships.length, skipped };
|
||||
}
|
||||
|
||||
async removeStudent(classId: number, studentId: number) {
|
||||
await this.classStudentRepo.delete({ classId, studentId });
|
||||
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 };
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getDataSourceToken } from '@nestjs/typeorm';
|
||||
import { DatabaseMigrationsService } from './database-migrations.service';
|
||||
|
||||
function createRunner(tableExists: boolean, columns: string[] = []) {
|
||||
return {
|
||||
connect: jest.fn(),
|
||||
release: jest.fn(),
|
||||
getTables: jest.fn().mockResolvedValue(tableExists ? [{ name: 'class_student' }] : []),
|
||||
getTable: jest.fn().mockResolvedValue({
|
||||
name: 'class_student',
|
||||
columns: columns.map((name) => ({ name })),
|
||||
}),
|
||||
dropColumn: jest.fn().mockResolvedValue(undefined),
|
||||
};
|
||||
}
|
||||
|
||||
async function createService(runner: ReturnType<typeof createRunner>) {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
DatabaseMigrationsService,
|
||||
{
|
||||
provide: getDataSourceToken(),
|
||||
useValue: {
|
||||
options: { type: 'better-sqlite3' },
|
||||
createQueryRunner: jest.fn().mockReturnValue(runner),
|
||||
},
|
||||
},
|
||||
],
|
||||
}).compile();
|
||||
return module.get(DatabaseMigrationsService) as DatabaseMigrationsService & {
|
||||
removeUnusedClassStudentColumns(): Promise<void>;
|
||||
};
|
||||
}
|
||||
|
||||
describe('DatabaseMigrationsService — class student cleanup', () => {
|
||||
it('drops the unused enrollment_id column', async () => {
|
||||
const runner = createRunner(true, ['id', 'enrollment_id']);
|
||||
const service = await createService(runner);
|
||||
|
||||
await service.removeUnusedClassStudentColumns();
|
||||
|
||||
expect(runner.dropColumn).toHaveBeenCalledWith('class_student', 'enrollment_id');
|
||||
expect(runner.release).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when the table is absent', async () => {
|
||||
const runner = createRunner(false);
|
||||
const service = await createService(runner);
|
||||
|
||||
await service.removeUnusedClassStudentColumns();
|
||||
|
||||
expect(runner.dropColumn).not.toHaveBeenCalled();
|
||||
expect(runner.release).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
||||
await this.protectAttendanceHistory();
|
||||
await this.removeUnusedClassroomColumns();
|
||||
await this.cleanupDepositRefundColumns();
|
||||
await this.removeUnusedClassStudentColumns();
|
||||
}
|
||||
|
||||
private async removeUnusedClassroomColumns(): Promise<void> {
|
||||
@@ -75,6 +76,22 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
||||
}
|
||||
}
|
||||
|
||||
private async removeUnusedClassStudentColumns(): Promise<void> {
|
||||
const runner = this.dataSource.createQueryRunner();
|
||||
await runner.connect();
|
||||
try {
|
||||
const tables = await runner.getTables(['class_student']);
|
||||
if (tables.length === 0) return;
|
||||
|
||||
const table = await runner.getTable('class_student');
|
||||
if (table?.columns.some((column) => column.name === 'enrollment_id')) {
|
||||
await runner.dropColumn('class_student', 'enrollment_id');
|
||||
}
|
||||
} finally {
|
||||
await runner.release();
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureAiConfigTable(): Promise<void> {
|
||||
const runner = this.dataSource.createQueryRunner();
|
||||
await runner.connect();
|
||||
|
||||
@@ -30,14 +30,11 @@ export class ClassStudent {
|
||||
@JoinColumn({ name: 'student_id' })
|
||||
student: Student;
|
||||
|
||||
@Column({ name: 'enrollment_id', type: 'integer', nullable: true })
|
||||
enrollmentId: number;
|
||||
|
||||
@Column({ name: 'join_date', type: 'date', nullable: true })
|
||||
joinDate: string;
|
||||
joinDate: string | null;
|
||||
|
||||
@Column({ name: 'leave_date', type: 'date', nullable: true })
|
||||
leaveDate: string;
|
||||
leaveDate: string | null;
|
||||
|
||||
@Column({ name: 'status', length: 10, default: 'active' })
|
||||
status: string;
|
||||
|
||||
Reference in New Issue
Block a user