feat: 学生档案 Excel 批量导入

- 4-Sheet Excel 模板:学生基础+档案+录取 / 报读班型 / 考试成绩 / 课堂回访
- 手机号精确匹配已有学生,不存在则新建
- POST /archive/import 批量导入接口
- GET /archive/import/template 模板下载接口
- 前端:下载模板 + 上传导入 + 结果汇总弹窗
This commit is contained in:
2026-07-20 15:05:41 +08:00
parent bbd700369c
commit 8494c511b6
4 changed files with 664 additions and 14 deletions

View File

@@ -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<StudentProfileContentProps> = ({
const [aggregateData, setAggregateData] = useState<StudentProfileAggregate | null>(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<StudentProfileContentProps> = ({
void fetchData();
}, [fetchData]);
const handleImport = async (file: File) => {
setImporting(true);
try {
const formData = new FormData();
formData.append('file', file);
const result = await api.post<typeof importResult>('/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<Blob>('/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<StudentProfileContentProps> = ({
return (
<div>
{inDrawer && (
<Row justify="space-between" align="middle" style={{ marginBottom: 24 }}>
<Space>
<Row justify="space-between" align="middle" style={{ marginBottom: 24 }}>
<Space>
{inDrawer && (
<Button type="text" icon={<CloseOutlined />} onClick={onClose} aria-label="关闭档案" />
<span style={{ fontSize: 16, fontWeight: 500 }}>
- {student.name} ({student.studentNo})
</span>
</Space>
<Space>
)}
<span style={{ fontSize: 16, fontWeight: 500 }}>
{inDrawer ? `学员档案 - ${student.name} (${student.studentNo})` : '档案操作'}
</span>
</Space>
<Space>
<Button icon={<DownloadOutlined />} onClick={handleDownloadTemplate}>
</Button>
<Upload
showUploadList={false}
accept=".xlsx,.xls"
customRequest={({ file }) => handleImport(file as File)}
>
<Button icon={<ImportOutlined />} loading={importing}>
</Button>
</Upload>
{!inDrawer && (
<Button icon={<FileTextOutlined />} onClick={handlePreviewReport}>
</Button>
<Button icon={<ReloadOutlined />} onClick={fetchData} loading={loading}>
</Button>
</Space>
</Row>
)}
)}
<Button icon={<ReloadOutlined />} onClick={fetchData} loading={loading}>
</Button>
</Space>
</Row>
<Row gutter={16} style={{ marginBottom: 24 }}>
{[
@@ -1101,6 +1164,85 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
</Descriptions>
<Tabs defaultActiveKey="profile" items={tabItems} />
{/* 批量导入结果弹窗 */}
<Modal
title="导入结果"
open={importModalOpen}
onCancel={() => {
setImportModalOpen(false);
setImportResult(null);
fetchData();
}}
footer={
<Button
type="primary"
onClick={() => {
setImportModalOpen(false);
setImportResult(null);
fetchData();
}}
>
</Button>
}
width={560}
>
{importResult && (
<div>
<Row gutter={[16, 12]} style={{ marginBottom: 16 }}>
<Col span={8}>
<Statistic
title="学生"
value={`新建 ${importResult.students.created} / 更新 ${importResult.students.updated}`}
valueStyle={{ fontSize: 16 }}
/>
</Col>
<Col span={8}>
<Statistic title="扩展档案" value={importResult.profiles} valueStyle={{ fontSize: 16 }} />
</Col>
<Col span={8}>
<Statistic title="录取归档" value={importResult.results} valueStyle={{ fontSize: 16 }} />
</Col>
<Col span={8}>
<Statistic title="报读班型" value={importResult.enrollments} valueStyle={{ fontSize: 16 }} />
</Col>
<Col span={8}>
<Statistic title="考试成绩" value={importResult.examScores} valueStyle={{ fontSize: 16 }} />
</Col>
<Col span={8}>
<Statistic title="课堂回访" value={importResult.learningRecords} valueStyle={{ fontSize: 16 }} />
</Col>
</Row>
{importResult.errors.length > 0 ? (
<div>
<div style={{ marginBottom: 8, fontWeight: 500 }}>
<CloseCircleOutlined style={{ color: '#ff4d4f', marginRight: 4 }} />
{importResult.errors.length}
</div>
<Table
dataSource={importResult.errors}
rowKey={(_, i) => 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' },
]}
/>
</div>
) : (
<div style={{ color: '#52c41a' }}>
<CheckCircleOutlined style={{ marginRight: 4 }} />
</div>
)}
</div>
)}
</Modal>
</div>
);
};

View File

@@ -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();
}
}

View File

@@ -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,
],

View File

@@ -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<ResultArchive>,
@InjectRepository(ArchiveAttachment) private attachmentRepo: Repository<ArchiveAttachment>,
@InjectRepository(AttendanceRecord) private attendanceRepo: Repository<AttendanceRecord>,
@InjectRepository(Organization) private orgRepo: Repository<Organization>,
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<number, string> {
const map = new Map<number, string>();
ws.getRow(1).eachCell((cell, colNumber) => {
const h = this.getCellText(cell).trim();
if (h) map.set(colNumber, h);
});
return map;
}
private getRowData(headers: Map<number, string>, row: ExcelJS.Row): Record<string, string> {
const data: Record<string, string> = {};
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<string, string>,
): Promise<{ student: Student; created: boolean }> {
let student = await this.studentRepo.findOne({ where: { phone } });
if (student) {
// 更新已有学生
const updates: Partial<Student> = {};
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<string, string> }> {
const headers = this.parseSheetHeaders(ws);
const rows: Array<{ rowNumber: number; data: Record<string, string> }> = [];
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<string, any> = { 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<string, any> = { 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<Student> {
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,
};
}
}