refactor(classes): preserve student membership history

This commit is contained in:
2026-07-13 14:38:34 +08:00
parent 1f32d1285b
commit 0533c30ece
7 changed files with 303 additions and 42 deletions

View File

@@ -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,
},
];

View File

@@ -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]);
});
});

View 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 })]),
);
});
});

View File

@@ -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 };
}

View File

@@ -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();
});
});

View File

@@ -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();

View File

@@ -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;