diff --git a/apps/server/src/archive/archive.boundaries.spec.ts b/apps/server/src/archive/archive.boundaries.spec.ts index e4b3fd33..ce799b75 100644 --- a/apps/server/src/archive/archive.boundaries.spec.ts +++ b/apps/server/src/archive/archive.boundaries.spec.ts @@ -12,6 +12,8 @@ function createService(repos: Partial>> (repos.attachment ?? {}) as never, (repos.attendance ?? {}) as never, {} as never, + {} as never, + {} as never, ); } diff --git a/apps/server/src/archive/archive.enrollment-roster.spec.ts b/apps/server/src/archive/archive.enrollment-roster.spec.ts new file mode 100644 index 00000000..37a9fa65 --- /dev/null +++ b/apps/server/src/archive/archive.enrollment-roster.spec.ts @@ -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> = [], otherActiveCount = 0) { + const memberSet = new WeakSet(); + const manager = { + roster, + findOne: jest.fn(async (Entity: unknown, opts: { where?: Record }) => { + 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) => { + const row = { ...data }; + if (Entity === ClassStudent) memberSet.add(row); + return row; + }), + save: jest.fn(async (row: Record) => { + 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; +}) { + const manager = opts.manager ?? makeManager(); + const dataSource = { + transaction: jest.fn(async (fn: (m: unknown) => Promise) => 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); + }); +}); diff --git a/apps/server/src/archive/archive.module.ts b/apps/server/src/archive/archive.module.ts index 44655029..5874c4eb 100644 --- a/apps/server/src/archive/archive.module.ts +++ b/apps/server/src/archive/archive.module.ts @@ -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, diff --git a/apps/server/src/archive/archive.purge.spec.ts b/apps/server/src/archive/archive.purge.spec.ts index 5f0952e4..fe6c5395 100644 --- a/apps/server/src/archive/archive.purge.spec.ts +++ b/apps/server/src/archive/archive.purge.spec.ts @@ -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) => 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); }); diff --git a/apps/server/src/archive/archive.service.spec.ts b/apps/server/src/archive/archive.service.spec.ts index 643a458a..2b7e3ed4 100644 --- a/apps/server/src/archive/archive.service.spec.ts +++ b/apps/server/src/archive/archive.service.spec.ts @@ -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); diff --git a/apps/server/src/archive/archive.service.ts b/apps/server/src/archive/archive.service.ts index 11b5d8cc..bca07007 100644 --- a/apps/server/src/archive/archive.service.ts +++ b/apps/server/src/archive/archive.service.ts @@ -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, @InjectRepository(AttendanceRecord) private attendanceRepo: Repository, private readonly notificationsService: NotificationsService, + @InjectRepository(Class) private classRepo: Repository, + @InjectRepository(ClassStudent) private classStudentRepo: Repository, + 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 { + 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: '已永久删除报名记录(不可恢复)' }; } diff --git a/apps/server/src/archive/dto/archive.dto.ts b/apps/server/src/archive/dto/archive.dto.ts index a6ece0b3..1cf84659 100644 --- a/apps/server/src/archive/dto/archive.dto.ts +++ b/apps/server/src/archive/dto/archive.dto.ts @@ -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; diff --git a/apps/server/src/entities/student-enrollment.entity.ts b/apps/server/src/entities/student-enrollment.entity.ts index fa025bee..31c3a924 100644 --- a/apps/server/src/entities/student-enrollment.entity.ts +++ b/apps/server/src/entities/student-enrollment.entity.ts @@ -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; diff --git a/apps/server/src/migration-runner.ts b/apps/server/src/migration-runner.ts index ad624ab1..c7339b76 100644 --- a/apps/server/src/migration-runner.ts +++ b/apps/server/src/migration-runner.ts @@ -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 { 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(); diff --git a/apps/server/src/migrations/1786406400000-AddClassIdToStudentEnrollments.ts b/apps/server/src/migrations/1786406400000-AddClassIdToStudentEnrollments.ts new file mode 100644 index 00000000..ceefb257 --- /dev/null +++ b/apps/server/src/migrations/1786406400000-AddClassIdToStudentEnrollments.ts @@ -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 { + 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 { + if (await queryRunner.hasColumn('student_enrollments', 'class_id')) { + await queryRunner.query('ALTER TABLE `student_enrollments` DROP COLUMN `class_id`'); + } + } +}