forked from wangziqi/gongxue-base
feat: expand student archive import template
This commit is contained in:
@@ -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<AttendanceRecord>,
|
||||
@InjectRepository(ClassTeacher) private classTeacherRepo: Repository<ClassTeacher>,
|
||||
@InjectRepository(Organization) private organizationRepo: Repository<Organization>,
|
||||
@InjectRepository(StudentProfile) private profileRepo: Repository<StudentProfile>,
|
||||
@InjectRepository(StudentEnrollment) private enrollmentRepo: Repository<StudentEnrollment>,
|
||||
@InjectRepository(ExamScore) private examScoreRepo: Repository<ExamScore>,
|
||||
@InjectRepository(LearningRecord) private learningRecordRepo: Repository<LearningRecord>,
|
||||
@InjectRepository(ResultArchive) private resultRepo: Repository<ResultArchive>,
|
||||
) {}
|
||||
|
||||
async getAccessibleClassIds(userId: number, canManageAll = false): Promise<number[] | undefined> {
|
||||
@@ -35,6 +52,23 @@ export class StudentsService {
|
||||
});
|
||||
}
|
||||
|
||||
async getArchiveExportMaps(studentIds: number[]) {
|
||||
if (studentIds.length === 0) {
|
||||
return {
|
||||
profiles: new Map<number, StudentProfile>(),
|
||||
results: new Map<number, ResultArchive>(),
|
||||
};
|
||||
}
|
||||
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<string, StudentEnrollment>();
|
||||
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<string, StudentEnrollment>,
|
||||
) {
|
||||
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('所属机构不存在或已归档');
|
||||
|
||||
Reference in New Issue
Block a user