import { Injectable } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { EntityManager, In, Repository } from 'typeorm'; import { Student } from '../entities/student.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 { Organization } from '../entities/organization.entity'; import type { ExamScoreImportRow, LearningRecordImportRow, StudentEnrollmentImportRow, StudentImportRow, StudentWorkbookImport, } from './student-import'; import { getHostOrganizationId } from './students.organization'; import { addDaysToDateOnly } from '../common/china-time'; /** 学生查重/匹配的内存索引:phone 与 idNumber 各一张 Map(key 为去空白后的值)。 */ type StudentLookupIndex = { byPhone: Map; byIdNumber: Map; }; @Injectable() export class StudentsImportService { // 档案/报读/成绩等写入统一走 manager.getRepository(见下方 helpers), // 这里只注入 Student repo(查重、更新、开启事务)。 constructor( @InjectRepository(Student) private readonly repo: Repository, ) {} async batchImport(importData: StudentWorkbookImport | StudentImportRow[]) { const data = this.normalizeImportData(importData); // 整批导入放进同一事务:查重、建学生、写档案要么全部成功,要么全部回滚。 return this.repo.manager.transaction(async (manager) => { const studentRepo = manager.getRepository(Student); const organizationRepo = manager.getRepository(Organization); // 按手机号预建报读/成绩/回访行分组(手机号是导入行与学生档案之间的关联键, // 等价于按学生分组),避免逐学生循环内对 data.* 全量 filter。 const enrollmentsByPhone = this.groupByPhone(data.enrollments); const examScoresByPhone = this.groupByPhone(data.examScores); const learningRecordsByPhone = this.groupByPhone(data.learningRecords); // 循环外按本批所有 phone/idNumber 一次性批量预取已有学生(仅两条 IN 查询), // 内存建索引后逐行匹配,避免逐行 findOne 的 N+1。 const existingIndexes = await this.prefetchStudentIndexes(studentRepo, data.students); let imported = 0; let skipped = 0; let archiveImported = 0; let invalidDateSkipped = 0; // 本机构 id 循环外懒加载缓存一次,避免每个学生行都查询 let hostOrganizationId: number | undefined; for (const row of data.students) { if (!row.name || !row.name.trim()) { skipped++; continue; } // 按 phone/idNumber 查重:先 phone 后 idNumber,命中即视为已存在 if (this.matchStudentByIndexes(existingIndexes, row)) { skipped++; continue; } if (!row.organizationId && hostOrganizationId === undefined) { hostOrganizationId = await getHostOrganizationId(organizationRepo); } const student = await studentRepo.save( studentRepo.create({ name: row.name.trim(), studentNo: row.studentNo?.trim() || undefined, phone: row.phone?.trim() || undefined, idNumber: row.idNumber?.trim() || undefined, gender: row.gender || undefined, ethnicity: row.ethnicity || undefined, emergencyContact: row.emergencyContact || undefined, emergencyPhone: row.emergencyPhone || undefined, supervisor: row.supervisor || undefined, organizationId: row.organizationId || hostOrganizationId, }), ); // 新保存的学生同步进内存索引:本批后续相同 phone/idNumber 行视为重复跳过, // 保持与逐行 findOne 一致的 upsert/去重语义。 this.indexStudent(existingIndexes, student); const archive = await this.importArchiveData(manager, student.id, row, { enrollments: enrollmentsByPhone, examScores: examScoresByPhone, learningRecords: learningRecordsByPhone, }); archiveImported += archive.imported; invalidDateSkipped += archive.invalidDateSkipped; imported++; } return { message: `成功导入 ${imported} 名学生,导入档案相关记录 ${archiveImported} 条,跳过 ${skipped} 条(重复或空行)`, imported, archiveImported, skipped, invalidDateSkipped, }; }); } async matchImport(importData: StudentWorkbookImport | StudentImportRow[]) { const data = this.normalizeImportData(importData); // 整批匹配写入放进同一事务:更新学生资料与写档案要么全部成功,要么全部回滚。 return this.repo.manager.transaction(async (manager) => { const studentRepo = manager.getRepository(Student); // 与 batchImport 一致:按手机号预建分组,避免逐学生循环内全量 filter。 const enrollmentsByPhone = this.groupByPhone(data.enrollments); const examScoresByPhone = this.groupByPhone(data.examScores); const learningRecordsByPhone = this.groupByPhone(data.learningRecords); // 循环外按本批所有 phone/idNumber 一次性批量预取已有学生(仅两条 IN 查询), // 内存建索引后逐行匹配,避免逐行 findOne 的 N+1。 const existingIndexes = await this.prefetchStudentIndexes(studentRepo, data.students); let matched = 0; let skipped = 0; let skippedNoFields = 0; let skippedConflict = 0; let archiveImported = 0; let invalidDateSkipped = 0; for (const row of data.students) { // 双键命中不同学生:跳过该行,避免把 idNumber 写到错误的 student 上 if (this.hasIndexConflict(existingIndexes, row)) { skipped++; skippedConflict++; continue; } // Match by phone first, then idNumber const student = this.matchStudentByIndexes(existingIndexes, row); if (!student) { skipped++; continue; } const updates: Partial< Pick< Student, | 'name' | 'studentNo' | 'phone' | 'idNumber' | 'gender' | 'ethnicity' | 'emergencyContact' | 'emergencyPhone' | 'supervisor' | 'organizationId' > > = {}; if (row.name?.trim()) updates.name = row.name.trim(); if (row.studentNo?.trim()) updates.studentNo = row.studentNo.trim(); // phone/idNumber 是匹配键:与命中学生一致时视为无变更(避免空 set 更新), // 不一致时仍允许通过另一个键命中后更新。 if (row.phone?.trim() && row.phone.trim() !== student.phone) updates.phone = row.phone.trim(); if (row.idNumber?.trim() && row.idNumber.trim() !== student.idNumber) updates.idNumber = row.idNumber.trim(); if (row.gender) updates.gender = row.gender; if (row.ethnicity) updates.ethnicity = row.ethnicity; if (row.emergencyContact) updates.emergencyContact = row.emergencyContact; if (row.emergencyPhone) updates.emergencyPhone = row.emergencyPhone; if (row.supervisor) updates.supervisor = row.supervisor; if (row.organizationId) updates.organizationId = row.organizationId; // 按 phone/idNumber 命中已有学生但本行没有任何可写字段时, // 跳过该学生的更新(避免 TypeORM 对空 set 报错),保留匹配/去重语义。 const hasWritableFields = Object.keys(updates).length > 0; const archive = await this.importArchiveData(manager, student.id, row, { enrollments: enrollmentsByPhone, examScores: examScoresByPhone, learningRecords: learningRecordsByPhone, }); archiveImported += archive.imported; invalidDateSkipped += archive.invalidDateSkipped; if (!hasWritableFields && archive.imported === 0 && archive.invalidDateSkipped === 0) { // 整行无任何可写字段:计入 skipped(不抛错) skippedNoFields++; skipped++; continue; } if (hasWritableFields) { await studentRepo.update(student.id, updates); // 更新后的 phone/idNumber 同步进内存索引:后续行按新值仍可命中, // 与逐行 findOne 在同一事务内能看到本批已更新记录的语义一致。 this.reindexStudent(existingIndexes, student, updates); } matched++; } return { message: `更新已有学生资料 ${matched} 人,导入档案相关记录 ${archiveImported} 条,跳过 ${skipped} 条(无匹配/无更新字段/双键冲突)`, matched, archiveImported, skipped, skippedNoFields, skippedConflict, invalidDateSkipped, }; }); } /** * 批量预取本批导入行涉及的所有 phone / idNumber 对应的已有学生: * 只发两条 IN 查询(phone In(...)、idNumber In(...)),内存建索引供逐行匹配, * 替代逐行 findOne(N+1)。某类 key 为空时跳过对应查询。 */ private async prefetchStudentIndexes( studentRepo: Repository, rows: StudentImportRow[], ): Promise { const phones = [ ...new Set( rows .map((row) => row.phone?.trim()) .filter((phone): phone is string => Boolean(phone)), ), ]; const idNumbers = [ ...new Set( rows .map((row) => row.idNumber?.trim()) .filter((idNumber): idNumber is string => Boolean(idNumber)), ), ]; const [byPhoneList, byIdNumberList] = await Promise.all([ phones.length > 0 ? studentRepo.find({ where: { phone: In(phones) } }) : Promise.resolve([] as Student[]), idNumbers.length > 0 ? studentRepo.find({ where: { idNumber: In(idNumbers) } }) : Promise.resolve([] as Student[]), ]); return { byPhone: this.buildIndex(byPhoneList, (student) => student.phone), byIdNumber: this.buildIndex(byIdNumberList, (student) => student.idNumber), }; } /** 从查询结果构建 phone / idNumber → Student 的 Map(key 去空白,空值不建索引)。 */ private buildIndex( students: Student[], keyOf: (student: Student) => string | null | undefined, ): Map { const index = new Map(); for (const student of students) { const key = keyOf(student)?.trim(); if (key) index.set(key, student); } return index; } /** * 双键冲突检测:同一行 phone 与 idNumber 分别命中不同的学生时,不能更新任何一个, * 否则会把一个学生的 idNumber 写到另一个学生身上(数据错乱)。冲突行按 skipped 处理。 */ private hasIndexConflict(indexes: StudentLookupIndex, row: StudentImportRow): boolean { const phone = row.phone?.trim(); const idNumber = row.idNumber?.trim(); if (!phone || !idNumber) return false; const byPhone = indexes.byPhone.get(phone); const byIdNumber = indexes.byIdNumber.get(idNumber); return !!byPhone && !!byIdNumber && byPhone.id !== byIdNumber.id; } /** 逐行匹配已有学生:优先 phone,未命中再按 idNumber(与 matchImport 原有语义一致)。 */ private matchStudentByIndexes( indexes: StudentLookupIndex, row: StudentImportRow, ): Student | undefined { const phone = row.phone?.trim(); if (phone) { const byPhone = indexes.byPhone.get(phone); if (byPhone) return byPhone; } const idNumber = row.idNumber?.trim(); if (idNumber) { const byIdNumber = indexes.byIdNumber.get(idNumber); if (byIdNumber) return byIdNumber; } return undefined; } /** 新保存的学生同步进内存索引(batchImport 批次内去重)。 */ private indexStudent(indexes: StudentLookupIndex, student: Student): void { if (student.phone) indexes.byPhone.set(student.phone.trim(), student); if (student.idNumber) indexes.byIdNumber.set(student.idNumber.trim(), student); } /** * matchImport 更新学生资料后同步内存索引:phone/idNumber 被改写时删除旧 key、 * 注册新 key,并让内存实体与已更新值保持一致(后续行按新值仍可命中)。 */ private reindexStudent( indexes: StudentLookupIndex, student: Student, updates: Partial>, ): void { const oldPhone = student.phone?.trim(); const newPhone = updates.phone?.trim(); const oldIdNumber = student.idNumber?.trim(); const newIdNumber = updates.idNumber?.trim(); Object.assign(student, updates); if (newPhone && newPhone !== oldPhone) { if (oldPhone) indexes.byPhone.delete(oldPhone); indexes.byPhone.set(newPhone, student); } if (newIdNumber && newIdNumber !== oldIdNumber) { if (oldIdNumber) indexes.byIdNumber.delete(oldIdNumber); indexes.byIdNumber.set(newIdNumber, student); } } 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(); } /** 按手机号预建行分组(手机号去空白后作为 key,空手机号的行不参与匹配)。 */ private groupByPhone(rows: T[]): Map { const byPhone = new Map(); for (const row of rows) { const phone = this.normalizePhone(row.phone); if (!phone) continue; const list = byPhone.get(phone); if (list) list.push(row); else byPhone.set(phone, [row]); } return byPhone; } /** * 日期字段写入前的真实日历校验:先检查 YYYY-MM-DD 形状, * 再用 common/china-time 的 addDaysToDateOnly 往返判断月/日是否真实存在 * (Date.UTC 会把溢出日期归一化,如 2024-02-31 → 2024-03-02,往返不一致即非法)。 */ private isValidDateOnly(value: string): boolean { const dateOnly = value.trim(); if (!/^\d{4}-\d{2}-\d{2}$/.test(dateOnly)) return false; return addDaysToDateOnly(dateOnly, 0) === dateOnly; } private hasProfileData(row: StudentImportRow) { return [ row.targetCollege, row.targetMajor, row.collegeSchool, row.collegeMajor, 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( manager: EntityManager, studentId: number, row: StudentImportRow, data: { enrollments: Map; examScores: Map; learningRecords: Map; }, ): Promise<{ imported: number; invalidDateSkipped: number }> { const phone = this.normalizePhone(row.phone); let imported = 0; let invalidDateSkipped = 0; if (this.hasProfileData(row)) { // 非法日期不写入,跳过该字段并计数 if (row.profileDate?.trim() && !this.isValidDateOnly(row.profileDate)) { invalidDateSkipped++; } await this.upsertProfileFromImport(manager, studentId, row); imported++; } if (this.hasResultData(row)) { await this.upsertResultFromImport(manager, studentId, row); imported++; } if (!phone) return { imported, invalidDateSkipped }; const enrollmentRows = data.enrollments.get(phone) ?? []; const examRows = data.examScores.get(phone) ?? []; const learningRows = data.learningRecords.get(phone) ?? []; // 按 studentId 批量预取既有档案行,内存匹配(保持 upsert 语义), // 避免每个档案行单独 find 查询;新建保存后同步加入内存列表供后续行匹配。 let existingEnrollments: StudentEnrollment[] = []; let existingExamScores: ExamScore[] = []; let existingLearningRecords: LearningRecord[] = []; if (enrollmentRows.length > 0) { existingEnrollments = await manager.getRepository(StudentEnrollment).find({ where: { studentId }, }); } if (examRows.length > 0) { existingExamScores = await manager.getRepository(ExamScore).find({ where: { studentId }, }); } if (learningRows.length > 0) { existingLearningRecords = await manager.getRepository(LearningRecord).find({ where: { studentId }, }); } const enrollmentByClassName = new Map(); for (const enrollmentRow of enrollmentRows) { const enrollment = await this.upsertEnrollmentFromImport( manager, studentId, enrollmentRow, existingEnrollments, ); if (!enrollment) continue; if (enrollment.className) enrollmentByClassName.set(enrollment.className, enrollment); imported++; } for (const examRow of examRows) { // 非法日期不写入,跳过该字段并计数 if (examRow.examDate?.trim() && !this.isValidDateOnly(examRow.examDate)) { invalidDateSkipped++; } if ( await this.upsertExamScoreFromImport( manager, studentId, examRow, enrollmentByClassName, existingExamScores, ) ) { imported++; } } for (const learningRow of learningRows) { // recordDate 必填:非法日期视为缺省,跳过整条并计数 if (learningRow.recordDate?.trim() && !this.isValidDateOnly(learningRow.recordDate)) { invalidDateSkipped++; continue; } if (await this.upsertLearningRecordFromImport(manager, studentId, learningRow, existingLearningRecords)) { imported++; } } return { imported, invalidDateSkipped }; } private async upsertProfileFromImport( manager: EntityManager, studentId: number, row: StudentImportRow, ) { const profileRepo = manager.getRepository(StudentProfile); const entity = (await profileRepo.findOne({ where: { studentId } })) || profileRepo.create({ studentId }); if (row.targetCollege?.trim()) entity.targetCollege = row.targetCollege.trim(); if (row.targetMajor?.trim()) entity.targetMajor = row.targetMajor.trim(); if (row.collegeSchool?.trim()) entity.collegeSchool = row.collegeSchool.trim(); if (row.collegeMajor?.trim()) entity.collegeMajor = row.collegeMajor.trim(); if (row.subjectDirection?.trim()) entity.subjectDirection = row.subjectDirection.trim(); if (row.grade?.trim()) entity.grade = row.grade.trim(); if (row.profileDate?.trim() && this.isValidDateOnly(row.profileDate)) { entity.profileDate = row.profileDate.trim(); } if (row.notes?.trim()) entity.notes = row.notes.trim(); await profileRepo.save(entity); } private async upsertResultFromImport( manager: EntityManager, studentId: number, row: StudentImportRow, ) { const resultRepo = manager.getRepository(ResultArchive); const entity = (await resultRepo.findOne({ where: { studentId } })) || 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 resultRepo.save(entity); } private async upsertEnrollmentFromImport( manager: EntityManager, studentId: number, row: StudentEnrollmentImportRow, existing: StudentEnrollment[], ) { if (!row.courseCategory?.trim() || !row.classType?.trim()) { return null; } const enrollmentRepo = manager.getRepository(StudentEnrollment); let 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), ); if (!entity) { entity = enrollmentRepo.create({ studentId }); // 保持 upsert 语义:后续相同行可在内存列表中命中该新建实体 existing.push(entity); } 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 enrollmentRepo.save(entity); } private async upsertExamScoreFromImport( manager: EntityManager, studentId: number, row: ExamScoreImportRow, enrollmentByClassName: Map, existing: ExamScore[], ) { if (!row.examType?.trim() || !row.subject?.trim() || row.score === undefined) return false; const examScoreRepo = manager.getRepository(ExamScore); let 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), ); if (!entity) { entity = examScoreRepo.create({ studentId }); // 保持 upsert 语义:后续相同行可在内存列表中命中该新建实体 existing.push(entity); } 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() && this.isValidDateOnly(row.examDate)) { 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 examScoreRepo.save(entity); return true; } private async upsertLearningRecordFromImport( manager: EntityManager, studentId: number, row: LearningRecordImportRow, existing: LearningRecord[], ) { if (!row.recordDate?.trim() || !row.recordType?.trim() || !row.content?.trim()) return false; const learningRecordRepo = manager.getRepository(LearningRecord); let entity = existing.find( (item) => this.sameValue(item.recordDate, row.recordDate) && this.sameValue(item.recordType, row.recordType) && this.sameValue(item.content, row.content), ); if (!entity) { entity = learningRecordRepo.create({ studentId }); // 保持 upsert 语义:后续相同行可在内存列表中命中该新建实体 existing.push(entity); } 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 learningRecordRepo.save(entity); return true; } }