feat: 学生档案 Excel 批量导入
- 4-Sheet Excel 模板:学生基础+档案+录取 / 报读班型 / 考试成绩 / 课堂回访 - 手机号精确匹配已有学生,不存在则新建 - POST /archive/import 批量导入接口 - GET /archive/import/template 模板下载接口 - 前端:下载模板 + 上传导入 + 结果汇总弹窗
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
],
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user