feat(server): 报读记录关联班级 classId 与花名册单向同步
- student_enrollments 新增 class_id 列(迁移幂等),报读/换班/归档/删除时同步 class_student 成员状态 - 班级名以班级表为准快照,防止前后端漂移;班级不存在直接 400 - 迁移 runner 改 glob 自动发现(ts-node/dist 均可),新增迁移文件免手改清单
This commit is contained in:
@@ -12,6 +12,8 @@ function createService(repos: Partial<Record<string, Record<string, jest.Mock>>>
|
||||
(repos.attachment ?? {}) as never,
|
||||
(repos.attendance ?? {}) as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
255
apps/server/src/archive/archive.enrollment-roster.spec.ts
Normal file
255
apps/server/src/archive/archive.enrollment-roster.spec.ts
Normal file
@@ -0,0 +1,255 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { ArchiveService } from './archive.service';
|
||||
import { ClassStudent, StudentEnrollment } from '../entities';
|
||||
|
||||
/** 内存花名册 + manager mock:findOne/save 读写 roster 数组,count 由测试控制。 */
|
||||
function makeManager(roster: Array<Record<string, unknown>> = [], otherActiveCount = 0) {
|
||||
const memberSet = new WeakSet<object>();
|
||||
const manager = {
|
||||
roster,
|
||||
findOne: jest.fn(async (Entity: unknown, opts: { where?: Record<string, unknown> }) => {
|
||||
if (Entity !== ClassStudent) return null;
|
||||
const w = opts?.where ?? {};
|
||||
return (
|
||||
roster.find(
|
||||
(r) =>
|
||||
(w.classId === undefined || r.classId === w.classId) &&
|
||||
(w.studentId === undefined || r.studentId === w.studentId) &&
|
||||
(w.status === undefined || r.status === w.status),
|
||||
) ?? null
|
||||
);
|
||||
}),
|
||||
create: jest.fn((Entity: unknown, data: Record<string, unknown>) => {
|
||||
const row = { ...data };
|
||||
if (Entity === ClassStudent) memberSet.add(row);
|
||||
return row;
|
||||
}),
|
||||
save: jest.fn(async (row: Record<string, unknown>) => {
|
||||
if (memberSet.has(row)) {
|
||||
const idx = roster.findIndex(
|
||||
(r) => r.classId === row.classId && r.studentId === row.studentId,
|
||||
);
|
||||
if (idx >= 0) roster[idx] = { ...roster[idx], ...row };
|
||||
else roster.push({ ...row });
|
||||
}
|
||||
return row;
|
||||
}),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
getRepository: jest.fn().mockReturnValue({
|
||||
count: jest.fn().mockResolvedValue(otherActiveCount),
|
||||
}),
|
||||
};
|
||||
return manager;
|
||||
}
|
||||
|
||||
function createService(opts: {
|
||||
student?: unknown;
|
||||
classRepo?: unknown;
|
||||
enrollment?: unknown;
|
||||
examScore?: unknown;
|
||||
manager?: ReturnType<typeof makeManager>;
|
||||
}) {
|
||||
const manager = opts.manager ?? makeManager();
|
||||
const dataSource = {
|
||||
transaction: jest.fn(async (fn: (m: unknown) => Promise<unknown>) => fn(manager)),
|
||||
};
|
||||
const service = new ArchiveService(
|
||||
{ findOne: jest.fn().mockResolvedValue(opts.student ?? { id: 7 }) } as never,
|
||||
{} as never,
|
||||
(opts.enrollment ?? { findOne: jest.fn() }) as never,
|
||||
(opts.examScore ?? { count: jest.fn().mockResolvedValue(0) }) as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
(opts.classRepo ?? { findOne: jest.fn().mockResolvedValue({ id: 5, name: '冲刺班' }) }) as never,
|
||||
{} as never,
|
||||
dataSource as never,
|
||||
);
|
||||
return { service, manager, dataSource };
|
||||
}
|
||||
|
||||
const baseDto = { courseCategory: '文化', classType: '冲刺' };
|
||||
|
||||
describe('ArchiveService enrollment → roster sync', () => {
|
||||
it('adds an active enrollment with classId: class name snapshotted, member joined with joinDate', async () => {
|
||||
const { service, manager } = createService({});
|
||||
const saved = await service.addEnrollment(7, { ...baseDto, classId: 5, startDate: '2026-08-01', className: '旧名字' });
|
||||
|
||||
expect(saved).toMatchObject({ classId: 5, className: '冲刺班', status: 'active' });
|
||||
const memberCreates = manager.create.mock.calls.filter(([E]) => E === ClassStudent);
|
||||
expect(memberCreates).toHaveLength(1);
|
||||
expect(memberCreates[0][1]).toEqual({
|
||||
classId: 5,
|
||||
studentId: 7,
|
||||
status: 'active',
|
||||
joinDate: '2026-08-01',
|
||||
});
|
||||
expect(manager.roster).toEqual([
|
||||
expect.objectContaining({ classId: 5, studentId: 7, status: 'active', joinDate: '2026-08-01' }),
|
||||
]);
|
||||
});
|
||||
|
||||
it('adds without classId: no roster write at all', async () => {
|
||||
const { service, manager } = createService({});
|
||||
await service.addEnrollment(7, { ...baseDto, className: '自由文本' });
|
||||
|
||||
expect(
|
||||
manager.create.mock.calls.filter(([E]) => E === ClassStudent),
|
||||
).toHaveLength(0);
|
||||
expect(manager.roster).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('adds with classId but status completed: no roster join', async () => {
|
||||
const { service, manager } = createService({});
|
||||
const saved = await service.addEnrollment(7, { ...baseDto, classId: 5, status: 'completed' });
|
||||
|
||||
expect(saved.className).toBe('冲刺班');
|
||||
expect(
|
||||
manager.create.mock.calls.filter(([E]) => E === ClassStudent),
|
||||
).toHaveLength(0);
|
||||
expect(manager.roster).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('rejects a classId pointing to a missing class before any transaction', async () => {
|
||||
const { service, dataSource } = createService({
|
||||
classRepo: { findOne: jest.fn().mockResolvedValue(null) },
|
||||
});
|
||||
|
||||
await expect(service.addEnrollment(7, { ...baseDto, classId: 999 })).rejects.toEqual(
|
||||
new BadRequestException('班级不存在'),
|
||||
);
|
||||
expect(dataSource.transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('marks the member left when the same class flips active → completed', async () => {
|
||||
const roster = [
|
||||
{ id: 50, classId: 5, studentId: 7, status: 'active', joinDate: '2026-08-01', leaveDate: null },
|
||||
];
|
||||
const { service, manager } = createService({
|
||||
manager: makeManager(roster),
|
||||
enrollment: {
|
||||
findOne: jest.fn().mockResolvedValue({
|
||||
id: 1,
|
||||
studentId: 7,
|
||||
classId: 5,
|
||||
className: '冲刺班',
|
||||
status: 'active',
|
||||
startDate: '2026-08-01',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
await service.updateEnrollment(1, { status: 'completed' });
|
||||
|
||||
expect(manager.roster[0]).toMatchObject({ classId: 5, studentId: 7, status: 'left' });
|
||||
expect(manager.roster[0].leaveDate).toBeTruthy();
|
||||
});
|
||||
|
||||
it('marks old class left and joins the new class when classId changes', async () => {
|
||||
const roster = [
|
||||
{ id: 50, classId: 5, studentId: 7, status: 'active', joinDate: '2026-08-01', leaveDate: null },
|
||||
];
|
||||
const { service, manager } = createService({
|
||||
manager: makeManager(roster),
|
||||
classRepo: { findOne: jest.fn().mockResolvedValue({ id: 6, name: '强化班' }) },
|
||||
enrollment: {
|
||||
findOne: jest.fn().mockResolvedValue({
|
||||
id: 1,
|
||||
studentId: 7,
|
||||
classId: 5,
|
||||
className: '冲刺班',
|
||||
status: 'active',
|
||||
startDate: '2026-08-01',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
const saved = await service.updateEnrollment(1, { classId: 6 });
|
||||
|
||||
expect(saved).toMatchObject({ classId: 6, className: '强化班', status: 'active' });
|
||||
expect(manager.roster.find((r) => r.classId === 5)).toMatchObject({ status: 'left' });
|
||||
expect(manager.roster.find((r) => r.classId === 6)).toMatchObject({
|
||||
studentId: 7,
|
||||
status: 'active',
|
||||
joinDate: '2026-08-01',
|
||||
});
|
||||
});
|
||||
|
||||
it('archive (soft delete) marks the member left', async () => {
|
||||
const roster = [
|
||||
{ id: 50, classId: 5, studentId: 7, status: 'active', joinDate: '2026-08-01', leaveDate: null },
|
||||
];
|
||||
const { service, manager } = createService({
|
||||
manager: makeManager(roster),
|
||||
enrollment: {
|
||||
findOne: jest.fn().mockResolvedValue({
|
||||
id: 1,
|
||||
studentId: 7,
|
||||
classId: 5,
|
||||
status: 'active',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(service.deleteEnrollment(1)).resolves.toEqual({ message: '已归档' });
|
||||
|
||||
expect(manager.update).toHaveBeenCalledWith(StudentEnrollment, 1, { status: 'archived' });
|
||||
expect(manager.roster[0].status).toBe('left');
|
||||
});
|
||||
|
||||
it('permanent delete marks the member left', async () => {
|
||||
const roster = [
|
||||
{ id: 50, classId: 5, studentId: 7, status: 'active', joinDate: '2026-08-01', leaveDate: null },
|
||||
];
|
||||
const { service, manager } = createService({
|
||||
manager: makeManager(roster),
|
||||
enrollment: {
|
||||
findOne: jest.fn().mockResolvedValue({
|
||||
id: 1,
|
||||
studentId: 7,
|
||||
classId: 5,
|
||||
status: 'archived',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
await expect(service.purgeEnrollment(1)).resolves.toEqual({
|
||||
message: '已永久删除报名记录(不可恢复)',
|
||||
});
|
||||
|
||||
expect(manager.delete).toHaveBeenCalledWith(StudentEnrollment, 1);
|
||||
expect(manager.roster[0].status).toBe('left');
|
||||
});
|
||||
|
||||
it('keeps the member active while another active enrollment still references the class', async () => {
|
||||
const roster = [
|
||||
{ id: 50, classId: 5, studentId: 7, status: 'active', joinDate: '2026-08-01', leaveDate: null },
|
||||
];
|
||||
const { service, manager } = createService({
|
||||
manager: makeManager(roster, /* otherActiveCount */ 1),
|
||||
enrollment: {
|
||||
findOne: jest.fn().mockResolvedValue({
|
||||
id: 1,
|
||||
studentId: 7,
|
||||
classId: 5,
|
||||
className: '冲刺班',
|
||||
status: 'active',
|
||||
startDate: '2026-08-01',
|
||||
}),
|
||||
},
|
||||
});
|
||||
|
||||
await service.updateEnrollment(1, { status: 'completed' });
|
||||
|
||||
expect(manager.roster[0].status).toBe('active');
|
||||
expect(manager.roster[0].leaveDate).toBeNull();
|
||||
});
|
||||
|
||||
it('propagates NotFoundException for missing enrollment on update', async () => {
|
||||
const { service } = createService({ enrollment: { findOne: jest.fn().mockResolvedValue(null) } });
|
||||
await expect(service.updateEnrollment(1, {})).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
});
|
||||
@@ -3,6 +3,8 @@ import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Student } from '../entities/student.entity';
|
||||
import { StudentProfile } from '../entities/student-profile.entity';
|
||||
import { StudentEnrollment } from '../entities/student-enrollment.entity';
|
||||
import { Class } from '../entities/class.entity';
|
||||
import { ClassStudent } from '../entities/class-student.entity';
|
||||
import { ExamScore } from '../entities/exam-score.entity';
|
||||
import { LearningRecord } from '../entities/learning-record.entity';
|
||||
import { ResultArchive } from '../entities/result-archive.entity';
|
||||
@@ -25,6 +27,8 @@ import { ArchiveController } from './archive.controller';
|
||||
ResultArchive,
|
||||
ArchiveAttachment,
|
||||
AttendanceRecord,
|
||||
Class,
|
||||
ClassStudent,
|
||||
]),
|
||||
NotificationsModule,
|
||||
StudentsModule,
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { ArchiveService } from './archive.service';
|
||||
import { StudentEnrollment } from '../entities';
|
||||
|
||||
describe('ArchiveService purge sub-records', () => {
|
||||
const createService = (overrides?: {
|
||||
@@ -46,6 +47,19 @@ describe('ArchiveService purge sub-records', () => {
|
||||
}),
|
||||
delete: jest.fn().mockResolvedValue({ affected: 1 }),
|
||||
};
|
||||
const managerMock = {
|
||||
delete: jest.fn().mockResolvedValue({ affected: 1 }),
|
||||
update: jest.fn().mockResolvedValue({ affected: 1 }),
|
||||
findOne: jest.fn(),
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
getRepository: jest.fn().mockReturnValue({
|
||||
count: jest.fn().mockResolvedValue(0),
|
||||
findOne: jest.fn(),
|
||||
create: jest.fn(),
|
||||
save: jest.fn(),
|
||||
}),
|
||||
};
|
||||
const service = new ArchiveService(
|
||||
{} as never,
|
||||
{} as never,
|
||||
@@ -56,8 +70,11 @@ describe('ArchiveService purge sub-records', () => {
|
||||
attachmentRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ transaction: jest.fn(async (fn: (m: unknown) => Promise<unknown>) => fn(managerMock)) } as never,
|
||||
);
|
||||
return { service, enrollmentRepo, examScoreRepo, learningRecordRepo, attachmentRepo };
|
||||
return { service, managerMock, enrollmentRepo, examScoreRepo, learningRecordRepo, attachmentRepo };
|
||||
};
|
||||
|
||||
it('rejects non-archived sub-records', async () => {
|
||||
@@ -77,7 +94,7 @@ describe('ArchiveService purge sub-records', () => {
|
||||
});
|
||||
|
||||
it('deletes archived enrollment, exam score, and learning record', async () => {
|
||||
const { service, enrollmentRepo, examScoreRepo, learningRecordRepo } = createService();
|
||||
const { service, managerMock, examScoreRepo, learningRecordRepo } = createService();
|
||||
await expect(service.purgeEnrollment(1)).resolves.toEqual({
|
||||
message: '已永久删除报名记录(不可恢复)',
|
||||
});
|
||||
@@ -87,7 +104,7 @@ describe('ArchiveService purge sub-records', () => {
|
||||
await expect(service.purgeLearningRecord(3)).resolves.toEqual({
|
||||
message: '已永久删除学习记录(不可恢复)',
|
||||
});
|
||||
expect(enrollmentRepo.delete).toHaveBeenCalledWith(1);
|
||||
expect(managerMock.delete).toHaveBeenCalledWith(StudentEnrollment, 1);
|
||||
expect(examScoreRepo.delete).toHaveBeenCalledWith(2);
|
||||
expect(learningRecordRepo.delete).toHaveBeenCalledWith(3);
|
||||
});
|
||||
|
||||
@@ -29,6 +29,8 @@ describe('ArchiveService.getProfile', () => {
|
||||
attachmentRepo as never,
|
||||
attendanceRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
const response = await service.getProfile(7);
|
||||
@@ -69,6 +71,8 @@ describe('ArchiveService.getProfile', () => {
|
||||
emptyRepos.attachment as never,
|
||||
emptyRepos.attendance as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
const response = await service.getProfile(7);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { DataSource, EntityManager, Not, Repository } from 'typeorm';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
@@ -8,6 +8,8 @@ import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { Student } from '../entities/student.entity';
|
||||
import { StudentProfile } from '../entities/student-profile.entity';
|
||||
import { StudentEnrollment } from '../entities/student-enrollment.entity';
|
||||
import { Class, ClassStudent } from '../entities';
|
||||
import dayjs from '../common/dayjs';
|
||||
import { ExamScore } from '../entities/exam-score.entity';
|
||||
import { LearningRecord } from '../entities/learning-record.entity';
|
||||
import { ResultArchive } from '../entities/result-archive.entity';
|
||||
@@ -36,6 +38,9 @@ export class ArchiveService {
|
||||
@InjectRepository(ArchiveAttachment) private attachmentRepo: Repository<ArchiveAttachment>,
|
||||
@InjectRepository(AttendanceRecord) private attendanceRepo: Repository<AttendanceRecord>,
|
||||
private readonly notificationsService: NotificationsService,
|
||||
@InjectRepository(Class) private classRepo: Repository<Class>,
|
||||
@InjectRepository(ClassStudent) private classStudentRepo: Repository<ClassStudent>,
|
||||
private dataSource: DataSource,
|
||||
) {}
|
||||
|
||||
get uploadDir(): string {
|
||||
@@ -158,26 +163,113 @@ export class ArchiveService {
|
||||
return this.profileRepo.save(profile);
|
||||
}
|
||||
|
||||
/**
|
||||
* 花名册成员关系唯一写入口(报读→班级单向全同步)。
|
||||
* wantActive=true 时确保成员 active;false 时若该生无其他「报读中」记录引用此班则标离。
|
||||
*/
|
||||
private async syncRosterMembership(
|
||||
manager: EntityManager,
|
||||
studentId: number,
|
||||
classId: number,
|
||||
wantActive: boolean,
|
||||
joinDate: string,
|
||||
excludeEnrollmentId?: number,
|
||||
): Promise<void> {
|
||||
if (wantActive) {
|
||||
let row = await manager.findOne(ClassStudent, { where: { classId, studentId } });
|
||||
if (!row) {
|
||||
row = manager.create(ClassStudent, { classId, studentId, status: 'active', joinDate });
|
||||
} else if (row.status !== 'active') {
|
||||
row.status = 'active';
|
||||
row.joinDate = joinDate;
|
||||
row.leaveDate = null;
|
||||
}
|
||||
await manager.save(row);
|
||||
return;
|
||||
}
|
||||
// 离班守卫:该生其他「报读中」报读记录仍引用此班时不移出
|
||||
const otherActive = await manager.getRepository(StudentEnrollment).count({
|
||||
where: {
|
||||
studentId,
|
||||
classId,
|
||||
status: 'active',
|
||||
...(excludeEnrollmentId ? { id: Not(excludeEnrollmentId) } : {}),
|
||||
},
|
||||
});
|
||||
if (otherActive > 0) return;
|
||||
const row = await manager.findOne(ClassStudent, { where: { classId, studentId, status: 'active' } });
|
||||
if (row) {
|
||||
row.status = 'left';
|
||||
row.leaveDate = dayjs().utcOffset(8).format('YYYY-MM-DD');
|
||||
await manager.save(row);
|
||||
}
|
||||
}
|
||||
|
||||
async addEnrollment(studentId: number, dto: CreateEnrollmentDto) {
|
||||
const student = await this.studentRepo.findOne({ where: { id: studentId } });
|
||||
if (!student) throw new NotFoundException('学生不存在');
|
||||
|
||||
const entity = this.enrollmentRepo.create({ ...dto, studentId });
|
||||
return this.enrollmentRepo.save(entity);
|
||||
const classId = dto.classId ?? null;
|
||||
let className = dto.className;
|
||||
if (classId != null) {
|
||||
const cls = await this.classRepo.findOne({ where: { id: classId } });
|
||||
if (!cls) throw new BadRequestException('班级不存在');
|
||||
// 快照以班级为准,防止前后端漂移
|
||||
className = cls.name;
|
||||
}
|
||||
const status = dto.status ?? 'active';
|
||||
const joinDate = dto.startDate || dayjs().utcOffset(8).format('YYYY-MM-DD');
|
||||
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const entity = manager.create(StudentEnrollment, { ...dto, studentId, className, classId, status });
|
||||
const saved = await manager.save(entity);
|
||||
if (classId != null && status === 'active') {
|
||||
await this.syncRosterMembership(manager, studentId, classId, true, joinDate);
|
||||
}
|
||||
return saved;
|
||||
});
|
||||
}
|
||||
|
||||
async updateEnrollment(id: number, dto: UpdateEnrollmentDto) {
|
||||
const entity = await this.enrollmentRepo.findOne({ where: { id } });
|
||||
if (!entity) throw new NotFoundException('报名记录不存在');
|
||||
Object.assign(entity, dto);
|
||||
return this.enrollmentRepo.save(entity);
|
||||
|
||||
const oldClassId = entity.classId ?? null;
|
||||
const oldStatus = entity.status;
|
||||
const classId = dto.classId !== undefined ? (dto.classId ?? null) : oldClassId;
|
||||
let className = dto.className ?? entity.className;
|
||||
if (classId != null) {
|
||||
const cls = await this.classRepo.findOne({ where: { id: classId } });
|
||||
if (!cls) throw new BadRequestException('班级不存在');
|
||||
className = cls.name;
|
||||
}
|
||||
const status = dto.status ?? oldStatus;
|
||||
const joinDate = dto.startDate || entity.startDate || dayjs().utcOffset(8).format('YYYY-MM-DD');
|
||||
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
Object.assign(entity, { ...dto, className, classId, status });
|
||||
await manager.save(entity);
|
||||
if (oldClassId != null && oldClassId !== classId) {
|
||||
await this.syncRosterMembership(manager, entity.studentId, oldClassId, false, '', id);
|
||||
}
|
||||
if (classId != null) {
|
||||
await this.syncRosterMembership(manager, entity.studentId, classId, status === 'active', joinDate, id);
|
||||
}
|
||||
return entity;
|
||||
});
|
||||
}
|
||||
|
||||
async deleteEnrollment(id: number) {
|
||||
const entity = await this.enrollmentRepo.findOne({ where: { id } });
|
||||
if (!entity) throw new NotFoundException('报名记录不存在');
|
||||
if (entity.status === 'archived') throw new BadRequestException('报名记录已归档');
|
||||
await this.enrollmentRepo.update(id, { status: 'archived' });
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.update(StudentEnrollment, id, { status: 'archived' });
|
||||
if (entity.classId != null) {
|
||||
await this.syncRosterMembership(manager, entity.studentId, entity.classId, false, '', id);
|
||||
}
|
||||
});
|
||||
return { message: '已归档' };
|
||||
}
|
||||
|
||||
@@ -191,7 +283,14 @@ export class ArchiveService {
|
||||
if (scoreCount > 0) {
|
||||
throw new BadRequestException('该报名记录已被考试成绩引用,无法永久删除');
|
||||
}
|
||||
await this.enrollmentRepo.delete(id);
|
||||
|
||||
await this.dataSource.transaction(async (manager) => {
|
||||
await manager.delete(StudentEnrollment, id);
|
||||
if (entity.classId != null) {
|
||||
// 已删记录本身被排除,守卫对其他 active 记录生效
|
||||
await this.syncRosterMembership(manager, entity.studentId, entity.classId, false, '', id);
|
||||
}
|
||||
});
|
||||
return { message: '已永久删除报名记录(不可恢复)' };
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { IsOptional, IsString, IsNumber, IsDateString, IsNotEmpty, Min } from 'class-validator';
|
||||
import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsNumber,
|
||||
IsDateString,
|
||||
IsNotEmpty,
|
||||
Min,
|
||||
IsInt,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpsertProfileDto {
|
||||
@IsOptional() @IsString() targetCollege?: string;
|
||||
@@ -16,6 +24,7 @@ export class CreateEnrollmentDto {
|
||||
@IsString() @IsNotEmpty() courseCategory: string;
|
||||
@IsString() @IsNotEmpty() classType: string;
|
||||
@IsOptional() @IsString() className?: string;
|
||||
@IsOptional() @IsInt() classId?: number;
|
||||
@IsOptional() @IsString() headTeacher?: string;
|
||||
@IsOptional() @IsString() subjectTeacher?: string;
|
||||
@IsOptional() @IsDateString() startDate?: string;
|
||||
|
||||
@@ -30,6 +30,9 @@ export class StudentEnrollment {
|
||||
@Column({ name: 'class_name', length: 100, nullable: true })
|
||||
className: string;
|
||||
|
||||
@Column({ name: 'class_id', type: 'integer', nullable: true })
|
||||
classId: number | null;
|
||||
|
||||
@Column({ name: 'head_teacher', length: 50, nullable: true })
|
||||
headTeacher: string;
|
||||
|
||||
|
||||
@@ -1,16 +1,5 @@
|
||||
import { DataSource } from 'typeorm';
|
||||
import { InitialSchema1784520727860 } from './migrations/1784520727860-InitialSchema';
|
||||
import { AddExamManagement1784600000000 } from './migrations/1784600000000-AddExamManagement';
|
||||
import { AddRoomInspections1784680000000 } from './migrations/1784680000000-AddRoomInspections';
|
||||
import { AddJinshujuMatchRules1784700000000 } from './migrations/1784700000000-AddJinshujuMatchRules';
|
||||
import { AddAiChat1784780000000 } from './migrations/1784780000000-AddAiChat';
|
||||
import { EnhanceAiChatForAntDesignX1784860000000 } from './migrations/1784860000000-EnhanceAiChatForAntDesignX';
|
||||
import { AddA2UiForms1784870000000 } from './migrations/1784870000000-AddA2UiForms';
|
||||
import { AddA2UiReviews1784880000000 } from './migrations/1784880000000-AddA2UiReviews';
|
||||
import { EnlargeAiReviewSections1784900000000 } from './migrations/1784900000000-EnlargeAiReviewSections';
|
||||
import { AddImportRuns1784910000000 } from './migrations/1784910000000-AddImportRuns';
|
||||
import { DropAiMessageFeedback1784920000000 } from './migrations/1784920000000-DropAiMessageFeedback';
|
||||
import { AddImportRunSettings1784930000000 } from './migrations/1784930000000-AddImportRunSettings';
|
||||
import { join } from 'path';
|
||||
import { config } from 'dotenv';
|
||||
|
||||
config();
|
||||
@@ -24,20 +13,9 @@ export async function runMigrationsOnStartup(): Promise<void> {
|
||||
password: process.env.DB_PASSWORD || '',
|
||||
database: process.env.DB_DATABASE || 'dorm_billing',
|
||||
charset: 'utf8mb4',
|
||||
migrations: [
|
||||
InitialSchema1784520727860,
|
||||
AddExamManagement1784600000000,
|
||||
AddRoomInspections1784680000000,
|
||||
AddJinshujuMatchRules1784700000000,
|
||||
AddAiChat1784780000000,
|
||||
EnhanceAiChatForAntDesignX1784860000000,
|
||||
AddA2UiForms1784870000000,
|
||||
AddA2UiReviews1784880000000,
|
||||
EnlargeAiReviewSections1784900000000,
|
||||
AddImportRuns1784910000000,
|
||||
DropAiMessageFeedback1784920000000,
|
||||
AddImportRunSettings1784930000000,
|
||||
],
|
||||
// 自动发现:ts-node 下匹配 src/migrations/*.ts,编译产物下匹配 dist/migrations/*.js。
|
||||
// 新增迁移文件无需再手改清单(此前手写数组已漏跑 2 个迁移)。
|
||||
migrations: [join(__dirname, 'migrations/*{.ts,.js}')],
|
||||
});
|
||||
|
||||
await ds.initialize();
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||
|
||||
/**
|
||||
* student_enrollments 增加 class_id:报读班型关联真实班级实体(花名册同步用)。
|
||||
* 纯 int 列不加外键:enrollments 是归档数据,班级永久删除时允许悬空 classId,
|
||||
* 前端对查不到班级的 classId 回退纯文本展示。
|
||||
*/
|
||||
export class AddClassIdToStudentEnrollments1786406400000 implements MigrationInterface {
|
||||
async up(queryRunner: QueryRunner): Promise<void> {
|
||||
if (!(await queryRunner.hasColumn('student_enrollments', 'class_id'))) {
|
||||
await queryRunner.query('ALTER TABLE `student_enrollments` ADD COLUMN `class_id` int NULL');
|
||||
}
|
||||
}
|
||||
|
||||
async down(queryRunner: QueryRunner): Promise<void> {
|
||||
if (await queryRunner.hasColumn('student_enrollments', 'class_id')) {
|
||||
await queryRunner.query('ALTER TABLE `student_enrollments` DROP COLUMN `class_id`');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user