fix(server): 花名册预览与提交匹配归一化一致,并发导入计数修正
- 预览二次校验改 LOWER(TRIM) 归一化,与主查询/commit 路径一致,避免预览判可新建而提交判冲突 - 并发创建同标识学生重查改用锁定读刷新可见性(REPEATABLE READ 旧快照问题) - 批量成员保存撞唯一索引后的恢复:复活行不计入并发跳过,仅新增行且已 active 才算已在本班 - 重试保存逐行执行,避免多行 INSERT 原子失败静默丢行
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Injectable, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { DataSource, Repository, In } from 'typeorm';
|
||||
import { Class, ClassStudent, ClassSchedule, AttendanceRecord } from '../entities';
|
||||
import { DataSource, In, Not, Repository } from 'typeorm';
|
||||
import { Class, ClassStudent, ClassSchedule, AttendanceRecord, Organization, Student } from '../entities';
|
||||
import { Classroom } from '../entities/classroom.entity';
|
||||
import { syncDingTalkStudents } from '../integration/dingtalk-student-sync';
|
||||
import type { QueryClassScheduleDto, QueryClassAttendanceSummaryDto } from './dto/class.dto';
|
||||
import { sanitizeCellText } from './class-roster-import';
|
||||
import type { ClassRosterCreateRow, ClassRosterImportRow } from './class-roster-import';
|
||||
import { escapeLike } from '../common/like-escape';
|
||||
import dayjs from '../common/dayjs';
|
||||
|
||||
@@ -15,6 +17,129 @@ interface AgentClassRow {
|
||||
studentCount: string | number;
|
||||
}
|
||||
|
||||
/** 判断是否为 MySQL 唯一键冲突(1062 / ER_DUP_ENTRY),兼容 TypeORM QueryFailedError 包装。 */
|
||||
export function isDuplicateEntryError(error: unknown): boolean {
|
||||
const driver = (error as { driverError?: { code?: string; errno?: number } })?.driverError ?? error;
|
||||
return (
|
||||
(driver as { code?: string })?.code === 'ER_DUP_ENTRY' ||
|
||||
(driver as { errno?: number })?.errno === 1062
|
||||
);
|
||||
}
|
||||
|
||||
/** 标识归一化:去空白 + 小写,与 SQL 侧 LOWER(TRIM(col)) 保持一致。 */
|
||||
function normalizeIdentifier(value: string | null | undefined): string {
|
||||
return (value ?? '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
export type RosterPreviewRowStatus = 'matched' | 'unmatched' | 'in-class' | 'conflict';
|
||||
|
||||
export interface RosterPreviewRow {
|
||||
rowNumber: number;
|
||||
name: string;
|
||||
phone?: string;
|
||||
idNumber?: string;
|
||||
studentNo?: string;
|
||||
status: RosterPreviewRowStatus;
|
||||
student?: { id: number; name: string; studentNo: string | null };
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export interface RosterPreviewResult {
|
||||
rows: RosterPreviewRow[];
|
||||
summary: {
|
||||
total: number;
|
||||
matched: number;
|
||||
unmatched: number;
|
||||
inClass: number;
|
||||
conflict: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface RosterCommitResult {
|
||||
added: number;
|
||||
created: number;
|
||||
skipped: number;
|
||||
conflicts: number;
|
||||
message: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验导入行与已匹配学生是否自洽:仅当学生已存储对应字段且与行内值不一致时返回原因。
|
||||
* 用于避免「手机号是 A、姓名/身份证/学号却是 B」的错位数据被静默关联。
|
||||
*/
|
||||
function inconsistentFieldReason(
|
||||
row: { name?: string; phone?: string; idNumber?: string; studentNo?: string },
|
||||
student: Student,
|
||||
): string | null {
|
||||
// 与 buildStudentIndexes / SQL LOWER(TRIM()) 的归一化保持一致
|
||||
const storedName = normalizeIdentifier(student.name);
|
||||
const storedPhone = normalizeIdentifier(student.phone);
|
||||
const storedIdNumber = normalizeIdentifier(student.idNumber);
|
||||
const storedStudentNo = normalizeIdentifier(student.studentNo);
|
||||
if (row.name && storedName && normalizeIdentifier(row.name) !== storedName) {
|
||||
return `姓名与匹配学生不一致(${student.name?.trim()})`;
|
||||
}
|
||||
if (row.phone && storedPhone && normalizeIdentifier(row.phone) !== storedPhone) {
|
||||
return '手机号与匹配学生不一致';
|
||||
}
|
||||
if (row.idNumber && storedIdNumber && normalizeIdentifier(row.idNumber) !== storedIdNumber) {
|
||||
return '身份证号与匹配学生不一致';
|
||||
}
|
||||
if (row.studentNo && storedStudentNo && normalizeIdentifier(row.studentNo) !== storedStudentNo) {
|
||||
return '学号与匹配学生不一致';
|
||||
}
|
||||
// 行内带标识但匹配学生未登记该字段:无法确认一致性,按冲突提示,避免静默丢弃字段
|
||||
if (row.phone && !storedPhone) return '手机号与匹配学生不一致(学生未登记手机号)';
|
||||
if (row.idNumber && !storedIdNumber) return '身份证号与匹配学生不一致(学生未登记身份证号)';
|
||||
if (row.studentNo && !storedStudentNo) return '学号与匹配学生不一致(学生未登记学号)';
|
||||
return null;
|
||||
}
|
||||
|
||||
/** 判断标识键下是否存在已归档或员工账号学生。 */
|
||||
function identifierHasArchivedOrStaff(
|
||||
indexes: StudentIndexes,
|
||||
key: string,
|
||||
which: 'byPhone' | 'byIdNumber' | 'byStudentNo',
|
||||
): boolean {
|
||||
return (indexes[which].get(normalizeIdentifier(key)) ?? []).some(
|
||||
(student) => student.status === 'archived' || student.status === 'staff',
|
||||
);
|
||||
}
|
||||
|
||||
interface StudentIndexes {
|
||||
byPhone: Map<string, Student[]>;
|
||||
byIdNumber: Map<string, Student[]>;
|
||||
byStudentNo: Map<string, Student[]>;
|
||||
byName: Map<string, Student[]>;
|
||||
}
|
||||
|
||||
function pushIndex(map: Map<string, Student[]>, key: string, student: Student) {
|
||||
const list = map.get(key) ?? [];
|
||||
list.push(student);
|
||||
map.set(key, list);
|
||||
}
|
||||
|
||||
/** 构建按 手机号/身份证号/学号/姓名 归一的查询索引;键值两侧均去空白,避免存储值带空格导致漏匹配。 */
|
||||
function buildStudentIndexes(students: Student[]): StudentIndexes {
|
||||
const indexes: StudentIndexes = {
|
||||
byPhone: new Map(),
|
||||
byIdNumber: new Map(),
|
||||
byStudentNo: new Map(),
|
||||
byName: new Map(),
|
||||
};
|
||||
for (const student of students) {
|
||||
const phone = normalizeIdentifier(student.phone);
|
||||
const idNumber = normalizeIdentifier(student.idNumber);
|
||||
const studentNo = normalizeIdentifier(student.studentNo);
|
||||
const name = normalizeIdentifier(student.name);
|
||||
if (phone) pushIndex(indexes.byPhone, phone, student);
|
||||
if (idNumber) pushIndex(indexes.byIdNumber, idNumber, student);
|
||||
if (studentNo) pushIndex(indexes.byStudentNo, studentNo, student);
|
||||
if (name) pushIndex(indexes.byName, name, student);
|
||||
}
|
||||
return indexes;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class ClassesQueriesService {
|
||||
constructor(
|
||||
@@ -113,7 +238,544 @@ export class ClassesQueriesService {
|
||||
conflicts: synced.conflicts.length,
|
||||
};
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/** 班级花名册导入预览:解析行后按 手机号→身份证号→学号→唯一姓名 匹配已有学生,不写库。 */
|
||||
async previewRosterImport(
|
||||
classId: number,
|
||||
rows: ClassRosterImportRow[],
|
||||
): Promise<RosterPreviewResult> {
|
||||
const classEntity = await this.classRepo.findOne({ where: { id: classId } });
|
||||
if (!classEntity) throw new NotFoundException('班级不存在');
|
||||
|
||||
const memberships = await this.classStudentRepo.find({ where: { classId } });
|
||||
const activeStudentIds = new Set(
|
||||
memberships.filter((m) => m.status === 'active').map((m) => m.studentId),
|
||||
);
|
||||
|
||||
const phones = [
|
||||
...new Set(rows.map((r) => normalizeIdentifier(r.phone)).filter((v) => !!v)),
|
||||
];
|
||||
const idNumbers = [
|
||||
...new Set(rows.map((r) => normalizeIdentifier(r.idNumber)).filter((v) => !!v)),
|
||||
];
|
||||
const studentNos = [
|
||||
...new Set(rows.map((r) => normalizeIdentifier(r.studentNo)).filter((v) => !!v)),
|
||||
];
|
||||
const names = [
|
||||
...new Set(rows.map((r) => normalizeIdentifier(r.name)).filter((v) => !!v)),
|
||||
];
|
||||
|
||||
// 只匹配未归档且非员工账号的学生,避免已归档学生被重新拉回在读班级;
|
||||
// 用 LOWER(TRIM(col)) 与 Map 索引的归一化保持一致,避免存储值带空格/大小写差异导致漏匹配
|
||||
let students: Student[] = [];
|
||||
{
|
||||
const qb = this.dataSource.getRepository(Student).createQueryBuilder('s');
|
||||
const ors: string[] = [];
|
||||
if (phones.length) {
|
||||
ors.push('LOWER(TRIM(s.phone)) IN (:...phones)');
|
||||
qb.setParameter('phones', phones);
|
||||
}
|
||||
if (idNumbers.length) {
|
||||
ors.push('LOWER(TRIM(s.idNumber)) IN (:...idNumbers)');
|
||||
qb.setParameter('idNumbers', idNumbers);
|
||||
}
|
||||
if (studentNos.length) {
|
||||
ors.push('LOWER(TRIM(s.studentNo)) IN (:...studentNos)');
|
||||
qb.setParameter('studentNos', studentNos);
|
||||
}
|
||||
if (names.length) {
|
||||
ors.push('LOWER(TRIM(s.name)) IN (:...names)');
|
||||
qb.setParameter('names', names);
|
||||
}
|
||||
if (ors.length) {
|
||||
qb.where('s.status NOT IN (:...blockedStatuses)', {
|
||||
blockedStatuses: ['archived', 'staff'],
|
||||
}).andWhere(`(${ors.join(' OR ')})`);
|
||||
students = await qb.getMany();
|
||||
}
|
||||
}
|
||||
|
||||
const { byPhone, byIdNumber, byStudentNo, byName } = buildStudentIndexes(students);
|
||||
|
||||
const previewRows: RosterPreviewRow[] = [];
|
||||
for (const row of rows) {
|
||||
const name = row.name?.trim() ?? '';
|
||||
const phone = row.phone?.trim();
|
||||
const idNumber = row.idNumber?.trim();
|
||||
const studentNo = row.studentNo?.trim();
|
||||
|
||||
if (!name && !phone && !idNumber && !studentNo) {
|
||||
previewRows.push({ ...row, name, status: 'conflict', reason: '缺少姓名和匹配信息' });
|
||||
continue;
|
||||
}
|
||||
|
||||
const identifierMatches = new Map<number, Student>();
|
||||
for (const match of [
|
||||
...(phone ? byPhone.get(normalizeIdentifier(phone)) ?? [] : []),
|
||||
...(idNumber ? byIdNumber.get(normalizeIdentifier(idNumber)) ?? [] : []),
|
||||
...(studentNo ? byStudentNo.get(normalizeIdentifier(studentNo)) ?? [] : []),
|
||||
]) {
|
||||
identifierMatches.set(match.id, match);
|
||||
}
|
||||
|
||||
if (identifierMatches.size > 1) {
|
||||
const sameIdentifierDuplicate =
|
||||
(phone && (byPhone.get(normalizeIdentifier(phone))?.length ?? 0) > 1) ||
|
||||
(idNumber && (byIdNumber.get(normalizeIdentifier(idNumber))?.length ?? 0) > 1) ||
|
||||
(studentNo && (byStudentNo.get(normalizeIdentifier(studentNo))?.length ?? 0) > 1);
|
||||
previewRows.push({
|
||||
...row,
|
||||
name,
|
||||
status: 'conflict',
|
||||
reason: sameIdentifierDuplicate
|
||||
? '手机号/学号/身份证在系统中匹配到多条学生记录,请先整理学生目录'
|
||||
: '手机号/学号/身份证匹配到不同学生',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (identifierMatches.size === 1) {
|
||||
const student = [...identifierMatches.values()][0];
|
||||
const reason = inconsistentFieldReason({ name, phone, idNumber, studentNo }, student);
|
||||
if (reason) {
|
||||
previewRows.push({ ...row, name, status: 'conflict', reason });
|
||||
continue;
|
||||
}
|
||||
previewRows.push({
|
||||
...row,
|
||||
name,
|
||||
status: activeStudentIds.has(student.id) ? 'in-class' : 'matched',
|
||||
student: { id: student.id, name: student.name, studentNo: student.studentNo },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const nameMatches = name ? byName.get(normalizeIdentifier(name)) ?? [] : [];
|
||||
if (nameMatches.length > 1) {
|
||||
previewRows.push({
|
||||
...row,
|
||||
name,
|
||||
status: 'conflict',
|
||||
reason: '姓名匹配到多名学生,请补充手机号/学号/身份证号',
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (nameMatches.length === 1) {
|
||||
const student = nameMatches[0];
|
||||
const reason = inconsistentFieldReason({ name, phone, idNumber, studentNo }, student);
|
||||
if (reason) {
|
||||
previewRows.push({ ...row, name, status: 'conflict', reason });
|
||||
continue;
|
||||
}
|
||||
previewRows.push({
|
||||
...row,
|
||||
name,
|
||||
status: activeStudentIds.has(student.id) ? 'in-class' : 'matched',
|
||||
student: { id: student.id, name: student.name, studentNo: student.studentNo },
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
previewRows.push({ ...row, name, status: 'unmatched' });
|
||||
}
|
||||
|
||||
// 二次校验:未匹配行的标识若命中已归档/员工账号,标记为冲突而非可新建,
|
||||
// 避免在创建时静默产生与归档学生同手机号/身份证/学号的重复学生。
|
||||
// 与主查询/commit 路径统一走 LOWER(TRIM()) 归一化,避免存储值带空格/大小写差异时
|
||||
// 预览判为可新建而 commit 判为冲突,导致两个入口结果不一致。
|
||||
const unmatchedRows = previewRows.filter((row) => row.status === 'unmatched');
|
||||
const unmatchedPhones = [
|
||||
...new Set(unmatchedRows.map((row) => normalizeIdentifier(row.phone)).filter((v) => !!v)),
|
||||
];
|
||||
const unmatchedIdNumbers = [
|
||||
...new Set(unmatchedRows.map((row) => normalizeIdentifier(row.idNumber)).filter((v) => !!v)),
|
||||
];
|
||||
const unmatchedStudentNos = [
|
||||
...new Set(unmatchedRows.map((row) => normalizeIdentifier(row.studentNo)).filter((v) => !!v)),
|
||||
];
|
||||
const blockedQb = this.dataSource.getRepository(Student).createQueryBuilder('s');
|
||||
const blockedOrs: string[] = [];
|
||||
if (unmatchedPhones.length) {
|
||||
blockedOrs.push('LOWER(TRIM(s.phone)) IN (:...unmatchedPhones)');
|
||||
blockedQb.setParameter('unmatchedPhones', unmatchedPhones);
|
||||
}
|
||||
if (unmatchedIdNumbers.length) {
|
||||
blockedOrs.push('LOWER(TRIM(s.idNumber)) IN (:...unmatchedIdNumbers)');
|
||||
blockedQb.setParameter('unmatchedIdNumbers', unmatchedIdNumbers);
|
||||
}
|
||||
if (unmatchedStudentNos.length) {
|
||||
blockedOrs.push('LOWER(TRIM(s.studentNo)) IN (:...unmatchedStudentNos)');
|
||||
blockedQb.setParameter('unmatchedStudentNos', unmatchedStudentNos);
|
||||
}
|
||||
const blockedStudents = blockedOrs.length
|
||||
? await blockedQb.where(`(${blockedOrs.join(' OR ')})`).getMany()
|
||||
: [];
|
||||
if (blockedStudents.length) {
|
||||
const blockedIndexes = buildStudentIndexes(blockedStudents);
|
||||
for (const row of previewRows) {
|
||||
if (row.status !== 'unmatched') continue;
|
||||
const blocked =
|
||||
(row.phone && identifierHasArchivedOrStaff(blockedIndexes, row.phone, 'byPhone')) ||
|
||||
(row.idNumber && identifierHasArchivedOrStaff(blockedIndexes, row.idNumber, 'byIdNumber')) ||
|
||||
(row.studentNo && identifierHasArchivedOrStaff(blockedIndexes, row.studentNo, 'byStudentNo'));
|
||||
if (blocked) {
|
||||
row.status = 'conflict';
|
||||
row.reason = '手机号/学号/身份证对应已归档或员工账号,无法导入';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const summary: RosterPreviewResult['summary'] = {
|
||||
total: 0,
|
||||
matched: 0,
|
||||
unmatched: 0,
|
||||
inClass: 0,
|
||||
conflict: 0,
|
||||
};
|
||||
for (const row of previewRows) {
|
||||
summary.total++;
|
||||
if (row.status === 'matched') summary.matched++;
|
||||
else if (row.status === 'unmatched') summary.unmatched++;
|
||||
else if (row.status === 'in-class') summary.inClass++;
|
||||
else summary.conflict++;
|
||||
}
|
||||
return { rows: previewRows, summary };
|
||||
}
|
||||
|
||||
/** 班级花名册导入提交:加入选中学生 + 按勾选创建未匹配学生,事务内完成。 */
|
||||
async commitRosterImport(
|
||||
classId: number,
|
||||
input: { addStudentIds?: number[]; createRows?: ClassRosterCreateRow[] },
|
||||
): Promise<RosterCommitResult> {
|
||||
if (!input.addStudentIds?.length && !input.createRows?.length) {
|
||||
throw new BadRequestException('提交内容不能为空');
|
||||
}
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const classEntity = await manager.findOne(Class, { where: { id: classId } });
|
||||
if (!classEntity) throw new NotFoundException('班级不存在');
|
||||
|
||||
const today = dayjs().utcOffset(8).format('YYYY-MM-DD');
|
||||
const studentIdsToAdd = new Set<number>((input.addStudentIds || []).map(Number));
|
||||
let createdCount = 0;
|
||||
let conflicts = 0;
|
||||
const createdStudents = new Map<number, Student>();
|
||||
const createdByPhone = new Map<string, number>();
|
||||
const createdByIdNumber = new Map<string, number>();
|
||||
const createdByStudentNo = new Map<string, number>();
|
||||
const createdByName = new Map<string, number>();
|
||||
let hostOrganizationId: number | undefined;
|
||||
let hostOrganizationResolved = false;
|
||||
|
||||
const createRows = input.createRows || [];
|
||||
if (createRows.length) {
|
||||
// 直接提交的 createRows 绕过上传解析,需同样剥离公式注入前缀后再匹配/落库
|
||||
const sanitizedRows = createRows.map((row) => ({
|
||||
name: sanitizeCellText(row.name?.trim() ?? ''),
|
||||
phone: row.phone?.trim() ? sanitizeCellText(row.phone.trim(), true) : undefined,
|
||||
idNumber: row.idNumber?.trim() ? sanitizeCellText(row.idNumber.trim()) : undefined,
|
||||
studentNo: row.studentNo?.trim() ? sanitizeCellText(row.studentNo.trim()) : undefined,
|
||||
}));
|
||||
const phones = [
|
||||
...new Set(sanitizedRows.map((r) => normalizeIdentifier(r.phone)).filter((v) => !!v)),
|
||||
];
|
||||
const idNumbers = [
|
||||
...new Set(sanitizedRows.map((r) => normalizeIdentifier(r.idNumber)).filter((v) => !!v)),
|
||||
];
|
||||
const studentNos = [
|
||||
...new Set(sanitizedRows.map((r) => normalizeIdentifier(r.studentNo)).filter((v) => !!v)),
|
||||
];
|
||||
const names = [
|
||||
...new Set(sanitizedRows.map((r) => normalizeIdentifier(r.name)).filter((v) => !!v)),
|
||||
];
|
||||
// 在读/正常账号:作为可匹配候选;查询与 Map 索引统一走 LOWER(TRIM()) 归一化
|
||||
const existingQb = manager.createQueryBuilder(Student, 's');
|
||||
const existingOrs: string[] = [];
|
||||
if (phones.length) {
|
||||
existingOrs.push('LOWER(TRIM(s.phone)) IN (:...phones)');
|
||||
existingQb.setParameter('phones', phones);
|
||||
}
|
||||
if (idNumbers.length) {
|
||||
existingOrs.push('LOWER(TRIM(s.idNumber)) IN (:...idNumbers)');
|
||||
existingQb.setParameter('idNumbers', idNumbers);
|
||||
}
|
||||
if (studentNos.length) {
|
||||
existingOrs.push('LOWER(TRIM(s.studentNo)) IN (:...studentNos)');
|
||||
existingQb.setParameter('studentNos', studentNos);
|
||||
}
|
||||
if (names.length) {
|
||||
existingOrs.push('LOWER(TRIM(s.name)) IN (:...names)');
|
||||
existingQb.setParameter('names', names);
|
||||
}
|
||||
const existingStudents = existingOrs.length
|
||||
? await existingQb
|
||||
.where(`(${existingOrs.join(' OR ')})`)
|
||||
.andWhere('s.status NOT IN (:...blockedStatuses)', {
|
||||
blockedStatuses: ['archived', 'staff'],
|
||||
})
|
||||
.getMany()
|
||||
: [];
|
||||
const existingIndexes = buildStudentIndexes(existingStudents);
|
||||
|
||||
// 全量状态查询(含已归档/员工):创建前检测标识是否已被非在读账号占用
|
||||
const allStatusQb = manager.getRepository(Student).createQueryBuilder('s');
|
||||
const allStatusOrs: string[] = [];
|
||||
if (phones.length) {
|
||||
allStatusOrs.push('LOWER(TRIM(s.phone)) IN (:...phones)');
|
||||
allStatusQb.setParameter('phones', phones);
|
||||
}
|
||||
if (idNumbers.length) {
|
||||
allStatusOrs.push('LOWER(TRIM(s.idNumber)) IN (:...idNumbers)');
|
||||
allStatusQb.setParameter('idNumbers', idNumbers);
|
||||
}
|
||||
if (studentNos.length) {
|
||||
allStatusOrs.push('LOWER(TRIM(s.studentNo)) IN (:...studentNos)');
|
||||
allStatusQb.setParameter('studentNos', studentNos);
|
||||
}
|
||||
const allStatusStudents = allStatusOrs.length
|
||||
? await allStatusQb.where(`(${allStatusOrs.join(' OR ')})`).getMany()
|
||||
: [];
|
||||
const allStatusIndexes = buildStudentIndexes(allStatusStudents);
|
||||
|
||||
for (const row of sanitizedRows) {
|
||||
const name = row.name;
|
||||
const phone = row.phone;
|
||||
const idNumber = row.idNumber;
|
||||
const studentNo = row.studentNo;
|
||||
|
||||
if (!name) {
|
||||
conflicts++;
|
||||
continue;
|
||||
}
|
||||
|
||||
const candidates = new Map<number, Student>();
|
||||
if (phone) {
|
||||
const byBatch = createdByPhone.get(normalizeIdentifier(phone));
|
||||
const batchStudent = byBatch ? createdStudents.get(byBatch) : undefined;
|
||||
if (batchStudent) {
|
||||
candidates.set(batchStudent.id, batchStudent);
|
||||
} else {
|
||||
const matches = existingIndexes.byPhone.get(normalizeIdentifier(phone)) ?? [];
|
||||
if (matches.length === 1) candidates.set(matches[0].id, matches[0]);
|
||||
else if (matches.length > 1) {
|
||||
conflicts++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (idNumber) {
|
||||
const byBatch = createdByIdNumber.get(normalizeIdentifier(idNumber));
|
||||
const batchStudent = byBatch ? createdStudents.get(byBatch) : undefined;
|
||||
if (batchStudent) {
|
||||
candidates.set(batchStudent.id, batchStudent);
|
||||
} else {
|
||||
const matches = existingIndexes.byIdNumber.get(normalizeIdentifier(idNumber)) ?? [];
|
||||
if (matches.length === 1) candidates.set(matches[0].id, matches[0]);
|
||||
else if (matches.length > 1) {
|
||||
conflicts++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (studentNo) {
|
||||
const byBatch = createdByStudentNo.get(normalizeIdentifier(studentNo));
|
||||
const batchStudent = byBatch ? createdStudents.get(byBatch) : undefined;
|
||||
if (batchStudent) {
|
||||
candidates.set(batchStudent.id, batchStudent);
|
||||
} else {
|
||||
const matches = existingIndexes.byStudentNo.get(normalizeIdentifier(studentNo)) ?? [];
|
||||
if (matches.length === 1) candidates.set(matches[0].id, matches[0]);
|
||||
else if (matches.length > 1) {
|
||||
conflicts++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
// 无标识命中时按唯一姓名兜底,与预览逻辑保持一致,避免绕过预览产生重复学生
|
||||
if (candidates.size === 0 && name) {
|
||||
const byBatch = createdByName.get(normalizeIdentifier(name));
|
||||
const batchStudent = byBatch ? createdStudents.get(byBatch) : undefined;
|
||||
if (batchStudent) {
|
||||
candidates.set(batchStudent.id, batchStudent);
|
||||
} else {
|
||||
const nameMatches = existingIndexes.byName.get(normalizeIdentifier(name)) ?? [];
|
||||
if (nameMatches.length === 1) candidates.set(nameMatches[0].id, nameMatches[0]);
|
||||
else if (nameMatches.length > 1) {
|
||||
conflicts++;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.size > 1) {
|
||||
conflicts++;
|
||||
continue;
|
||||
}
|
||||
|
||||
let studentId: number;
|
||||
if (candidates.size === 1) {
|
||||
const student = [...candidates.values()][0];
|
||||
const reason = inconsistentFieldReason({ name, phone, idNumber, studentNo }, student);
|
||||
if (reason) {
|
||||
conflicts++;
|
||||
continue;
|
||||
}
|
||||
studentId = student.id;
|
||||
} else {
|
||||
const blocked =
|
||||
(phone && identifierHasArchivedOrStaff(allStatusIndexes, phone, 'byPhone')) ||
|
||||
(idNumber && identifierHasArchivedOrStaff(allStatusIndexes, idNumber, 'byIdNumber')) ||
|
||||
(studentNo && identifierHasArchivedOrStaff(allStatusIndexes, studentNo, 'byStudentNo'));
|
||||
if (blocked) {
|
||||
conflicts++;
|
||||
continue;
|
||||
}
|
||||
if (!hostOrganizationResolved) {
|
||||
const host = await manager.findOne(Organization, {
|
||||
where: { isHost: true, status: 'active' },
|
||||
});
|
||||
hostOrganizationId = host?.id;
|
||||
hostOrganizationResolved = true;
|
||||
}
|
||||
if (!hostOrganizationId) {
|
||||
conflicts++;
|
||||
continue;
|
||||
}
|
||||
let created: Student;
|
||||
try {
|
||||
created = await manager.save(
|
||||
manager.create(Student, {
|
||||
name,
|
||||
phone: phone || undefined,
|
||||
idNumber: idNumber || undefined,
|
||||
studentNo: studentNo || undefined,
|
||||
status: 'active',
|
||||
organizationId: hostOrganizationId,
|
||||
}),
|
||||
);
|
||||
createdCount++;
|
||||
} catch (error) {
|
||||
// 并发创建同标识学生:按标识重查并复用已有档案,避免整批回滚。
|
||||
// 仅复用未归档/非员工账号且与行内信息自洽的学生,否则按冲突处理。
|
||||
// REPEATABLE READ 下普通一致读基于旧快照看不到并发提交的行,需用锁定读刷新可见性
|
||||
if (!isDuplicateEntryError(error)) throw error;
|
||||
const dup = await manager.findOne(Student, {
|
||||
where: [
|
||||
...(phone ? [{ phone, status: Not(In(['archived', 'staff'])) }] : []),
|
||||
...(idNumber ? [{ idNumber, status: Not(In(['archived', 'staff'])) }] : []),
|
||||
...(studentNo ? [{ studentNo, status: Not(In(['archived', 'staff'])) }] : []),
|
||||
],
|
||||
lock: { mode: 'pessimistic_read' },
|
||||
order: { id: 'ASC' },
|
||||
});
|
||||
if (!dup || inconsistentFieldReason({ name, phone, idNumber, studentNo }, dup)) {
|
||||
conflicts++;
|
||||
continue;
|
||||
}
|
||||
created = dup;
|
||||
}
|
||||
studentId = created.id;
|
||||
createdStudents.set(studentId, created);
|
||||
if (phone) createdByPhone.set(normalizeIdentifier(phone), studentId);
|
||||
if (idNumber) createdByIdNumber.set(normalizeIdentifier(idNumber), studentId);
|
||||
if (studentNo) createdByStudentNo.set(normalizeIdentifier(studentNo), studentId);
|
||||
createdByName.set(normalizeIdentifier(name), studentId);
|
||||
}
|
||||
studentIdsToAdd.add(studentId);
|
||||
}
|
||||
}
|
||||
|
||||
const ids = [...studentIdsToAdd];
|
||||
let added = 0;
|
||||
let skipped = 0;
|
||||
if (ids.length) {
|
||||
// 校验待加入学生仍存在(预览与提交之间可能被删除),缺失的按冲突跳过,避免外键失败整批回滚
|
||||
const foundStudents = await manager.find(Student, {
|
||||
where: { id: In(ids), status: Not(In(['archived', 'staff'])) },
|
||||
});
|
||||
const foundIds = new Set(foundStudents.map((student) => student.id));
|
||||
const validIds = ids.filter((studentId) => foundIds.has(studentId));
|
||||
conflicts += ids.length - validIds.length;
|
||||
const existingMemberships = validIds.length
|
||||
? await manager.find(ClassStudent, {
|
||||
where: { classId, studentId: In(validIds) },
|
||||
})
|
||||
: [];
|
||||
const existingByStudentId = new Map(
|
||||
existingMemberships.map((m) => [m.studentId, m]),
|
||||
);
|
||||
const memberships = validIds.flatMap((studentId) => {
|
||||
const current = existingByStudentId.get(studentId);
|
||||
if (current?.status === 'active') {
|
||||
skipped++;
|
||||
return [];
|
||||
}
|
||||
if (current) {
|
||||
current.status = 'active';
|
||||
current.joinDate = today;
|
||||
current.leaveDate = null;
|
||||
added++;
|
||||
return [current];
|
||||
}
|
||||
added++;
|
||||
return [
|
||||
manager.create(ClassStudent, {
|
||||
classId,
|
||||
studentId,
|
||||
status: 'active',
|
||||
joinDate: today,
|
||||
}),
|
||||
];
|
||||
});
|
||||
if (memberships.length) {
|
||||
try {
|
||||
await manager.save(ClassStudent, memberships);
|
||||
} catch (error) {
|
||||
// 并发导入同一学生时 class_student 唯一索引可能冲突;InnoDB 下该语句回滚但事务仍可用。
|
||||
// REPEATABLE READ 下普通读基于旧快照,需用锁定读刷新可见性后再跳过已存在成员
|
||||
if (!isDuplicateEntryError(error)) throw error;
|
||||
const existingAfter = await manager.find(ClassStudent, {
|
||||
where: { classId, studentId: In(validIds) },
|
||||
lock: { mode: 'pessimistic_read' },
|
||||
});
|
||||
// 本批复活行(已存在记录改为 active)的 UPDATE 在批内已成功且对锁定读可见,
|
||||
// 应保留在 added;仅「新增行」且已 active 才视为并发事务已插入,计入 skipped
|
||||
const reactivationIds = new Set(
|
||||
memberships.filter((m) => m.id !== undefined).map((m) => m.studentId),
|
||||
);
|
||||
const activeAfterIds = new Set(
|
||||
existingAfter.filter((m) => m.status === 'active').map((m) => m.studentId),
|
||||
);
|
||||
const remaining = memberships.filter((m) => !activeAfterIds.has(m.studentId));
|
||||
const alreadyAdded = memberships.filter(
|
||||
(m) => !reactivationIds.has(m.studentId) && activeAfterIds.has(m.studentId),
|
||||
).length;
|
||||
added -= alreadyAdded;
|
||||
skipped += alreadyAdded;
|
||||
if (remaining.length) {
|
||||
// 逐行重试:多行 INSERT 是原子语句,任意一行撞唯一索引整批不落库;
|
||||
// 逐行让已由并发事务插入的行跳过、其余新成员正常落库,计数准确
|
||||
for (const membership of remaining) {
|
||||
try {
|
||||
await manager.save(ClassStudent, membership);
|
||||
} catch (retryError) {
|
||||
if (!isDuplicateEntryError(retryError)) throw retryError;
|
||||
added -= 1;
|
||||
skipped += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const message =
|
||||
`成功加入 ${added} 名学生` +
|
||||
(createdCount > 0 ? `(其中新建 ${createdCount} 名)` : '') +
|
||||
`,跳过 ${skipped} 名(已在本班)` +
|
||||
(conflicts > 0 ? `,未处理 ${conflicts} 行` : '');
|
||||
return { added, created: createdCount, skipped, conflicts, message };
|
||||
});
|
||||
}
|
||||
|
||||
async getSchedule(classId: number, query: QueryClassScheduleDto) {
|
||||
const qb = this.scheduleRepo
|
||||
@@ -137,7 +799,7 @@ export class ClassesQueriesService {
|
||||
...s,
|
||||
classroomName: (s.classroom as Classroom | undefined)?.name || null,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
async getAttendanceSummary(classId: number, query: QueryClassAttendanceSummaryDto) {
|
||||
const qb = this.attendanceRepo
|
||||
|
||||
Reference in New Issue
Block a user