From 90cc0c10b2c32bc6062065b33d74e2d8ec50f032 Mon Sep 17 00:00:00 2001 From: xiong Date: Tue, 21 Jul 2026 15:11:52 +0800 Subject: [PATCH] feat: expand student archive import template --- .../src/components/EditableCell/index.tsx | 2 +- .../StudentProfileContent/index.tsx | 491 +++++++++++------- apps/server/package.json | 1 + .../scripts/generate-student-import-xlsx.ts | 104 ++++ apps/server/src/archive/archive.service.ts | 5 +- .../src/students/student-import.spec.ts | 75 +++ apps/server/src/students/student-import.ts | 319 ++++++++++++ .../src/students/students.agent-api.spec.ts | 5 + .../src/students/students.controller.ts | 178 ++----- .../src/students/students.lifecycle.spec.ts | 34 ++ apps/server/src/students/students.module.ts | 10 + .../src/students/students.scope.spec.ts | 10 + apps/server/src/students/students.service.ts | 254 +++++++-- 13 files changed, 1103 insertions(+), 385 deletions(-) create mode 100644 apps/server/scripts/generate-student-import-xlsx.ts create mode 100644 apps/server/src/students/student-import.spec.ts create mode 100644 apps/server/src/students/student-import.ts diff --git a/apps/admin/src/components/EditableCell/index.tsx b/apps/admin/src/components/EditableCell/index.tsx index c69a132..a60b195 100644 --- a/apps/admin/src/components/EditableCell/index.tsx +++ b/apps/admin/src/components/EditableCell/index.tsx @@ -24,7 +24,7 @@ export interface EditableCellOption { export interface EditableCellProps { value: Value; - children: React.ReactNode; + children?: React.ReactNode; editor?: EditableCellEditor; options?: EditableCellOption[]; permission?: string; diff --git a/apps/admin/src/components/StudentProfileContent/index.tsx b/apps/admin/src/components/StudentProfileContent/index.tsx index b723ac6..77b5971 100644 --- a/apps/admin/src/components/StudentProfileContent/index.tsx +++ b/apps/admin/src/components/StudentProfileContent/index.tsx @@ -46,6 +46,13 @@ interface StudentInfo { phone: string; idNumber: string; studentNo: string; + gender?: string; + ethnicity?: string; + emergencyContact?: string; + emergencyPhone?: string; + organizationId?: number; + organization?: { id?: number; name?: string } | null; + supervisor?: string; status: string; } @@ -163,13 +170,6 @@ const RECORD_TYPE_OPTIONS = [ { value: 'other', label: '其他' }, ]; -const STUDENT_STATUS_MAP: Record = { - active: { text: '在读', color: 'green' }, - graduated: { text: '已毕业', color: 'blue' }, - withdrawn: { text: '已退训', color: 'red' }, - archived: { text: '已归档', color: '#999' }, -}; - const ENROLLMENT_STATUS_MAP: Record = { active: { text: '报读中', color: 'green' }, completed: { text: '已结课', color: 'blue' }, @@ -209,11 +209,6 @@ const getEnrollmentStatus = (value?: string | null): { text: string; color: stri return ENROLLMENT_STATUS_MAP[value] || { text: value, color: 'default' }; }; -const getStudentStatus = (value?: string | null): { text: string; color: string } => { - if (!value) return { text: '-', color: 'default' }; - return STUDENT_STATUS_MAP[value] || { text: value, color: 'default' }; -}; - const formatEnrollmentDisplayName = (enrollment: EnrollmentRecord): string => enrollment.className || (enrollment.courseCategory @@ -311,72 +306,274 @@ interface TabProps { onRefresh: () => void; } -const ProfileTab: React.FC<{ - data: ProfileData | null; +const InlineArchiveSummary: React.FC<{ studentId: number; + student: StudentInfo; + profile: ProfileData | null; + result: ResultData | null; + organizations: Array<{ id: number; name: string }>; onRefresh: () => void; -}> = ({ data, studentId, onRefresh }) => { - const [form] = Form.useForm(); - const [saving, setSaving] = useState(false); - - const handleSave = async () => { - try { - const values = await form.validateFields(); - setSaving(true); - await api.put(`/archive/${studentId}/profile`, { - ...values, - profileDate: values.profileDate?.format('YYYY-MM-DD'), - }); - message.success('基础档案已保存'); - onRefresh(); - } catch (e: unknown) { - const err = e as { message?: string }; - if (err?.message) message.error(err.message); - } finally { - setSaving(false); - } + onViewSensitive: (fieldLabel: string, value: string) => void; +}> = ({ studentId, student, profile, result, organizations, onRefresh, onViewSensitive }) => { + const saveStudent = async (field: keyof StudentInfo, value: unknown) => { + await api.put(`/students/${studentId}`, { [field]: value }); + message.success('学生资料已保存'); + onRefresh(); }; + const saveProfile = async (field: keyof ProfileData, value: unknown) => { + await api.put(`/archive/${studentId}/profile`, { [field]: value }); + message.success('档案已保存'); + onRefresh(); + }; + + const saveResult = async (field: keyof ResultData, value: unknown) => { + await api.put(`/archive/${studentId}/result`, { [field]: value }); + message.success('录取信息已保存'); + onRefresh(); + }; + + const admissionStatus = getOptionLabel( + Object.entries(ADMISSION_STATUS_MAP).map(([value, meta]) => ({ + value, + label: meta.text, + })), + result?.admissionStatus, + ); + return ( -
-
- - - - - - - - - - - - - - - - - - - - - -
-
+ + + saveStudent('phone', next)} + > + {student.phone ? ( + + {maskPhone(student.phone)} + onViewSensitive('电话', student.phone)}> + + + + ) : ( + '-' + )} + + + + saveStudent('name', next)} + > + {student.name || '-'} + + + + saveStudent('studentNo', next)} + > + {student.studentNo || '-'} + + + + saveStudent('gender', next)} + > + {student.gender || '-'} + + + + saveStudent('idNumber', next)} + > + {student.idNumber ? ( + + {maskIdNumber(student.idNumber)} + onViewSensitive('身份证号', student.idNumber)}> + + + + ) : ( + '-' + )} + + + + saveStudent('ethnicity', next)} + > + {student.ethnicity || '-'} + + + + saveStudent('emergencyContact', next)} + > + {student.emergencyContact || '-'} + + + + saveStudent('emergencyPhone', next)} + > + {student.emergencyPhone ? ( + + {maskPhone(student.emergencyPhone)} + onViewSensitive('紧急联系人电话', student.emergencyPhone || '')}> + + + + ) : ( + '-' + )} + + + + ({ value: item.id, label: item.name }))} + permission="student:edit" + onSave={(next) => saveStudent('organizationId', next)} + > + {student.organization?.name ? {student.organization.name} : '-'} + + + + saveStudent('supervisor', next)} + > + {student.supervisor || '-'} + + + + saveProfile('targetCollege', next)} + > + {profile?.targetCollege || '-'} + + + + saveProfile('targetMajor', next)} + > + {profile?.targetMajor || '-'} + + + + saveProfile('subjectDirection', next)} + > + {profile?.subjectDirection || '-'} + + + + saveProfile('grade', next)} + > + {profile?.grade || '-'} + + + + saveProfile('profileDate', next)} + > + {profile?.profileDate || '-'} + + + + saveProfile('notes', next)} + > + {profile?.notes || '-'} + + + + saveResult('cultureFinalScore', next)} + > + {result?.cultureFinalScore ?? '-'} + + + + saveResult('professionalFinalScore', next)} + > + {result?.professionalFinalScore ?? '-'} + + + + ({ + value, + label: meta.text, + }))} + permission="student:edit" + onSave={(next) => saveResult('admissionStatus', next)} + > + {admissionStatus} + + + + saveResult('admittedCollege', next)} + > + {result?.admittedCollege || '-'} + + + + saveResult('admittedMajor', next)} + > + {result?.admittedMajor || '-'} + + + ); }; @@ -1018,75 +1215,6 @@ const LearningTab: React.FC = ({ ); }; -const ResultTab: React.FC = ({ - data, - studentId, - onRefresh, -}) => { - const [form] = Form.useForm(); - const [saving, setSaving] = useState(false); - - const handleSave = async () => { - try { - const values = await form.validateFields(); - setSaving(true); - await api.put(`/archive/${studentId}/result`, values); - message.success('录取结果已保存'); - onRefresh(); - } catch (e: unknown) { - const err = e as { message?: string }; - if (err?.message) message.error(err.message); - } finally { - setSaving(false); - } - }; - - return ( -
-
- - - - - - - - - - - - - - - -
-
- ); -}; - const AttachmentsTab: React.FC = ({ data, studentId, @@ -1202,6 +1330,7 @@ const StudentProfileContent: React.FC = ({ onClose, }) => { const [aggregateData, setAggregateData] = useState(null); + const [organizations, setOrganizations] = useState>([]); const [loading, setLoading] = useState(false); const fetchData = useCallback(async () => { @@ -1221,6 +1350,15 @@ const StudentProfileContent: React.FC = ({ void fetchData(); }, [fetchData]); + useEffect(() => { + api + .get('/organizations', { params: { includeArchived: 'false' } }) + .then((res: unknown) => { + setOrganizations(res as Array<{ id: number; name: string }>); + }) + .catch(() => {}); + }, []); + const handlePreviewReport = useCallback(async () => { try { const { html } = await api.get<{ html: string }>(`/archive/${studentId}/report-html`); @@ -1238,14 +1376,8 @@ const StudentProfileContent: React.FC = ({ const tabItems = useMemo(() => { if (!aggregateData) return []; - const { profile, enrollments, examScores, learningRecords, result, attachments, attendances } = - aggregateData; + const { enrollments, examScores, learningRecords, attachments, attendances } = aggregateData; return [ - { - key: 'profile', - label: '扩展档案', - children: , - }, { key: 'enrollments', label: `报读班型 (${enrollments.length})`, @@ -1275,11 +1407,6 @@ const StudentProfileContent: React.FC = ({ ), }, - { - key: 'result', - label: '录取归档', - children: , - }, { key: 'attachments', label: `附件 (${attachments.length})`, @@ -1304,7 +1431,7 @@ const StudentProfileContent: React.FC = ({ return null; } - const { student, profile } = aggregateData; + const { student, profile, result } = aggregateData; return (
@@ -1342,51 +1469,17 @@ const StudentProfileContent: React.FC = ({ ))} - - {student.studentNo || '-'} - - {student.phone ? ( - - {maskPhone(student.phone)} - handleViewSensitive('电话', student.phone)}> - - - - ) : ( - '-' - )} - - - {student.idNumber ? ( - - {maskIdNumber(student.idNumber)} - handleViewSensitive('身份证号', student.idNumber)}> - - - - ) : ( - '-' - )} - - - {(() => { - const status = getStudentStatus(student.status); - return {status.text}; - })()} - - {profile?.targetCollege && ( - {profile.targetCollege} - )} - {profile?.targetMajor && ( - {profile.targetMajor} - )} - {profile?.grade && {profile.grade}} - {profile?.subjectDirection && ( - {profile.subjectDirection} - )} - + - +
); }; diff --git a/apps/server/package.json b/apps/server/package.json index d0550e9..f35d955 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -13,6 +13,7 @@ "start": "nest start", "start:debug": "nest start --debug --watch", "start:prod": "node dist/main", + "generate:student-import": "ts-node -r tsconfig-paths/register -P tsconfig.json scripts/generate-student-import-xlsx.ts", "lint": "eslint \"{src,apps,libs,test}/**/*.ts\" --fix", "typecheck": "tsc -p tsconfig.build.json --noEmit", "test": "jest", diff --git a/apps/server/scripts/generate-student-import-xlsx.ts b/apps/server/scripts/generate-student-import-xlsx.ts new file mode 100644 index 0000000..df7e6d7 --- /dev/null +++ b/apps/server/scripts/generate-student-import-xlsx.ts @@ -0,0 +1,104 @@ +/// + +import * as ExcelJS from 'exceljs'; +import { + STUDENT_IMPORT_COLUMNS, + createStudentImportTemplateWorkbook, +} from '../src/students/student-import'; + +const count = Number(process.argv[2] || 1000); +const output = process.argv[3] || `student-import-${count}.xlsx`; + +function getWorksheetOrThrow(workbook: ExcelJS.Workbook, name: string) { + const sheet = workbook.getWorksheet(name); + if (!sheet) throw new Error(`Worksheet not found: ${name}`); + return sheet; +} + +async function main() { + if (!Number.isInteger(count) || count <= 0) { + throw new Error('Count must be a positive integer'); + } + + const workbook = createStudentImportTemplateWorkbook(); + const students = getWorksheetOrThrow(workbook, '学生基础+档案+录取'); + const enrollments = getWorksheetOrThrow(workbook, '报读班型'); + const examScores = getWorksheetOrThrow(workbook, '考试成绩'); + const learningRecords = getWorksheetOrThrow(workbook, '课堂回访'); + + for (let i = 1; i <= count; i++) { + const serial = String(i).padStart(4, '0'); + const phone = `138${String(i).padStart(8, '0')}`; + students.addRow({ + phone, + name: `导入学生${serial}`, + studentNo: `GX${serial}`, + gender: i % 2 === 0 ? '女' : '男', + idNumber: `110101200601${String((i % 28) + 1).padStart(2, '0')}${String(i % 1000).padStart(3, '0')}X`, + ethnicity: '汉族', + emergencyContact: `联系人${serial}`, + emergencyPhone: `139${String(i).padStart(8, '0')}`, + organization: '', + supervisor: '', + targetCollege: `目标院校${(i % 20) + 1}`, + targetMajor: `目标专业${(i % 10) + 1}`, + subjectDirection: ['物化生', '物化地', '史政地'][i % 3], + grade: '高三', + profileDate: '2024-09-01', + notes: '', + cultureFinalScore: 500 + (i % 151), + professionalFinalScore: 300 + (i % 101), + admissionStatus: 'pending', + admittedCollege: '', + admittedMajor: '', + }); + + enrollments.addRow({ + phone, + name: `导入学生${serial}`, + courseCategory: i % 2 === 0 ? 'culture' : 'professional', + classType: i % 3 === 0 ? 'small_group' : 'one_on_one', + className: `导入测试班${(i % 10) + 1}`, + headTeacher: '', + subjectTeacher: '', + startDate: '2024-09-01', + endDate: '2025-06-01', + status: 'active', + }); + + examScores.addRow({ + phone, + name: `导入学生${serial}`, + examType: 'monthly', + examName: '导入测试月考', + subject: '语文', + score: 80 + (i % 41), + classAvg: 90, + rank: (i % 50) + 1, + examDate: '2024-10-15', + enrollmentName: `导入测试班${(i % 10) + 1}`, + }); + + learningRecords.addRow({ + phone, + name: `导入学生${serial}`, + recordDate: '2024-10-20', + recordType: 'study_feedback', + content: `导入测试回访${serial}`, + followUpMethod: 'phone', + nextStep: '', + }); + } + + for (const sheet of workbook.worksheets) { + sheet.views = [{ state: 'frozen', ySplit: 1 }]; + } + + await workbook.xlsx.writeFile(output); + console.log(`Generated ${count} students with ${STUDENT_IMPORT_COLUMNS.length} main columns: ${output}`); +} + +main().catch((error) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/apps/server/src/archive/archive.service.ts b/apps/server/src/archive/archive.service.ts index e867620..f1140c2 100644 --- a/apps/server/src/archive/archive.service.ts +++ b/apps/server/src/archive/archive.service.ts @@ -58,7 +58,10 @@ export class ArchiveService { } async getProfile(studentId: number) { - const student = await this.studentRepo.findOne({ where: { id: studentId } }); + const student = await this.studentRepo.findOne({ + where: { id: studentId }, + relations: ['organization'], + }); if (!student) throw new NotFoundException('学生不存在'); const [ diff --git a/apps/server/src/students/student-import.spec.ts b/apps/server/src/students/student-import.spec.ts new file mode 100644 index 0000000..0de3fef --- /dev/null +++ b/apps/server/src/students/student-import.spec.ts @@ -0,0 +1,75 @@ +import * as ExcelJS from 'exceljs'; +import { + createStudentImportTemplateWorkbook, + parseStudentImportWorkbook, +} from './student-import'; + +describe('student import workbook', () => { + it('creates a template with one main example row and header-only detail sheets', () => { + const workbook = createStudentImportTemplateWorkbook(); + const students = workbook.getWorksheet('学生基础+档案+录取'); + + expect(workbook.worksheets.map((sheet) => sheet.name)).toEqual([ + '学生基础+档案+录取', + '报读班型', + '考试成绩', + '课堂回访', + ]); + expect(students?.rowCount).toBe(2); + 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(workbook.getWorksheet('报读班型')?.rowCount).toBe(1); + expect(workbook.getWorksheet('考试成绩')?.rowCount).toBe(1); + expect(workbook.getWorksheet('课堂回访')?.rowCount).toBe(1); + }); + + it('maps rows by headers across archive sheets', () => { + const workbook = new ExcelJS.Workbook(); + const students = workbook.addWorksheet('学生基础+档案+录取'); + students.addRow([ + '手机号*', + '姓名*', + '目标院校', + '建档日期', + '文化课最终分', + '录取状态', + ]); + students.addRow(['13800138000', '张三', '北京大学', '2024/9/1', 620, 'pending']); + + const enrollments = workbook.addWorksheet('报读班型'); + enrollments.addRow(['手机号*', '课程类别*', '班型*', '班级名称']); + enrollments.addRow(['13800138000', 'culture', 'one_on_one', '冲刺班']); + + const examScores = workbook.addWorksheet('考试成绩'); + examScores.addRow(['手机号*', '考试类型*', '科目*', '成绩*', '考试日期']); + examScores.addRow(['13800138000', 'monthly', '语文', '108.5', '2024-10-15']); + + const learningRecords = workbook.addWorksheet('课堂回访'); + learningRecords.addRow(['手机号*', '记录日期*', '记录类型*', '内容*']); + learningRecords.addRow(['13800138000', '2024-10-20', 'study_feedback', '状态稳定']); + + const parsed = parseStudentImportWorkbook(workbook); + + expect(parsed.students[0]).toEqual( + expect.objectContaining({ + phone: '13800138000', + name: '张三', + targetCollege: '北京大学', + profileDate: '2024-09-01', + cultureFinalScore: 620, + admissionStatus: 'pending', + }), + ); + expect(parsed.enrollments[0]).toEqual( + expect.objectContaining({ courseCategory: 'culture', classType: 'one_on_one' }), + ); + expect(parsed.examScores[0]).toEqual( + expect.objectContaining({ score: 108.5, examDate: '2024-10-15' }), + ); + expect(parsed.learningRecords[0]).toEqual( + expect.objectContaining({ recordType: 'study_feedback', content: '状态稳定' }), + ); + }); +}); diff --git a/apps/server/src/students/student-import.ts b/apps/server/src/students/student-import.ts new file mode 100644 index 0000000..44dbc0f --- /dev/null +++ b/apps/server/src/students/student-import.ts @@ -0,0 +1,319 @@ +import * as ExcelJS from 'exceljs'; + +export interface StudentImportRow { + name: string; + studentNo?: string; + phone?: string; + idNumber?: string; + gender?: string; + ethnicity?: string; + emergencyContact?: string; + emergencyPhone?: string; + organization?: string; + supervisor?: string; + organizationId?: number; + targetCollege?: string; + targetMajor?: string; + subjectDirection?: string; + grade?: string; + profileDate?: string; + notes?: string; + cultureFinalScore?: number; + professionalFinalScore?: number; + admissionStatus?: string; + admittedCollege?: string; + admittedMajor?: string; +} + +export interface StudentEnrollmentImportRow { + phone: string; + name?: string; + courseCategory: string; + classType: string; + className?: string; + headTeacher?: string; + subjectTeacher?: string; + startDate?: string; + endDate?: string; + status?: string; +} + +export interface ExamScoreImportRow { + phone: string; + name?: string; + examType: string; + examName?: string; + subject: string; + score?: number; + classAvg?: number; + rank?: number; + examDate?: string; + enrollmentName?: string; +} + +export interface LearningRecordImportRow { + phone: string; + name?: string; + recordDate: string; + recordType: string; + content: string; + followUpMethod?: string; + nextStep?: string; +} + +export interface StudentWorkbookImport { + students: StudentImportRow[]; + enrollments: StudentEnrollmentImportRow[]; + examScores: ExamScoreImportRow[]; + learningRecords: LearningRecordImportRow[]; +} + +type ColumnDef = { + header: string; + key: keyof T; + width: number; + aliases?: string[]; + kind?: 'text' | 'date' | 'number' | 'integer'; +}; + +export const STUDENT_IMPORT_COLUMNS: ColumnDef[] = [ + { header: '手机号*', key: 'phone', width: 18, aliases: ['手机号', '电话'] }, + { header: '姓名*', key: 'name', width: 15, aliases: ['姓名'] }, + { header: '学号', key: 'studentNo', width: 15 }, + { header: '性别', key: 'gender', width: 8 }, + { header: '身份证号', key: 'idNumber', width: 22, aliases: ['身份证', '学号/身份证'] }, + { header: '民族', key: 'ethnicity', width: 10 }, + { header: '紧急联系人', key: 'emergencyContact', width: 15 }, + { header: '紧急联系人电话', key: 'emergencyPhone', width: 18 }, + { header: '所属机构', key: 'organization', width: 18, aliases: ['所属机构名称'] }, + { header: '负责人', key: 'supervisor', width: 15, aliases: ['负责人/班主任'] }, + { header: '目标院校', key: 'targetCollege', width: 18 }, + { header: '目标专业', key: 'targetMajor', width: 22 }, + { header: '选科方向', key: 'subjectDirection', width: 14 }, + { header: '年级', key: 'grade', width: 10 }, + { header: '建档日期', key: 'profileDate', width: 14, kind: 'date' }, + { header: '档案备注', key: 'notes', width: 28 }, + { header: '文化课最终分', key: 'cultureFinalScore', width: 14, kind: 'number' }, + { header: '专业课最终分', key: 'professionalFinalScore', width: 14, kind: 'number' }, + { header: '录取状态', key: 'admissionStatus', width: 14 }, + { header: '录取院校', key: 'admittedCollege', width: 18 }, + { header: '录取专业', key: 'admittedMajor', width: 22 }, +]; + +export const STUDENT_EXPORT_COLUMNS = [ + ...STUDENT_IMPORT_COLUMNS, +]; + +const ENROLLMENT_IMPORT_COLUMNS: ColumnDef[] = [ + { header: '手机号*', key: 'phone', width: 18, aliases: ['手机号', '电话'] }, + { header: '姓名', key: 'name', width: 15 }, + { header: '课程类别*', key: 'courseCategory', width: 14, aliases: ['课程类别'] }, + { header: '班型*', key: 'classType', width: 14, aliases: ['班型'] }, + { header: '班级名称', key: 'className', width: 20 }, + { header: '班主任', key: 'headTeacher', width: 15 }, + { header: '任课教师', key: 'subjectTeacher', width: 15 }, + { header: '开始日期', key: 'startDate', width: 14, kind: 'date' }, + { header: '结束日期', key: 'endDate', width: 14, kind: 'date' }, + { header: '状态', key: 'status', width: 12 }, +]; + +const EXAM_SCORE_IMPORT_COLUMNS: ColumnDef[] = [ + { header: '手机号*', key: 'phone', width: 18, aliases: ['手机号', '电话'] }, + { header: '姓名', key: 'name', width: 15 }, + { header: '考试类型*', key: 'examType', width: 14, aliases: ['考试类型'] }, + { header: '考试名称', key: 'examName', width: 20 }, + { header: '科目*', key: 'subject', width: 14, aliases: ['科目'] }, + { header: '成绩*', key: 'score', width: 12, aliases: ['成绩'], kind: 'number' }, + { header: '班级均分', key: 'classAvg', width: 12, kind: 'number' }, + { header: '排名', key: 'rank', width: 10, kind: 'integer' }, + { header: '考试日期', key: 'examDate', width: 14, kind: 'date' }, + { header: '关联报读(班级名)', key: 'enrollmentName', width: 22, aliases: ['关联报读(班级名)'] }, +]; + +const LEARNING_RECORD_IMPORT_COLUMNS: ColumnDef[] = [ + { header: '手机号*', key: 'phone', width: 18, aliases: ['手机号', '电话'] }, + { header: '姓名', key: 'name', width: 15 }, + { header: '记录日期*', key: 'recordDate', width: 14, aliases: ['记录日期'], kind: 'date' }, + { header: '记录类型*', key: 'recordType', width: 14, aliases: ['记录类型'] }, + { header: '内容*', key: 'content', width: 36, aliases: ['内容'] }, + { header: '跟进方式', key: 'followUpMethod', width: 14 }, + { header: '下一步计划', key: 'nextStep', width: 28 }, +]; + +function normalizeHeader(header: string): string { + return header.trim().replace(/\*+$/u, '').trim(); +} + +function getCellPrimitiveValue(cell: ExcelJS.Cell): unknown { + const value = cell.value; + if (value === null || value === undefined) return undefined; + if (value instanceof Date) return value; + if (typeof value === 'object') { + if ('result' in value) return value.result; + if ('text' in value) return value.text; + if ('richText' in value && Array.isArray(value.richText)) { + return value.richText.map((part) => part.text).join(''); + } + } + return value; +} + +function cellToText(cell: ExcelJS.Cell): string { + const value = getCellPrimitiveValue(cell); + if (value === null || value === undefined) return ''; + if (value instanceof Date) return formatDate(value); + return String(value).trim(); +} + +function formatDate(date: Date): string { + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +function excelSerialToDate(serial: number): string | undefined { + if (!Number.isFinite(serial) || serial < 1) return undefined; + const epoch = Date.UTC(1899, 11, 30); + return formatDate(new Date(epoch + serial * 24 * 60 * 60 * 1000)); +} + +function parseDateText(cell: ExcelJS.Cell): string | undefined { + const value = getCellPrimitiveValue(cell); + if (value instanceof Date) return formatDate(value); + if (typeof value === 'number') return excelSerialToDate(value); + const text = value === null || value === undefined ? '' : String(value).trim(); + if (!text) return undefined; + const normalized = text.replace(/[/.]/g, '-'); + const match = normalized.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/u); + if (!match) return text; + return `${match[1]}-${match[2].padStart(2, '0')}-${match[3].padStart(2, '0')}`; +} + +function parseNumber(cell: ExcelJS.Cell): number | undefined { + const text = cellToText(cell).replace(/,/g, ''); + if (!text) return undefined; + const value = Number(text); + return Number.isFinite(value) ? value : undefined; +} + +function buildHeaderMap(ws: ExcelJS.Worksheet, columns: ColumnDef[]) { + const columnByHeader = new Map>(); + for (const column of columns) { + columnByHeader.set(normalizeHeader(column.header), column); + for (const alias of column.aliases || []) { + columnByHeader.set(normalizeHeader(alias), column); + } + } + + const headerIndex = new Map>(); + ws.getRow(1).eachCell((cell, colNumber) => { + const column = columnByHeader.get(normalizeHeader(cellToText(cell))); + if (column) headerIndex.set(colNumber, column); + }); + return headerIndex; +} + +function parseWorksheetRows( + ws: ExcelJS.Worksheet | undefined, + columns: ColumnDef[], +): T[] { + if (!ws) return []; + const headerIndex = buildHeaderMap(ws, columns); + const rows: T[] = []; + + ws.eachRow((row, idx) => { + if (idx === 1) return; + const parsed: Partial> = {}; + headerIndex.forEach((column, colNumber) => { + const cell = row.getCell(colNumber); + const value = + column.kind === 'date' + ? parseDateText(cell) + : column.kind === 'number' || column.kind === 'integer' + ? parseNumber(cell) + : cellToText(cell); + if (value !== undefined && value !== '') { + parsed[column.key] = column.kind === 'integer' && typeof value === 'number' + ? Math.trunc(value) + : value; + } + }); + + if (Object.keys(parsed).length > 0) rows.push(parsed as T); + }); + + return rows; +} + +function findWorksheet(workbook: ExcelJS.Workbook, names: string[], fallbackIndex?: number) { + for (const name of names) { + const sheet = workbook.getWorksheet(name); + if (sheet) return sheet; + } + return fallbackIndex === undefined ? undefined : workbook.worksheets[fallbackIndex]; +} + +export function parseStudentImportWorkbook(workbook: ExcelJS.Workbook): StudentWorkbookImport { + return { + students: parseWorksheetRows( + findWorksheet(workbook, ['学生基础+档案+录取', '学生导入模板', '学生名单'], 0), + STUDENT_IMPORT_COLUMNS, + ).map((row) => ({ ...row, name: row.name || '', phone: row.phone || '' })), + enrollments: parseWorksheetRows( + findWorksheet(workbook, ['报读班型']), + ENROLLMENT_IMPORT_COLUMNS, + ), + examScores: parseWorksheetRows( + findWorksheet(workbook, ['考试成绩']), + EXAM_SCORE_IMPORT_COLUMNS, + ), + learningRecords: parseWorksheetRows( + findWorksheet(workbook, ['课堂回访']), + LEARNING_RECORD_IMPORT_COLUMNS, + ), + }; +} + +function applyHeaderStyle(ws: ExcelJS.Worksheet) { + ws.getRow(1).font = { bold: true }; + ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } }; +} + +function setupWorksheet(workbook: ExcelJS.Workbook, name: string, columns: ColumnDef[]) { + const ws = workbook.addWorksheet(name); + ws.columns = columns.map(({ header, key, width }) => ({ header, key: String(key), width })); + applyHeaderStyle(ws); + return ws; +} + +export function createStudentImportTemplateWorkbook(): ExcelJS.Workbook { + const workbook = new ExcelJS.Workbook(); + setupWorksheet(workbook, '学生基础+档案+录取', STUDENT_IMPORT_COLUMNS).addRow({ + phone: '13800138000', + name: '张三', + studentNo: '2024001', + gender: '男', + idNumber: '11010120060101001X', + ethnicity: '汉族', + emergencyContact: '张父', + emergencyPhone: '13900139000', + organization: '北京校区', + supervisor: '李老师', + targetCollege: '北京大学', + targetMajor: '计算机科学与技术', + subjectDirection: '物化生', + grade: '高三', + profileDate: '2024-09-01', + notes: '学习态度积极,目标明确', + cultureFinalScore: 620, + professionalFinalScore: 580, + admissionStatus: 'pending', + admittedCollege: '', + admittedMajor: '', + }); + setupWorksheet(workbook, '报读班型', ENROLLMENT_IMPORT_COLUMNS); + setupWorksheet(workbook, '考试成绩', EXAM_SCORE_IMPORT_COLUMNS); + setupWorksheet(workbook, '课堂回访', LEARNING_RECORD_IMPORT_COLUMNS); + return workbook; +} diff --git a/apps/server/src/students/students.agent-api.spec.ts b/apps/server/src/students/students.agent-api.spec.ts index b83f023..03c779d 100644 --- a/apps/server/src/students/students.agent-api.spec.ts +++ b/apps/server/src/students/students.agent-api.spec.ts @@ -62,6 +62,11 @@ function makeService( {} as never, // attendanceRepo {} as never, // classTeacherRepo {} as never, // organizationRepo + {} as never, // profileRepo + {} as never, // enrollmentRepo + {} as never, // examScoreRepo + {} as never, // learningRecordRepo + {} as never, // resultRepo ); return { service, studentQb, classStudentQb }; diff --git a/apps/server/src/students/students.controller.ts b/apps/server/src/students/students.controller.ts index e45cb38..a3ed56f 100644 --- a/apps/server/src/students/students.controller.ts +++ b/apps/server/src/students/students.controller.ts @@ -30,127 +30,16 @@ import { RequirePermission } from '../auth/decorators/permission.decorator'; import { AuthorizationService, CaslAction, SubjectName } from '../authorization'; import type { AuthenticatedUser } from '../authorization'; import * as ExcelJS from 'exceljs'; +import { + createStudentImportTemplateWorkbook, + parseStudentImportWorkbook, + STUDENT_EXPORT_COLUMNS, +} from './student-import'; interface AuthenticatedRequest { user: AuthenticatedUser; } - -interface StudentImportRow { - name: string; - studentNo?: string; - phone?: string; - idNumber?: string; - gender?: string; - ethnicity?: string; - emergencyContact?: string; - emergencyPhone?: string; - organization?: string; - supervisor?: string; - organizationId?: number; -} - -const STUDENT_IMPORT_COLUMNS = [ - { header: '姓名', key: 'name', width: 15 }, - { header: '学号', key: 'studentNo', width: 15 }, - { header: '性别', key: 'gender', width: 8 }, - { header: '电话', key: 'phone', width: 18 }, - { header: '身份证号', key: 'idNumber', width: 22 }, - { header: '民族', key: 'ethnicity', width: 10 }, - { header: '紧急联系人', key: 'emergencyContact', width: 15 }, - { header: '紧急联系人电话', key: 'emergencyPhone', width: 18 }, - { header: '所属机构名称', key: 'organization', width: 18 }, - { header: '负责人/班主任', key: 'supervisor', width: 15 }, -]; - -const STUDENT_EXPORT_COLUMNS = [ - ...STUDENT_IMPORT_COLUMNS.map((column) => ({ - ...column, - header: column.key === 'organization' ? '所属机构' : column.header, - })), - { header: '状态', key: 'status', width: 10 }, -]; - -const STUDENT_IMPORT_HEADER_MAP: Record = { - 姓名: 'name', - 学号: 'studentNo', - 电话: 'phone', - 手机号: 'phone', - '学号/身份证': 'idNumber', - 身份证: 'idNumber', - 身份证号: 'idNumber', - 性别: 'gender', - 民族: 'ethnicity', - 紧急联系人: 'emergencyContact', - 紧急联系人电话: 'emergencyPhone', - 所属机构: 'organization', - 所属机构名称: 'organization', - 负责人: 'supervisor', - '负责人/班主任': 'supervisor', -}; - -function getExcelCellText(cell: ExcelJS.Cell): string { - const value = cell.value; - if (value === null || value === undefined) return ''; - if (typeof value === 'object') { - if ('text' in value) return String(value.text || ''); - if ('richText' in value && Array.isArray(value.richText)) { - return value.richText.map((part) => part.text).join(''); - } - if ('result' in value) return String(value.result || ''); - } - return String(value); -} - -function parseStudentImportRows(ws: ExcelJS.Worksheet): StudentImportRow[] { - const headerIndex = new Map(); - ws.getRow(1).eachCell((cell, colNumber) => { - const header = getExcelCellText(cell).trim(); - const field = STUDENT_IMPORT_HEADER_MAP[header]; - if (field) headerIndex.set(colNumber, field); - }); - - const rows: StudentImportRow[] = []; - ws.eachRow((row, idx) => { - if (idx === 1) return; - - const parsed: Partial = {}; - if (headerIndex.size > 0) { - headerIndex.forEach((field, colNumber) => { - const value = getExcelCellText(row.getCell(colNumber)).trim(); - if (value) { - Object.assign(parsed, { [field]: value }); - } - }); - } else { - parsed.name = getExcelCellText(row.getCell(1)).trim(); - parsed.studentNo = getExcelCellText(row.getCell(2)).trim() || undefined; - parsed.gender = getExcelCellText(row.getCell(3)).trim() || undefined; - parsed.phone = getExcelCellText(row.getCell(4)).trim() || undefined; - parsed.idNumber = getExcelCellText(row.getCell(5)).trim() || undefined; - parsed.ethnicity = getExcelCellText(row.getCell(6)).trim() || undefined; - parsed.emergencyContact = getExcelCellText(row.getCell(7)).trim() || undefined; - parsed.emergencyPhone = getExcelCellText(row.getCell(8)).trim() || undefined; - parsed.organization = getExcelCellText(row.getCell(9)).trim() || undefined; - parsed.supervisor = getExcelCellText(row.getCell(10)).trim() || undefined; - } - - rows.push({ - name: parsed.name || '', - studentNo: parsed.studentNo, - phone: parsed.phone, - idNumber: parsed.idNumber, - gender: parsed.gender, - ethnicity: parsed.ethnicity, - emergencyContact: parsed.emergencyContact, - emergencyPhone: parsed.emergencyPhone, - organization: parsed.organization, - supervisor: parsed.supervisor, - }); - }); - return rows; -} - @UseGuards(JwtAuthGuard) @Controller('students') export class StudentsController { @@ -218,25 +107,34 @@ export class StudentsController { ws.columns = STUDENT_EXPORT_COLUMNS; ws.getRow(1).font = { bold: true }; ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } }; - const statusMap: Record = { - active: '在读', - graduated: '已毕业', - withdrawn: '已退训', - archived: '已归档', - }; + const { profiles, results } = await this.service.getArchiveExportMaps( + students.map((student) => student.id), + ); for (const s of students) { + const profile = profiles.get(s.id); + const result = results.get(s.id); ws.addRow({ + phone: s.phone || '', name: s.name, studentNo: s.studentNo || '', gender: s.gender || '', - phone: s.phone || '', idNumber: s.idNumber || '', ethnicity: s.ethnicity || '', emergencyContact: s.emergencyContact || '', emergencyPhone: s.emergencyPhone || '', organization: s.organization?.name || '', supervisor: s.supervisor || '', - status: statusMap[s.status] || s.status, + targetCollege: profile?.targetCollege || '', + targetMajor: profile?.targetMajor || '', + subjectDirection: profile?.subjectDirection || '', + grade: profile?.grade || '', + profileDate: profile?.profileDate || '', + notes: profile?.notes || '', + cultureFinalScore: result?.cultureFinalScore ?? '', + professionalFinalScore: result?.professionalFinalScore ?? '', + admissionStatus: result?.admissionStatus || '', + admittedCollege: result?.admittedCollege || '', + admittedMajor: result?.admittedMajor || '', }); } const { ipAddress, userAgent } = extractRequestInfo(req); @@ -261,23 +159,7 @@ export class StudentsController { @Get('template') @RequirePermission('student:view') async downloadTemplate(@Res() res: Response) { - const workbook = new ExcelJS.Workbook(); - const ws = workbook.addWorksheet('学生导入模板'); - ws.columns = STUDENT_IMPORT_COLUMNS; - ws.getRow(1).font = { bold: true }; - ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } }; - ws.addRow({ - name: '张三', - studentNo: '2024001', - gender: '男', - phone: '13800138000', - idNumber: '11010120060101001X', - ethnicity: '汉族', - emergencyContact: '张父', - emergencyPhone: '13900000000', - organization: 'XX教育公司', - supervisor: '', - }); + const workbook = createStudentImportTemplateWorkbook(); res.setHeader( 'Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', @@ -391,10 +273,9 @@ export class StudentsController { const { ipAddress, userAgent } = extractRequestInfo(req); const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(file.buffer as any); - const ws = workbook.worksheets[0]; - const rows = parseStudentImportRows(ws); + const importData = parseStudentImportWorkbook(workbook); // Resolve organization names to IDs - for (const row of rows) { + for (const row of importData.students) { if (row.organization) { const organization = await this.organizationRepo.findOne({ where: { name: row.organization }, @@ -404,7 +285,7 @@ export class StudentsController { } } } - const result = await this.service.batchImport(rows); + const result = await this.service.batchImport(importData); await this.logService.log({ userId: req.user?.id, username: req.user?.username, @@ -424,10 +305,9 @@ export class StudentsController { const { ipAddress, userAgent } = extractRequestInfo(req); const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(file.buffer as unknown as ArrayBuffer); - const ws = workbook.worksheets[0]; - const rows = parseStudentImportRows(ws); + const importData = parseStudentImportWorkbook(workbook); // Resolve organization names to IDs - for (const row of rows) { + for (const row of importData.students) { if (row.organization) { const organization = await this.organizationRepo.findOne({ where: { name: row.organization }, @@ -435,7 +315,7 @@ export class StudentsController { if (organization) row.organizationId = organization.id; } } - const result = await this.service.matchImport(rows); + const result = await this.service.matchImport(importData); await this.logService.log({ userId: req.user?.id, username: req.user?.username, diff --git a/apps/server/src/students/students.lifecycle.spec.ts b/apps/server/src/students/students.lifecycle.spec.ts index 7006344..ecd8d0f 100644 --- a/apps/server/src/students/students.lifecycle.spec.ts +++ b/apps/server/src/students/students.lifecycle.spec.ts @@ -2,6 +2,8 @@ import { BadRequestException, NotFoundException } from '@nestjs/common'; import { StudentsService } from './students.service'; function createService(repo: Record, organizationRepo = {}) { + const profileRepo = { find: jest.fn().mockResolvedValue([]) }; + const resultRepo = { find: jest.fn().mockResolvedValue([]) }; return new StudentsService( repo as never, {} as never, @@ -9,6 +11,11 @@ function createService(repo: Record, organizationRepo = {}) { {} as never, {} as never, organizationRepo as never, + profileRepo as never, + {} as never, + {} as never, + {} as never, + resultRepo as never, ); } @@ -40,4 +47,31 @@ describe('StudentsService — archive lifecycle boundaries', () => { const repo = { findOne: jest.fn().mockResolvedValue(null) }; await expect(createService(repo).findOne(404)).rejects.toBeInstanceOf(NotFoundException); }); + + it('builds export archive maps from profile and result rows', async () => { + const profileRepo = { + find: jest.fn().mockResolvedValue([{ studentId: 1, targetCollege: '北京大学' }]), + }; + const resultRepo = { + find: jest.fn().mockResolvedValue([{ studentId: 1, admissionStatus: 'pending' }]), + }; + const service = new StudentsService( + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + profileRepo as never, + {} as never, + {} as never, + {} as never, + resultRepo as never, + ); + + const maps = await service.getArchiveExportMaps([1]); + + expect(maps.profiles.get(1)?.targetCollege).toBe('北京大学'); + expect(maps.results.get(1)?.admissionStatus).toBe('pending'); + }); }); diff --git a/apps/server/src/students/students.module.ts b/apps/server/src/students/students.module.ts index 759fdcf..a1576b0 100644 --- a/apps/server/src/students/students.module.ts +++ b/apps/server/src/students/students.module.ts @@ -6,6 +6,11 @@ import { Organization } from '../entities/organization.entity'; import { ClassStudent } from '../entities/class-student.entity'; import { AttendanceRecord } from '../entities/attendance-record.entity'; import { ClassTeacher } from '../entities/class-teacher.entity'; +import { StudentProfile } from '../entities/student-profile.entity'; +import { StudentEnrollment } from '../entities/student-enrollment.entity'; +import { ExamScore } from '../entities/exam-score.entity'; +import { LearningRecord } from '../entities/learning-record.entity'; +import { ResultArchive } from '../entities/result-archive.entity'; import { StudentsService } from './students.service'; import { StudentAccessScopeFactory } from './student-access-scope.factory'; import { StudentsController } from './students.controller'; @@ -19,6 +24,11 @@ import { StudentsController } from './students.controller'; AttendanceRecord, Organization, ClassTeacher, + StudentProfile, + StudentEnrollment, + ExamScore, + LearningRecord, + ResultArchive, ]), ], controllers: [StudentsController], diff --git a/apps/server/src/students/students.scope.spec.ts b/apps/server/src/students/students.scope.spec.ts index 44c3751..6e4faf6 100644 --- a/apps/server/src/students/students.scope.spec.ts +++ b/apps/server/src/students/students.scope.spec.ts @@ -13,6 +13,11 @@ describe('StudentsService — teacher class scope', () => { {} as never, {} as never, {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, ); await service.findAll({}, [3, 5]); @@ -37,6 +42,11 @@ describe('StudentsService — teacher class scope', () => { {} as never, {} as never, {} as never, + {} as never, + {} as never, + {} as never, + {} as never, + {} as never, ); await expect(service.findAll({}, [])).resolves.toEqual([]); diff --git a/apps/server/src/students/students.service.ts b/apps/server/src/students/students.service.ts index f6c1211..a701f9d 100644 --- a/apps/server/src/students/students.service.ts +++ b/apps/server/src/students/students.service.ts @@ -7,7 +7,19 @@ import { ClassStudent } from '../entities/class-student.entity'; import { ClassTeacher } from '../entities/class-teacher.entity'; import { AttendanceRecord } from '../entities/attendance-record.entity'; import { Organization } from '../entities/organization.entity'; +import { StudentProfile } from '../entities/student-profile.entity'; +import { StudentEnrollment } from '../entities/student-enrollment.entity'; +import { ExamScore } from '../entities/exam-score.entity'; +import { LearningRecord } from '../entities/learning-record.entity'; +import { ResultArchive } from '../entities/result-archive.entity'; import { CreateStudentDto, UpdateStudentDto } from './dto/student.dto'; +import type { + ExamScoreImportRow, + LearningRecordImportRow, + StudentEnrollmentImportRow, + StudentImportRow, + StudentWorkbookImport, +} from './student-import'; import type { StudentAccessScope } from './student-access-scope'; @Injectable() @@ -19,6 +31,11 @@ export class StudentsService { @InjectRepository(AttendanceRecord) private attendanceRepo: Repository, @InjectRepository(ClassTeacher) private classTeacherRepo: Repository, @InjectRepository(Organization) private organizationRepo: Repository, + @InjectRepository(StudentProfile) private profileRepo: Repository, + @InjectRepository(StudentEnrollment) private enrollmentRepo: Repository, + @InjectRepository(ExamScore) private examScoreRepo: Repository, + @InjectRepository(LearningRecord) private learningRecordRepo: Repository, + @InjectRepository(ResultArchive) private resultRepo: Repository, ) {} async getAccessibleClassIds(userId: number, canManageAll = false): Promise { @@ -35,6 +52,23 @@ export class StudentsService { }); } + async getArchiveExportMaps(studentIds: number[]) { + if (studentIds.length === 0) { + return { + profiles: new Map(), + results: new Map(), + }; + } + const [profiles, results] = await Promise.all([ + this.profileRepo.find({ where: { studentId: In(studentIds) } }), + this.resultRepo.find({ where: { studentId: In(studentIds) } }), + ]); + return { + profiles: new Map(profiles.map((profile) => [profile.studentId, profile])), + results: new Map(results.map((result) => [result.studentId, result])), + }; + } + async findAll( query?: { name?: string; @@ -185,24 +219,12 @@ export class StudentsService { return { message: '已恢复' }; } - async batchImport( - rows: { - name: string; - studentNo?: string; - phone?: string; - idNumber?: string; - gender?: string; - ethnicity?: string; - emergencyContact?: string; - emergencyPhone?: string; - organization?: string; - supervisor?: string; - organizationId?: number; - }[], - ) { + async batchImport(importData: StudentWorkbookImport | StudentImportRow[]) { + const data = this.normalizeImportData(importData); let imported = 0; let skipped = 0; - for (const row of rows) { + let archiveImported = 0; + for (const row of data.students) { if (!row.name || !row.name.trim()) { skipped++; continue; @@ -212,7 +234,7 @@ export class StudentsService { skipped++; continue; } - await this.repo.save( + const student = await this.repo.save( this.repo.create({ name: row.name.trim(), studentNo: row.studentNo?.trim() || undefined, @@ -226,33 +248,23 @@ export class StudentsService { organizationId: row.organizationId || (await this.getHostOrganizationId()), }), ); + archiveImported += await this.importArchiveData(student.id, row, data); imported++; } return { - message: `成功导入 ${imported} 名学生,跳过 ${skipped} 条(重复或空行)`, + message: `成功导入 ${imported} 名学生,导入档案相关记录 ${archiveImported} 条,跳过 ${skipped} 条(重复或空行)`, imported, + archiveImported, skipped, }; } - async matchImport( - rows: { - name: string; - studentNo?: string; - phone?: string; - idNumber?: string; - gender?: string; - ethnicity?: string; - emergencyContact?: string; - emergencyPhone?: string; - organization?: string; - supervisor?: string; - organizationId?: number; - }[], - ) { + async matchImport(importData: StudentWorkbookImport | StudentImportRow[]) { + const data = this.normalizeImportData(importData); let matched = 0; let skipped = 0; - for (const row of rows) { + let archiveImported = 0; + for (const row of data.students) { // Match by phone first, then idNumber let student = row.phone?.trim() ? await this.repo.findOne({ where: { phone: row.phone.trim() } }) @@ -291,15 +303,187 @@ export class StudentsService { if (row.supervisor) updates.supervisor = row.supervisor; if (row.organizationId) updates.organizationId = row.organizationId; await this.repo.update(student.id, updates); + archiveImported += await this.importArchiveData(student.id, row, data); matched++; } return { - message: `更新已有学生资料 ${matched} 人,跳过 ${skipped} 条(无匹配)`, + message: `更新已有学生资料 ${matched} 人,导入档案相关记录 ${archiveImported} 条,跳过 ${skipped} 条(无匹配)`, matched, + archiveImported, skipped, }; } + private normalizeImportData(importData: StudentWorkbookImport | StudentImportRow[]): StudentWorkbookImport { + if (Array.isArray(importData)) { + return { students: importData, enrollments: [], examScores: [], learningRecords: [] }; + } + return importData; + } + + private normalizePhone(phone?: string) { + return phone?.trim() || ''; + } + + private sameValue(left?: string | number | null, right?: string | number | null) { + return String(left ?? '').trim() === String(right ?? '').trim(); + } + + private hasProfileData(row: StudentImportRow) { + return [ + row.targetCollege, + row.targetMajor, + row.subjectDirection, + row.grade, + row.profileDate, + row.notes, + ].some((value) => value !== undefined && String(value).trim() !== ''); + } + + private hasResultData(row: StudentImportRow) { + return [ + row.cultureFinalScore, + row.professionalFinalScore, + row.admissionStatus, + row.admittedCollege, + row.admittedMajor, + ].some((value) => value !== undefined && String(value).trim() !== ''); + } + + private async importArchiveData( + studentId: number, + row: StudentImportRow, + data: StudentWorkbookImport, + ) { + const phone = this.normalizePhone(row.phone); + let imported = 0; + if (this.hasProfileData(row)) { + await this.upsertProfileFromImport(studentId, row); + imported++; + } + if (this.hasResultData(row)) { + await this.upsertResultFromImport(studentId, row); + imported++; + } + if (!phone) return imported; + + const enrollmentByClassName = new Map(); + for (const enrollmentRow of data.enrollments.filter((item) => this.normalizePhone(item.phone) === phone)) { + const enrollment = await this.upsertEnrollmentFromImport(studentId, enrollmentRow); + if (!enrollment) continue; + if (enrollment.className) enrollmentByClassName.set(enrollment.className, enrollment); + imported++; + } + for (const examRow of data.examScores.filter((item) => this.normalizePhone(item.phone) === phone)) { + if (await this.upsertExamScoreFromImport(studentId, examRow, enrollmentByClassName)) { + imported++; + } + } + for (const learningRow of data.learningRecords.filter((item) => this.normalizePhone(item.phone) === phone)) { + if (await this.upsertLearningRecordFromImport(studentId, learningRow)) { + imported++; + } + } + return imported; + } + + private async upsertProfileFromImport(studentId: number, row: StudentImportRow) { + 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.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(); + if (row.notes?.trim()) entity.notes = row.notes.trim(); + await this.profileRepo.save(entity); + } + + private async upsertResultFromImport(studentId: number, row: StudentImportRow) { + const entity = (await this.resultRepo.findOne({ where: { studentId } })) || this.resultRepo.create({ studentId }); + if (row.cultureFinalScore !== undefined) entity.cultureFinalScore = row.cultureFinalScore; + if (row.professionalFinalScore !== undefined) entity.professionalFinalScore = row.professionalFinalScore; + if (row.admissionStatus?.trim()) entity.admissionStatus = row.admissionStatus.trim(); + if (row.admittedCollege?.trim()) entity.admittedCollege = row.admittedCollege.trim(); + if (row.admittedMajor?.trim()) entity.admittedMajor = row.admittedMajor.trim(); + await this.resultRepo.save(entity); + } + + private async upsertEnrollmentFromImport(studentId: number, row: StudentEnrollmentImportRow) { + if (!row.courseCategory?.trim() || !row.classType?.trim()) { + return null; + } + const existing = await this.enrollmentRepo.find({ where: { studentId } }); + const entity = + existing.find( + (item) => + this.sameValue(item.courseCategory, row.courseCategory) && + this.sameValue(item.classType, row.classType) && + this.sameValue(item.className, row.className) && + this.sameValue(item.startDate, row.startDate), + ) || this.enrollmentRepo.create({ studentId }); + entity.courseCategory = row.courseCategory.trim(); + entity.classType = row.classType.trim(); + if (row.className?.trim()) entity.className = row.className.trim(); + if (row.headTeacher?.trim()) entity.headTeacher = row.headTeacher.trim(); + if (row.subjectTeacher?.trim()) entity.subjectTeacher = row.subjectTeacher.trim(); + if (row.startDate?.trim()) entity.startDate = row.startDate.trim(); + if (row.endDate?.trim()) entity.endDate = row.endDate.trim(); + if (row.status?.trim()) entity.status = row.status.trim(); + else if (!entity.status) entity.status = 'active'; + return this.enrollmentRepo.save(entity); + } + + private async upsertExamScoreFromImport( + studentId: number, + row: ExamScoreImportRow, + enrollmentByClassName: Map, + ) { + if (!row.examType?.trim() || !row.subject?.trim() || row.score === undefined) return false; + const existing = await this.examScoreRepo.find({ where: { studentId } }); + const entity = + existing.find( + (item) => + this.sameValue(item.examType, row.examType) && + this.sameValue(item.examName, row.examName) && + this.sameValue(item.subject, row.subject) && + this.sameValue(item.examDate, row.examDate), + ) || this.examScoreRepo.create({ studentId }); + entity.examType = row.examType.trim(); + entity.subject = row.subject.trim(); + entity.score = row.score; + if (row.examName?.trim()) entity.examName = row.examName.trim(); + if (row.classAvg !== undefined) entity.classAvg = row.classAvg; + if (row.rank !== undefined) entity.rank = row.rank; + if (row.examDate?.trim()) entity.examDate = row.examDate.trim(); + if (row.enrollmentName?.trim()) { + const enrollment = enrollmentByClassName.get(row.enrollmentName.trim()); + if (enrollment) entity.enrollmentId = enrollment.id; + } + if (!entity.status) entity.status = 'active'; + await this.examScoreRepo.save(entity); + return true; + } + + private async upsertLearningRecordFromImport(studentId: number, row: LearningRecordImportRow) { + if (!row.recordDate?.trim() || !row.recordType?.trim() || !row.content?.trim()) return false; + const existing = await this.learningRecordRepo.find({ where: { studentId } }); + const entity = + existing.find( + (item) => + this.sameValue(item.recordDate, row.recordDate) && + this.sameValue(item.recordType, row.recordType) && + this.sameValue(item.content, row.content), + ) || this.learningRecordRepo.create({ studentId }); + entity.recordDate = row.recordDate.trim(); + entity.recordType = row.recordType.trim(); + entity.content = row.content.trim(); + if (row.followUpMethod?.trim()) entity.followUpMethod = row.followUpMethod.trim(); + if (row.nextStep?.trim()) entity.nextStep = row.nextStep.trim(); + if (!entity.status) entity.status = 'active'; + await this.learningRecordRepo.save(entity); + return true; + } + private async assertActiveOrganization(id: number) { const organization = await this.organizationRepo.findOne({ where: { id, status: 'active' } }); if (!organization) throw new BadRequestException('所属机构不存在或已归档');