diff --git a/apps/admin/src/components/StudentProfileContent/index.tsx b/apps/admin/src/components/StudentProfileContent/index.tsx index 3877f22..92f8564 100644 --- a/apps/admin/src/components/StudentProfileContent/index.tsx +++ b/apps/admin/src/components/StudentProfileContent/index.tsx @@ -30,6 +30,10 @@ import { CloseOutlined, FileTextOutlined, ReloadOutlined, + ImportOutlined, + DownloadOutlined, + CheckCircleOutlined, + CloseCircleOutlined, } from '@ant-design/icons'; import dayjs from 'dayjs'; import api from '../../api'; @@ -918,6 +922,19 @@ const StudentProfileContent: React.FC = ({ const [aggregateData, setAggregateData] = useState(null); const [loading, setLoading] = useState(false); + // 批量导入状态 + const [importModalOpen, setImportModalOpen] = useState(false); + const [importing, setImporting] = useState(false); + const [importResult, setImportResult] = useState<{ + students: { created: number; updated: number }; + profiles: number; + results: number; + enrollments: number; + examScores: number; + learningRecords: number; + errors: Array<{ sheet: string; row: number; phone: string; reason: string }>; + } | null>(null); + const fetchData = useCallback(async () => { setLoading(true); try { @@ -935,6 +952,38 @@ const StudentProfileContent: React.FC = ({ void fetchData(); }, [fetchData]); + const handleImport = async (file: File) => { + setImporting(true); + try { + const formData = new FormData(); + formData.append('file', file); + const result = await api.post('/archive/import', formData, { + headers: { 'Content-Type': 'multipart/form-data' }, + }); + setImportResult(result); + setImportModalOpen(true); + } catch (e: unknown) { + const err = e as { message?: string }; + message.error(err?.message || '导入失败'); + } finally { + setImporting(false); + } + }; + + const handleDownloadTemplate = async () => { + try { + const blob = await api.get('/archive/import/template', { responseType: 'blob' }); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = '学生档案批量导入模板.xlsx'; + a.click(); + URL.revokeObjectURL(url); + } catch { + message.error('下载模板失败'); + } + }; + const handlePreviewReport = useCallback(async () => { try { const { html } = await api.get<{ html: string }>(`/archive/${studentId}/report-html`); @@ -1022,24 +1071,38 @@ const StudentProfileContent: React.FC = ({ return (
- {inDrawer && ( - - + + + {inDrawer && ( + handleImport(file as File)} + > + + + {!inDrawer && ( - - - - )} + )} + + + {[ @@ -1101,6 +1164,85 @@ const StudentProfileContent: React.FC = ({ + + {/* 批量导入结果弹窗 */} + { + setImportModalOpen(false); + setImportResult(null); + fetchData(); + }} + footer={ + + } + width={560} + > + {importResult && ( +
+ + + + + + + + + + + + + + + + + + + + + + {importResult.errors.length > 0 ? ( +
+
+ + 失败 {importResult.errors.length} 条 +
+ String(i)} + size="small" + pagination={false} + columns={[ + { title: 'Sheet', dataIndex: 'sheet', width: 140 }, + { title: '行号', dataIndex: 'row', width: 60 }, + { title: '手机号', dataIndex: 'phone', width: 120 }, + { title: '原因', dataIndex: 'reason' }, + ]} + /> + + ) : ( +
+ + 全部导入成功! +
+ )} + + )} + ); }; diff --git a/apps/server/src/archive/archive.controller.ts b/apps/server/src/archive/archive.controller.ts index fd9aeb4..91c7c6f 100644 --- a/apps/server/src/archive/archive.controller.ts +++ b/apps/server/src/archive/archive.controller.ts @@ -12,10 +12,12 @@ import { UploadedFile, Res, ParseIntPipe, + BadRequestException, } from '@nestjs/common'; import { FileInterceptor } from '@nestjs/platform-express'; import type { Request as ExpressRequest, Response } from 'express'; import * as fs from 'fs'; +import * as ExcelJS from 'exceljs'; import { ArchiveReportService } from './archive-report.service'; import { ArchiveService } from './archive.service'; import { @@ -382,4 +384,120 @@ export class ArchiveController { const html = await this.reportService.generateReportHtml(studentId); return { html }; } + + /** Excel 批量导入学生档案 */ + @Post('import') + @RequirePermission('student:import') + @UseInterceptors(FileInterceptor('file')) + async batchImport(@UploadedFile() file: Express.Multer.File, @Request() req: AuthenticatedRequest) { + if (!file) throw new BadRequestException('请上传 Excel 文件'); + const { ipAddress, userAgent } = extractRequestInfo(req); + const result = await this.archiveService.batchImportArchive(file.buffer); + await this.logService.log({ + userId: req.user?.id, + username: req.user?.username, + module: '学生档案', + action: '批量导入档案', + detail: `新建学生:${result.students.created} 更新:${result.students.updated} 报读:${result.enrollments} 考试:${result.examScores} 回访:${result.learningRecords} 档案:${result.profiles} 录取:${result.results}`, + ipAddress, + userAgent, + }); + return result; + } + + /** 下载批量导入模板 */ + @Get('import/template') + @RequirePermission('student:view') + async downloadTemplate(@Res() res: Response) { + const workbook = new ExcelJS.Workbook(); + const headerStyle = (row: ExcelJS.Row) => { + row.font = { bold: true, size: 11, color: { argb: 'FFFFFFFF' } }; + row.fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FF4472C4' } }; + row.alignment = { vertical: 'middle', horizontal: 'center', wrapText: true }; + row.height = 36; + }; + const addNote = (cell: ExcelJS.Cell, text: string) => { + cell.note = { + texts: [{ font: { size: 10 }, text }], + margins: { insetmode: 'auto' as any }, + }; + }; + + // Sheet1: 学生基础+档案+录取 + { + const ws = workbook.addWorksheet('学生基础+档案+录取'); + const headers = [ + '手机号*', '姓名*', '学号', '性别', '身份证号', '民族', + '紧急联系人', '紧急联系人电话', '所属机构', '负责人', + '目标院校', '目标专业', '选科方向', '年级', '建档日期', + '档案备注', '文化课最终分', '专业课最终分', '录取状态', '录取院校', '录取专业', + ]; + const notes = [ + '必填。手机号匹配已有学生,不存在则新建', '必填。学生姓名', '选填。学号/考号', '选填。男/女', '选填。身份证号', + '选填。如:汉族', '选填。紧急联系人', '选填。紧急联系人电话', '选填。机构/校区名,自动匹配', '选填。负责人/班主任', + '选填。目标院校', '选填。目标专业', '选填。物化生/史地政', '选填。高三/高二', '选填。YYYY-MM-DD', + '选填。备注', '选填。文化课最终分', '选填。专业课最终分', + '选填。admitted/pending/rejected/withdrawn', '选填。录取院校', '选填。录取专业', + ]; + ws.columns = headers.map((h) => ({ header: h, key: h, width: Math.max(h.length * 2.5, 14) })); + const headerRow = ws.getRow(1); + headerStyle(headerRow); + headerRow.eachCell((c, i) => { if (notes[i - 1]) addNote(c, notes[i - 1]); }); + ws.views = [{ state: 'frozen', ySplit: 1 }]; + ws.addRow(['13800138000', '张三', '2024001', '男', '', '汉族', '张父', '13900139000', '北京校区', '李老师', '北京大学', '计算机', '物化生', '高三', '2024-09-01', '', '', '', '', '', '']); + } + + // Sheet2: 报读班型 + { + const ws = workbook.addWorksheet('报读班型'); + const headers = ['手机号*', '姓名', '课程类别*', '班型*', '班级名称', '班主任', '任课教师', '开始日期', '结束日期', '状态']; + const notes = [ + '必填。匹配学生', '', '必填。culture/professional/comprehensive', '必填。one_on_one/small_group/large_class/online/offline', + '选填', '选填', '选填', '选填。YYYY-MM-DD', '选填。YYYY-MM-DD', '选填。active/completed/withdrawn', + ]; + ws.columns = headers.map((h) => ({ header: h, key: h, width: 14 })); + const headerRow = ws.getRow(1); + headerStyle(headerRow); + headerRow.eachCell((c, i) => { if (notes[i - 1]) addNote(c, notes[i - 1]); }); + ws.views = [{ state: 'frozen', ySplit: 1 }]; + ws.addRow(['13800138000', '张三', 'culture', 'one_on_one', '2024届冲刺班', '王老师', '刘老师', '2024-09-01', '2025-06-01', 'active']); + } + + // Sheet3: 考试成绩 + { + const ws = workbook.addWorksheet('考试成绩'); + const headers = ['手机号*', '姓名', '考试类型*', '考试名称', '科目*', '成绩*', '班级均分', '排名', '考试日期', '关联报读(班级名)']; + const notes = [ + '必填', '', '必填。monthly/midterm/final/mock/entrance/other', '选填', '必填。科目名', + '必填。分数', '选填', '选填', '选填。YYYY-MM-DD', '选填。填班级名匹配报读记录', + ]; + ws.columns = headers.map((h) => ({ header: h, key: h, width: 16 })); + const headerRow = ws.getRow(1); + headerStyle(headerRow); + headerRow.eachCell((c, i) => { if (notes[i - 1]) addNote(c, notes[i - 1]); }); + ws.views = [{ state: 'frozen', ySplit: 1 }]; + ws.addRow(['13800138000', '张三', 'monthly', '第一次月考', '数学', '95', '82', '3', '2024-10-15', '2024届冲刺班']); + } + + // Sheet4: 课堂回访 + { + const ws = workbook.addWorksheet('课堂回访'); + const headers = ['手机号*', '姓名', '记录日期*', '记录类型*', '内容*', '跟进方式', '下一步计划']; + const notes = [ + '必填', '', '必填。YYYY-MM-DD', '必填。study_feedback/parent_communication/behavior_note/meeting/other', + '必填。回访内容', '选填。电话/微信/面谈', '选填', + ]; + ws.columns = headers.map((h) => ({ header: h, key: h, width: 18 })); + const headerRow = ws.getRow(1); + headerStyle(headerRow); + headerRow.eachCell((c, i) => { if (notes[i - 1]) addNote(c, notes[i - 1]); }); + ws.views = [{ state: 'frozen', ySplit: 1 }]; + ws.addRow(['13800138000', '张三', '2024-10-20', 'study_feedback', '数学进步明显,建议加强几何练习', '电话', '下周几何辅导']); + } + + res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'); + res.setHeader('Content-Disposition', 'attachment; filename=student_archive_template.xlsx'); + await workbook.xlsx.write(res); + res.end(); + } } diff --git a/apps/server/src/archive/archive.module.ts b/apps/server/src/archive/archive.module.ts index d755a89..1de96c4 100644 --- a/apps/server/src/archive/archive.module.ts +++ b/apps/server/src/archive/archive.module.ts @@ -8,6 +8,7 @@ import { LearningRecord } from '../entities/learning-record.entity'; import { ResultArchive } from '../entities/result-archive.entity'; import { ArchiveAttachment } from '../entities/archive-attachment.entity'; import { AttendanceRecord } from '../entities/attendance-record.entity'; +import { Organization } from '../entities/organization.entity'; import { NotificationsModule } from '../notifications/notifications.module'; import { ArchiveService } from './archive.service'; import { ArchiveReportService } from './archive-report.service'; @@ -24,6 +25,7 @@ import { ArchiveController } from './archive.controller'; ResultArchive, ArchiveAttachment, AttendanceRecord, + Organization, ]), NotificationsModule, ], diff --git a/apps/server/src/archive/archive.service.ts b/apps/server/src/archive/archive.service.ts index e867620..5d568cf 100644 --- a/apps/server/src/archive/archive.service.ts +++ b/apps/server/src/archive/archive.service.ts @@ -3,6 +3,7 @@ import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import * as fs from 'fs'; import * as path from 'path'; +import * as ExcelJS from 'exceljs'; import { NotificationsService } from '../notifications/notifications.service'; import { Student } from '../entities/student.entity'; @@ -13,6 +14,7 @@ import { LearningRecord } from '../entities/learning-record.entity'; import { ResultArchive } from '../entities/result-archive.entity'; import { ArchiveAttachment } from '../entities/archive-attachment.entity'; import { AttendanceRecord } from '../entities/attendance-record.entity'; +import { Organization } from '../entities/organization.entity'; import { UpsertProfileDto, CreateEnrollmentDto, @@ -35,6 +37,7 @@ export class ArchiveService { @InjectRepository(ResultArchive) private resultRepo: Repository, @InjectRepository(ArchiveAttachment) private attachmentRepo: Repository, @InjectRepository(AttendanceRecord) private attendanceRepo: Repository, + @InjectRepository(Organization) private orgRepo: Repository, private readonly notificationsService: NotificationsService, ) {} @@ -248,4 +251,389 @@ export class ArchiveService { await this.attachmentRepo.update(id, { status: 'archived' }); return { message: '已归档' }; } + + // ── Excel 批量导入 ────────────────────────────────────────────── + + /** 从 Excel cell 提取文本 */ + private getCellText(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 as any).text || ''); + if ('richText' in value && Array.isArray((value as any).richText)) { + return (value as any).richText.map((part: any) => part.text).join(''); + } + if ('result' in value) return String((value as any).result || ''); + } + return String(value); + } + + private parseSheetHeaders(ws: ExcelJS.Worksheet): Map { + const map = new Map(); + ws.getRow(1).eachCell((cell, colNumber) => { + const h = this.getCellText(cell).trim(); + if (h) map.set(colNumber, h); + }); + return map; + } + + private getRowData(headers: Map, row: ExcelJS.Row): Record { + const data: Record = {}; + headers.forEach((header, colNumber) => { + const val = this.getCellText(row.getCell(colNumber)).trim(); + if (val) data[header] = val; + }); + return data; + } + + /** 通过手机号查找或创建学生 */ + private async findOrCreateStudent( + phone: string, + fields: Record, + ): Promise<{ student: Student; created: boolean }> { + let student = await this.studentRepo.findOne({ where: { phone } }); + if (student) { + // 更新已有学生 + const updates: Partial = {}; + if (fields['姓名*']?.trim()) updates.name = fields['姓名*'].trim(); + if (fields['学号']?.trim()) updates.studentNo = fields['学号'].trim(); + if (fields['性别']?.trim()) updates.gender = fields['性别'].trim(); + if (fields['身份证号']?.trim()) updates.idNumber = fields['身份证号'].trim(); + if (fields['民族']?.trim()) updates.ethnicity = fields['民族'].trim(); + if (fields['紧急联系人']?.trim()) updates.emergencyContact = fields['紧急联系人'].trim(); + if (fields['紧急联系人电话']?.trim()) updates.emergencyPhone = fields['紧急联系人电话'].trim(); + if (fields['负责人']?.trim()) updates.supervisor = fields['负责人'].trim(); + if (fields['所属机构']?.trim()) { + const org = await this.orgRepo.findOne({ where: { name: fields['所属机构'].trim() } }); + if (org) updates.organizationId = org.id; + } + if (Object.keys(updates).length > 0) { + await this.studentRepo.update(student.id, updates); + } + return { student, created: false }; + } + // 新学生 + const name = fields['姓名*']?.trim(); + if (!name) throw new BadRequestException('姓名不能为空'); + let organizationId: number | undefined; + if (fields['所属机构']?.trim()) { + const org = await this.orgRepo.findOne({ where: { name: fields['所属机构'].trim() } }); + if (org) organizationId = org.id; + } + const newStudent = this.studentRepo.create({ + name, + phone, + studentNo: fields['学号']?.trim() || undefined, + gender: fields['性别']?.trim() || undefined, + idNumber: fields['身份证号']?.trim() || undefined, + ethnicity: fields['民族']?.trim() || undefined, + emergencyContact: fields['紧急联系人']?.trim() || undefined, + emergencyPhone: fields['紧急联系人电话']?.trim() || undefined, + supervisor: fields['负责人']?.trim() || undefined, + organizationId, + }); + student = await this.studentRepo.save(newStudent); + return { student, created: true }; + } + + /** 收集所有数据行(跳过表头) */ + private collectRows(ws: ExcelJS.Worksheet): Array<{ rowNumber: number; data: Record }> { + const headers = this.parseSheetHeaders(ws); + const rows: Array<{ rowNumber: number; data: Record }> = []; + ws.eachRow((row, rowNumber) => { + if (rowNumber === 1) return; + const data = this.getRowData(headers, row); + const phone = data['手机号*']; + if (!phone) return; + rows.push({ rowNumber, data }); + }); + return rows; + } + + /** 导入 Sheet1: 学生基础+扩展档案+录取归档 */ + private async importSheet1(ws: ExcelJS.Worksheet): Promise<{ + studentsCreated: number; + studentsUpdated: number; + profilesUpserted: number; + resultsUpserted: number; + errors: Array<{ row: number; phone: string; reason: string }>; + }> { + const rows = this.collectRows(ws); + let studentsCreated = 0; + let studentsUpdated = 0; + let profilesUpserted = 0; + let resultsUpserted = 0; + const errors: Array<{ row: number; phone: string; reason: string }> = []; + + for (const { rowNumber, data } of rows) { + const phone = data['手机号*']; + try { + const { student, created } = await this.findOrCreateStudent(phone, data); + if (created) studentsCreated++; + else studentsUpdated++; + + // 扩展档案 + const profileFields = ['目标院校', '目标专业', '选科方向', '年级', '建档日期', '档案备注']; + const hasProfileData = profileFields.some((f) => data[f]); + if (hasProfileData) { + const existingProfile = await this.profileRepo.findOne({ where: { studentId: student.id } }); + const profilePayload: Record = { studentId: student.id }; + if (data['目标院校']) profilePayload.targetCollege = data['目标院校']; + if (data['目标专业']) profilePayload.targetMajor = data['目标专业']; + if (data['选科方向']) profilePayload.subjectDirection = data['选科方向']; + if (data['年级']) profilePayload.grade = data['年级']; + if (data['建档日期']) profilePayload.profileDate = data['建档日期']; + if (data['档案备注']) profilePayload.notes = data['档案备注']; + if (existingProfile) { + Object.assign(existingProfile, profilePayload); + await this.profileRepo.save(existingProfile); + } else { + const newProfile = this.profileRepo.create(profilePayload as any); + await this.profileRepo.save(newProfile); + } + profilesUpserted++; + } + + // 录取归档 + const resultFields = ['文化课最终分', '专业课最终分', '录取状态', '录取院校', '录取专业']; + const hasResultData = resultFields.some((f) => data[f]); + if (hasResultData) { + const existingResult = await this.resultRepo.findOne({ where: { studentId: student.id } }); + const resultPayload: Record = { studentId: student.id }; + if (data['文化课最终分']) resultPayload.cultureFinalScore = parseFloat(data['文化课最终分']); + if (data['专业课最终分']) resultPayload.professionalFinalScore = parseFloat(data['专业课最终分']); + if (data['录取状态']) resultPayload.admissionStatus = data['录取状态']; + if (data['录取院校']) resultPayload.admittedCollege = data['录取院校']; + if (data['录取专业']) resultPayload.admittedMajor = data['录取专业']; + if (existingResult) { + Object.assign(existingResult, resultPayload); + await this.resultRepo.save(existingResult); + } else { + const newResult = this.resultRepo.create(resultPayload as any); + await this.resultRepo.save(newResult); + } + resultsUpserted++; + } + } catch (e: any) { + errors.push({ row: rowNumber, phone, reason: e.message || '未知错误' }); + } + } + + return { studentsCreated, studentsUpdated, profilesUpserted, resultsUpserted, errors }; + } + + /** 通过手机号查找学生,不存在则报错 */ + private async findStudentByPhone(phone: string): Promise { + const student = await this.studentRepo.findOne({ where: { phone } }); + if (!student) throw new BadRequestException(`手机号 ${phone} 未匹配到学生`); + return student; + } + + /** 导入 Sheet2: 报读班型 */ + private async importSheet2(ws: ExcelJS.Worksheet): Promise<{ + enrollmentsCreated: number; + errors: Array<{ row: number; phone: string; reason: string }>; + }> { + const rows = this.collectRows(ws); + let enrollmentsCreated = 0; + const errors: Array<{ row: number; phone: string; reason: string }> = []; + + for (const { rowNumber, data } of rows) { + const phone = data['手机号*']; + try { + const student = await this.findStudentByPhone(phone); + const courseCategory = data['课程类别*']; + const classType = data['班型*']; + if (!courseCategory || !classType) { + errors.push({ row: rowNumber, phone, reason: '课程类别和班型为必填' }); + continue; + } + const enrollment = this.enrollmentRepo.create({ + studentId: student.id, + courseCategory, + classType, + className: data['班级名称'] || undefined, + headTeacher: data['班主任'] || undefined, + subjectTeacher: data['任课教师'] || undefined, + startDate: data['开始日期'] || undefined, + endDate: data['结束日期'] || undefined, + status: data['状态'] || 'active', + }); + await this.enrollmentRepo.save(enrollment); + enrollmentsCreated++; + } catch (e: any) { + errors.push({ row: rowNumber, phone, reason: e.message || '未知错误' }); + } + } + + return { enrollmentsCreated, errors }; + } + + /** 导入 Sheet3: 考试成绩 */ + private async importSheet3(ws: ExcelJS.Worksheet): Promise<{ + examScoresCreated: number; + errors: Array<{ row: number; phone: string; reason: string }>; + }> { + const rows = this.collectRows(ws); + let examScoresCreated = 0; + const errors: Array<{ row: number; phone: string; reason: string }> = []; + + for (const { rowNumber, data } of rows) { + const phone = data['手机号*']; + try { + const student = await this.findStudentByPhone(phone); + const examType = data['考试类型*']; + const subject = data['科目*']; + const score = data['成绩*']; + if (!examType || !subject || score === undefined) { + errors.push({ row: rowNumber, phone, reason: '考试类型、科目、成绩为必填' }); + continue; + } + + // 尝试匹配关联报读 + let enrollmentId: number | undefined; + const enrollmentRef = data['关联报读(班级名)']; + if (enrollmentRef) { + const enrollment = await this.enrollmentRepo.findOne({ + where: { studentId: student.id, className: enrollmentRef }, + }); + if (enrollment) enrollmentId = enrollment.id; + } + + const examScore = this.examScoreRepo.create({ + studentId: student.id, + examType, + examName: data['考试名称'] || undefined, + subject, + score: parseFloat(score), + classAvg: data['班级均分'] ? parseFloat(data['班级均分']) : undefined, + rank: data['排名'] ? parseInt(data['排名'], 10) : undefined, + examDate: data['考试日期'] || undefined, + enrollmentId, + }); + await this.examScoreRepo.save(examScore); + examScoresCreated++; + } catch (e: any) { + errors.push({ row: rowNumber, phone, reason: e.message || '未知错误' }); + } + } + + return { examScoresCreated, errors }; + } + + /** 导入 Sheet4: 课堂回访 */ + private async importSheet4(ws: ExcelJS.Worksheet): Promise<{ + learningRecordsCreated: number; + errors: Array<{ row: number; phone: string; reason: string }>; + }> { + const rows = this.collectRows(ws); + let learningRecordsCreated = 0; + const errors: Array<{ row: number; phone: string; reason: string }> = []; + + for (const { rowNumber, data } of rows) { + const phone = data['手机号*']; + try { + const student = await this.findStudentByPhone(phone); + const recordDate = data['记录日期*']; + const recordType = data['记录类型*']; + const content = data['内容*']; + if (!recordDate || !recordType || !content) { + errors.push({ row: rowNumber, phone, reason: '记录日期、记录类型、内容为必填' }); + continue; + } + + const learningRecord = this.learningRecordRepo.create({ + studentId: student.id, + recordDate, + recordType, + content, + followUpMethod: data['跟进方式'] || undefined, + nextStep: data['下一步计划'] || undefined, + }); + await this.learningRecordRepo.save(learningRecord); + learningRecordsCreated++; + } catch (e: any) { + errors.push({ row: rowNumber, phone, reason: e.message || '未知错误' }); + } + } + + return { learningRecordsCreated, errors }; + } + + /** 批量导入学生档案 Excel */ + async batchImportArchive(fileBuffer: Buffer): Promise<{ + students: { created: number; updated: number }; + profiles: number; + results: number; + enrollments: number; + examScores: number; + learningRecords: number; + errors: Array<{ sheet: string; row: number; phone: string; reason: string }>; + }> { + const workbook = new ExcelJS.Workbook(); + await workbook.xlsx.load(fileBuffer as any); + + const errors: Array<{ sheet: string; row: number; phone: string; reason: string }> = []; + let studentsCreated = 0; + let studentsUpdated = 0; + let profilesUpserted = 0; + let resultsUpserted = 0; + let enrollmentsCreated = 0; + let examScoresCreated = 0; + let learningRecordsCreated = 0; + + // Sheet1: 学生基础+扩展档案+录取归档 + { + const ws = workbook.getWorksheet('学生基础+档案+录取'); + if (ws) { + const { studentsCreated: sc, studentsUpdated: su, profilesUpserted: pu, resultsUpserted: ru, errors: errs } = + await this.importSheet1(ws); + studentsCreated += sc; + studentsUpdated += su; + profilesUpserted += pu; + resultsUpserted += ru; + for (const e of errs) errors.push({ sheet: '学生基础+档案+录取', ...e }); + } + } + + // Sheet2: 报读班型 + { + const ws = workbook.getWorksheet('报读班型'); + if (ws) { + const { enrollmentsCreated: ec, errors: errs } = await this.importSheet2(ws); + enrollmentsCreated += ec; + for (const e of errs) errors.push({ sheet: '报读班型', ...e }); + } + } + + // Sheet3: 考试成绩 + { + const ws = workbook.getWorksheet('考试成绩'); + if (ws) { + const { examScoresCreated: esc, errors: errs } = await this.importSheet3(ws); + examScoresCreated += esc; + for (const e of errs) errors.push({ sheet: '考试成绩', ...e }); + } + } + + // Sheet4: 课堂回访 + { + const ws = workbook.getWorksheet('课堂回访'); + if (ws) { + const { learningRecordsCreated: lrc, errors: errs } = await this.importSheet4(ws); + learningRecordsCreated += lrc; + for (const e of errs) errors.push({ sheet: '课堂回访', ...e }); + } + } + + return { + students: { created: studentsCreated, updated: studentsUpdated }, + profiles: profilesUpserted, + results: resultsUpserted, + enrollments: enrollmentsCreated, + examScores: examScoresCreated, + learningRecords: learningRecordsCreated, + errors, + }; + } }