refactor(classes): preserve student membership history
This commit is contained in:
@@ -19,6 +19,7 @@ interface ClassStudent {
|
|||||||
studentName: string;
|
studentName: string;
|
||||||
studentNo: string;
|
studentNo: string;
|
||||||
joinDate: string;
|
joinDate: string;
|
||||||
|
leaveDate: string | null;
|
||||||
status: string;
|
status: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -305,6 +306,7 @@ const ClassDetailPage: React.FC = () => {
|
|||||||
{ title: '姓名', dataIndex: 'studentName' },
|
{ title: '姓名', dataIndex: 'studentName' },
|
||||||
{ title: '学号', dataIndex: 'studentNo' },
|
{ title: '学号', dataIndex: 'studentNo' },
|
||||||
{ title: '加入日期', dataIndex: 'joinDate' },
|
{ title: '加入日期', dataIndex: 'joinDate' },
|
||||||
|
{ title: '离班日期', dataIndex: 'leaveDate', render: (v: string | null) => v || '-' },
|
||||||
{
|
{
|
||||||
title: '状态',
|
title: '状态',
|
||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
@@ -316,11 +318,12 @@ const ClassDetailPage: React.FC = () => {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '操作',
|
title: '操作',
|
||||||
render: (_: unknown, r: ClassStudent) => (
|
render: (_: unknown, r: ClassStudent) =>
|
||||||
|
r.status === 'active' ? (
|
||||||
<Popconfirm title="确认移除?" onConfirm={() => handleRemoveStudent(r.studentId)}>
|
<Popconfirm title="确认移除?" onConfirm={() => handleRemoveStudent(r.studentId)}>
|
||||||
<PermissionButton permission="class:edit" size="small" danger>移除</PermissionButton>
|
<PermissionButton permission="class:edit" size="small" danger>移除</PermissionButton>
|
||||||
</Popconfirm>
|
</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
|
// 3. Fetch existing class-student links in one query
|
||||||
const allStudentIds = Array.from(dingToStudentId.values());
|
const allStudentIds = Array.from(new Set(dingToStudentId.values()));
|
||||||
const alreadyInClass = new Set<number>();
|
const existingClassStudents =
|
||||||
if (allStudentIds.length > 0) {
|
allStudentIds.length > 0
|
||||||
const existingClassStudents = await this.classStudentRepo.find({
|
? await this.classStudentRepo.find({
|
||||||
where: { classId, studentId: In(allStudentIds) },
|
where: { classId, studentId: In(allStudentIds) },
|
||||||
});
|
})
|
||||||
for (const cs of existingClassStudents) {
|
: [];
|
||||||
alreadyInClass.add(cs.studentId);
|
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 [];
|
||||||
}
|
}
|
||||||
|
if (existing) {
|
||||||
|
existing.status = 'active';
|
||||||
|
existing.joinDate = today;
|
||||||
|
existing.leaveDate = null;
|
||||||
|
return [existing];
|
||||||
}
|
}
|
||||||
|
return [
|
||||||
// 4. Batch insert new class-student records
|
|
||||||
const newClassStudents = allStudentIds
|
|
||||||
.filter((sid) => !alreadyInClass.has(sid))
|
|
||||||
.map((studentId) =>
|
|
||||||
this.classStudentRepo.create({
|
this.classStudentRepo.create({
|
||||||
classId,
|
classId,
|
||||||
studentId,
|
studentId,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
joinDate: new Date().toISOString().slice(0, 10),
|
joinDate: today,
|
||||||
}),
|
}),
|
||||||
);
|
];
|
||||||
|
});
|
||||||
|
|
||||||
if (newClassStudents.length > 0) {
|
if (memberships.length > 0) {
|
||||||
await this.classStudentRepo.save(newClassStudents);
|
await this.classStudentRepo.save(memberships);
|
||||||
}
|
}
|
||||||
|
|
||||||
return { imported: newClassStudents.length, skipped: alreadyInClass.size };
|
return { imported: memberships.length, skipped };
|
||||||
}
|
}
|
||||||
async update(id: number, dto: UpdateClassDto) {
|
async update(id: number, dto: UpdateClassDto) {
|
||||||
const cls = await this.classRepo.findOne({ where: { id } });
|
const cls = await this.classRepo.findOne({ where: { id } });
|
||||||
@@ -315,26 +326,61 @@ export class ClassesService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async addStudents(classId: number, studentIds: number[]) {
|
async addStudents(classId: number, studentIds: number[]) {
|
||||||
const existing = await this.classStudentRepo.find({
|
const uniqueStudentIds = [...new Set(studentIds)];
|
||||||
where: { classId, studentId: In(studentIds) },
|
if (uniqueStudentIds.length === 0) return { added: 0, skipped: 0 };
|
||||||
});
|
|
||||||
const existingIds = new Set(existing.map((e) => e.studentId));
|
|
||||||
const newIds = studentIds.filter((id) => !existingIds.has(id));
|
|
||||||
|
|
||||||
const entries = newIds.map((sid) =>
|
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({
|
this.classStudentRepo.create({
|
||||||
classId,
|
classId,
|
||||||
studentId: sid,
|
studentId,
|
||||||
joinDate: new Date().toISOString().split('T')[0],
|
status: 'active',
|
||||||
|
joinDate: today,
|
||||||
}),
|
}),
|
||||||
);
|
];
|
||||||
if (entries.length) await this.classStudentRepo.save(entries);
|
});
|
||||||
|
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) {
|
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 };
|
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.protectAttendanceHistory();
|
||||||
await this.removeUnusedClassroomColumns();
|
await this.removeUnusedClassroomColumns();
|
||||||
await this.cleanupDepositRefundColumns();
|
await this.cleanupDepositRefundColumns();
|
||||||
|
await this.removeUnusedClassStudentColumns();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async removeUnusedClassroomColumns(): Promise<void> {
|
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> {
|
private async ensureAiConfigTable(): Promise<void> {
|
||||||
const runner = this.dataSource.createQueryRunner();
|
const runner = this.dataSource.createQueryRunner();
|
||||||
await runner.connect();
|
await runner.connect();
|
||||||
|
|||||||
@@ -30,14 +30,11 @@ export class ClassStudent {
|
|||||||
@JoinColumn({ name: 'student_id' })
|
@JoinColumn({ name: 'student_id' })
|
||||||
student: Student;
|
student: Student;
|
||||||
|
|
||||||
@Column({ name: 'enrollment_id', type: 'integer', nullable: true })
|
|
||||||
enrollmentId: number;
|
|
||||||
|
|
||||||
@Column({ name: 'join_date', type: 'date', nullable: true })
|
@Column({ name: 'join_date', type: 'date', nullable: true })
|
||||||
joinDate: string;
|
joinDate: string | null;
|
||||||
|
|
||||||
@Column({ name: 'leave_date', type: 'date', nullable: true })
|
@Column({ name: 'leave_date', type: 'date', nullable: true })
|
||||||
leaveDate: string;
|
leaveDate: string | null;
|
||||||
|
|
||||||
@Column({ name: 'status', length: 10, default: 'active' })
|
@Column({ name: 'status', length: 10, default: 'active' })
|
||||||
status: string;
|
status: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user