From 37ef6f9dd7ff7c92b2b1e330209cee750b781bb7 Mon Sep 17 00:00:00 2001 From: xiong Date: Tue, 21 Jul 2026 15:35:35 +0800 Subject: [PATCH] feat: add college archive fields --- .../StudentProfileContent/index.tsx | 20 +++++++++++++ .../scripts/generate-student-import-xlsx.ts | 2 ++ apps/server/src/archive/dto/archive.dto.ts | 2 ++ .../database/database-migrations.service.ts | 20 +++++++++++++ .../src/entities/student-profile.entity.ts | 6 ++++ .../migrations/1784520727860-InitialSchema.ts | 2 +- .../src/students/student-import.spec.ts | 30 ++++++++++++++++--- apps/server/src/students/student-import.ts | 6 ++++ .../src/students/students.controller.ts | 2 ++ .../src/students/students.lifecycle.spec.ts | 7 ++++- apps/server/src/students/students.service.ts | 4 +++ 11 files changed, 95 insertions(+), 6 deletions(-) diff --git a/apps/admin/src/components/StudentProfileContent/index.tsx b/apps/admin/src/components/StudentProfileContent/index.tsx index 77b5971..15d0b55 100644 --- a/apps/admin/src/components/StudentProfileContent/index.tsx +++ b/apps/admin/src/components/StudentProfileContent/index.tsx @@ -59,6 +59,8 @@ interface StudentInfo { interface ProfileData { targetCollege?: string; targetMajor?: string; + collegeSchool?: string; + collegeMajor?: string; subjectDirection?: string; grade?: string; profileDate?: string; @@ -481,6 +483,24 @@ const InlineArchiveSummary: React.FC<{ {profile?.targetMajor || '-'} + + saveProfile('collegeSchool', next)} + > + {profile?.collegeSchool || '-'} + + + + saveProfile('collegeMajor', next)} + > + {profile?.collegeMajor || '-'} + + { await this.ensureAiConfigTable(); await this.ensureSyncStateLeaseColumns(); + await this.ensureStudentProfileCollegeColumns(); await this.ensureCourseAttendanceSchema(); await this.ensureAttendanceDevicesSchema(); await this.ensureStudentWalletSchema(); @@ -42,6 +43,25 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap { } } + private async ensureStudentProfileCollegeColumns(): Promise { + const runner = this.dataSource.createQueryRunner(); + await runner.connect(); + try { + const table = await runner.getTable('student_profiles'); + if (!table) return; + const columns = new Set(table.columns.map((column) => column.name)); + const additions: Array<[string, string]> = [ + ['college_school', 'VARCHAR(100)'], + ['college_major', 'VARCHAR(100)'], + ]; + for (const [name, definition] of additions) { + if (!columns.has(name)) await runner.query(`ALTER TABLE student_profiles ADD COLUMN ${name} ${definition}`); + } + } finally { + await runner.release(); + } + } + private async ensureAttendanceDevicesSchema(): Promise { const runner = this.dataSource.createQueryRunner(); await runner.connect(); diff --git a/apps/server/src/entities/student-profile.entity.ts b/apps/server/src/entities/student-profile.entity.ts index bac56a9..7b687b4 100644 --- a/apps/server/src/entities/student-profile.entity.ts +++ b/apps/server/src/entities/student-profile.entity.ts @@ -27,6 +27,12 @@ export class StudentProfile { @Column({ name: 'target_major', length: 100, nullable: true }) targetMajor: string; + @Column({ name: 'college_school', length: 100, nullable: true }) + collegeSchool: string; + + @Column({ name: 'college_major', length: 100, nullable: true }) + collegeMajor: string; + @Column({ name: 'subject_direction', length: 50, nullable: true }) subjectDirection: string; diff --git a/apps/server/src/migrations/1784520727860-InitialSchema.ts b/apps/server/src/migrations/1784520727860-InitialSchema.ts index eea4fb2..36cc656 100644 --- a/apps/server/src/migrations/1784520727860-InitialSchema.ts +++ b/apps/server/src/migrations/1784520727860-InitialSchema.ts @@ -21,7 +21,7 @@ export class InitialSchema1784520727860 implements MigrationInterface { await queryRunner.query(`CREATE TABLE \`bills\` (\`id\` int NOT NULL AUTO_INCREMENT, \`student_id\` int NOT NULL, \`period_start\` date NOT NULL, \`period_end\` date NOT NULL, \`shared_amount\` decimal(10,2) NOT NULL DEFAULT '0.00', \`personal_amount\` decimal(10,2) NOT NULL DEFAULT '0.00', \`total_amount\` decimal(10,2) NOT NULL DEFAULT '0.00', \`source\` varchar(30) NOT NULL DEFAULT 'batch', \`paid_amount\` decimal(10,2) NOT NULL DEFAULT '0.00', \`outstanding_amount\` decimal(10,2) NOT NULL DEFAULT '0.00', \`status\` varchar(20) NOT NULL DEFAULT 'unpaid', \`cancelled_at\` datetime NULL, \`cancel_reason\` varchar(300) NULL, \`generated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); await queryRunner.query(`CREATE TABLE \`students\` (\`id\` int NOT NULL AUTO_INCREMENT, \`name\` varchar(50) NOT NULL, \`student_no\` varchar(30) NULL, \`phone\` varchar(20) NULL, \`id_number\` varchar(30) NULL, \`gender\` varchar(10) NULL, \`ethnicity\` varchar(20) NULL, \`emergency_contact\` varchar(50) NULL, \`emergency_phone\` varchar(20) NULL, \`status\` varchar(20) NOT NULL DEFAULT 'active', \`supervisor\` varchar(50) NULL, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`user_id\` int NULL, \`organization_id\` int NULL, UNIQUE INDEX \`IDX_fb3eff90b11bddf7285f9b4e28\` (\`user_id\`), UNIQUE INDEX \`REL_fb3eff90b11bddf7285f9b4e28\` (\`user_id\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); await queryRunner.query(`CREATE TABLE \`student_wallets\` (\`id\` int NOT NULL AUTO_INCREMENT, \`student_id\` int NOT NULL, \`balance\` decimal(12,2) NOT NULL DEFAULT '0.00', \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE INDEX \`IDX_07a434ad1a960d506386754d59\` (\`student_id\`), UNIQUE INDEX \`REL_07a434ad1a960d506386754d59\` (\`student_id\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); - await queryRunner.query(`CREATE TABLE \`student_profiles\` (\`id\` int NOT NULL AUTO_INCREMENT, \`student_id\` int NOT NULL, \`target_college\` varchar(100) NULL, \`target_major\` varchar(100) NULL, \`subject_direction\` varchar(50) NULL, \`grade\` varchar(20) NULL, \`profile_date\` date NULL, \`notes\` text NULL, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE INDEX \`IDX_4cedc08d3dc1f2c2da8a12f7a8\` (\`student_id\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`student_profiles\` (\`id\` int NOT NULL AUTO_INCREMENT, \`student_id\` int NOT NULL, \`target_college\` varchar(100) NULL, \`target_major\` varchar(100) NULL, \`college_school\` varchar(100) NULL, \`college_major\` varchar(100) NULL, \`subject_direction\` varchar(50) NULL, \`grade\` varchar(20) NULL, \`profile_date\` date NULL, \`notes\` text NULL, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE INDEX \`IDX_4cedc08d3dc1f2c2da8a12f7a8\` (\`student_id\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); await queryRunner.query(`CREATE TABLE \`student_enrollments\` (\`id\` int NOT NULL AUTO_INCREMENT, \`student_id\` int NOT NULL, \`course_category\` varchar(50) NULL, \`class_type\` varchar(50) NULL, \`class_name\` varchar(100) NULL, \`head_teacher\` varchar(50) NULL, \`subject_teacher\` varchar(50) NULL, \`start_date\` date NULL, \`end_date\` date NULL, \`status\` varchar(20) NOT NULL DEFAULT 'active', \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); await queryRunner.query(`CREATE TABLE \`student_ding_mapping\` (\`id\` int NOT NULL AUTO_INCREMENT, \`ding_user_id\` varchar(100) NOT NULL, \`student_id\` int NOT NULL, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), UNIQUE INDEX \`IDX_0d1ec47e2f901d37e3b6e56331\` (\`ding_user_id\`), UNIQUE INDEX \`IDX_f9ba15ff04de8ffbd8679ae9db\` (\`student_id\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); await queryRunner.query(`CREATE TABLE \`result_archives\` (\`id\` int NOT NULL AUTO_INCREMENT, \`student_id\` int NOT NULL, \`culture_final_score\` decimal(5,2) NULL, \`professional_final_score\` decimal(5,2) NULL, \`admission_status\` varchar(50) NULL, \`admitted_college\` varchar(100) NULL, \`admitted_major\` varchar(100) NULL, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE INDEX \`IDX_377bba8eb6a027eecd9737d4ed\` (\`student_id\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`); diff --git a/apps/server/src/students/student-import.spec.ts b/apps/server/src/students/student-import.spec.ts index 0de3fef..3b19471 100644 --- a/apps/server/src/students/student-import.spec.ts +++ b/apps/server/src/students/student-import.spec.ts @@ -16,10 +16,19 @@ describe('student import workbook', () => { '课堂回访', ]); expect(students?.rowCount).toBe(2); + const headerRow = students?.getRow(1); + const columnByHeader = new Map(); + headerRow?.eachCell((cell, colNumber) => columnByHeader.set(String(cell.value), colNumber)); expect(students?.getRow(2).getCell(1).value).toBe('13800138000'); - expect(students?.getRow(2).getCell(19).value).toBe('pending'); - expect(students?.getRow(2).getCell(20).value || '').toBe(''); - expect(students?.getRow(2).getCell(21).value || '').toBe(''); + expect(students?.getRow(2).getCell(columnByHeader.get('大专院校') || 0).value).toBe( + '北京职业技术学院', + ); + expect(students?.getRow(2).getCell(columnByHeader.get('大专专业') || 0).value).toBe( + '软件技术', + ); + expect(students?.getRow(2).getCell(columnByHeader.get('录取状态') || 0).value).toBe('pending'); + expect(students?.getRow(2).getCell(columnByHeader.get('录取院校') || 0).value || '').toBe(''); + expect(students?.getRow(2).getCell(columnByHeader.get('录取专业') || 0).value || '').toBe(''); expect(workbook.getWorksheet('报读班型')?.rowCount).toBe(1); expect(workbook.getWorksheet('考试成绩')?.rowCount).toBe(1); expect(workbook.getWorksheet('课堂回访')?.rowCount).toBe(1); @@ -32,11 +41,22 @@ describe('student import workbook', () => { '手机号*', '姓名*', '目标院校', + '大专院校', + '大专专业', '建档日期', '文化课最终分', '录取状态', ]); - students.addRow(['13800138000', '张三', '北京大学', '2024/9/1', 620, 'pending']); + students.addRow([ + '13800138000', + '张三', + '北京大学', + '北京职业技术学院', + '软件技术', + '2024/9/1', + 620, + 'pending', + ]); const enrollments = workbook.addWorksheet('报读班型'); enrollments.addRow(['手机号*', '课程类别*', '班型*', '班级名称']); @@ -57,6 +77,8 @@ describe('student import workbook', () => { phone: '13800138000', name: '张三', targetCollege: '北京大学', + collegeSchool: '北京职业技术学院', + collegeMajor: '软件技术', profileDate: '2024-09-01', cultureFinalScore: 620, admissionStatus: 'pending', diff --git a/apps/server/src/students/student-import.ts b/apps/server/src/students/student-import.ts index 44dbc0f..0962771 100644 --- a/apps/server/src/students/student-import.ts +++ b/apps/server/src/students/student-import.ts @@ -14,6 +14,8 @@ export interface StudentImportRow { organizationId?: number; targetCollege?: string; targetMajor?: string; + collegeSchool?: string; + collegeMajor?: string; subjectDirection?: string; grade?: string; profileDate?: string; @@ -89,6 +91,8 @@ export const STUDENT_IMPORT_COLUMNS: ColumnDef[] = [ { header: '负责人', key: 'supervisor', width: 15, aliases: ['负责人/班主任'] }, { header: '目标院校', key: 'targetCollege', width: 18 }, { header: '目标专业', key: 'targetMajor', width: 22 }, + { header: '大专院校', key: 'collegeSchool', width: 18 }, + { header: '大专专业', key: 'collegeMajor', width: 22 }, { header: '选科方向', key: 'subjectDirection', width: 14 }, { header: '年级', key: 'grade', width: 10 }, { header: '建档日期', key: 'profileDate', width: 14, kind: 'date' }, @@ -302,6 +306,8 @@ export function createStudentImportTemplateWorkbook(): ExcelJS.Workbook { supervisor: '李老师', targetCollege: '北京大学', targetMajor: '计算机科学与技术', + collegeSchool: '北京职业技术学院', + collegeMajor: '软件技术', subjectDirection: '物化生', grade: '高三', profileDate: '2024-09-01', diff --git a/apps/server/src/students/students.controller.ts b/apps/server/src/students/students.controller.ts index a3ed56f..96cdd61 100644 --- a/apps/server/src/students/students.controller.ts +++ b/apps/server/src/students/students.controller.ts @@ -126,6 +126,8 @@ export class StudentsController { supervisor: s.supervisor || '', targetCollege: profile?.targetCollege || '', targetMajor: profile?.targetMajor || '', + collegeSchool: profile?.collegeSchool || '', + collegeMajor: profile?.collegeMajor || '', subjectDirection: profile?.subjectDirection || '', grade: profile?.grade || '', profileDate: profile?.profileDate || '', diff --git a/apps/server/src/students/students.lifecycle.spec.ts b/apps/server/src/students/students.lifecycle.spec.ts index ecd8d0f..fbe7b11 100644 --- a/apps/server/src/students/students.lifecycle.spec.ts +++ b/apps/server/src/students/students.lifecycle.spec.ts @@ -50,7 +50,11 @@ describe('StudentsService — archive lifecycle boundaries', () => { it('builds export archive maps from profile and result rows', async () => { const profileRepo = { - find: jest.fn().mockResolvedValue([{ studentId: 1, targetCollege: '北京大学' }]), + find: jest.fn().mockResolvedValue([{ + studentId: 1, + targetCollege: '北京大学', + collegeSchool: '北京职业技术学院', + }]), }; const resultRepo = { find: jest.fn().mockResolvedValue([{ studentId: 1, admissionStatus: 'pending' }]), @@ -72,6 +76,7 @@ describe('StudentsService — archive lifecycle boundaries', () => { const maps = await service.getArchiveExportMaps([1]); expect(maps.profiles.get(1)?.targetCollege).toBe('北京大学'); + expect(maps.profiles.get(1)?.collegeSchool).toBe('北京职业技术学院'); expect(maps.results.get(1)?.admissionStatus).toBe('pending'); }); }); diff --git a/apps/server/src/students/students.service.ts b/apps/server/src/students/students.service.ts index a701f9d..26a2279 100644 --- a/apps/server/src/students/students.service.ts +++ b/apps/server/src/students/students.service.ts @@ -333,6 +333,8 @@ export class StudentsService { return [ row.targetCollege, row.targetMajor, + row.collegeSchool, + row.collegeMajor, row.subjectDirection, row.grade, row.profileDate, @@ -391,6 +393,8 @@ export class StudentsService { const entity = (await this.profileRepo.findOne({ where: { studentId } })) || this.profileRepo.create({ studentId }); if (row.targetCollege?.trim()) entity.targetCollege = row.targetCollege.trim(); if (row.targetMajor?.trim()) entity.targetMajor = row.targetMajor.trim(); + if (row.collegeSchool?.trim()) entity.collegeSchool = row.collegeSchool.trim(); + if (row.collegeMajor?.trim()) entity.collegeMajor = row.collegeMajor.trim(); if (row.subjectDirection?.trim()) entity.subjectDirection = row.subjectDirection.trim(); if (row.grade?.trim()) entity.grade = row.grade.trim(); if (row.profileDate?.trim()) entity.profileDate = row.profileDate.trim();