Add independent subject pass rules and atomic score imports

This commit is contained in:
2026-07-20 15:42:36 +08:00 Unverified
parent c5b18d0b15
commit 9f850fa045
17 changed files with 688 additions and 114 deletions
+177 -22
View File
@@ -284,6 +284,52 @@ function publicExam(exam) {
};
}
function subjectPassThreshold(subject) {
const rule = subject?.passRule || 'fixed_score';
if (rule !== 'fixed_score') return null;
const value = Number(subject?.passValue ?? subject?.passScore ?? 0);
return Number(value.toFixed(2));
}
function subjectPassText(subject) {
const rule = subject?.passRule || 'fixed_score';
if (rule === 'none') return '不设单科线';
if (rule === 'rank_percent') return `本科排名前 ${Number(subject.passValue ?? 60)}% 达线`;
return `固定 ${subjectPassThreshold(subject)}`;
}
function gradeForRank(rank, cohortSize) {
const cutoff = ratio => Math.max(1, Math.ceil(cohortSize * ratio));
if (rank <= cutoff(.1)) return 'A+';
if (rank <= cutoff(.25)) return 'A';
if (rank <= cutoff(.5)) return 'B+';
if (rank <= cutoff(.7)) return 'B';
if (rank <= cutoff(.9)) return 'C';
return 'D';
}
function resultRankInfo(db, result, scoreOverride = result?.score) {
if (!result?.subjectId || !Number.isFinite(Number(scoreOverride))) return { rank: null, cohortSize: 0, rankPercent: null, grade: '' };
const score = Number(scoreOverride);
const peers = db.results.filter(item => item.id !== result.id && item.subjectId === result.subjectId && item.published);
const cohortSize = peers.length + 1;
const rank = 1 + peers.filter(item => Number(item.score) > score).length;
const rankPercent = Number((rank / cohortSize * 100).toFixed(2));
return { rank, cohortSize, rankPercent, grade: gradeForRank(rank, cohortSize) };
}
function subjectPassEvaluation(db, result, subject, scoreOverride = result?.score) {
const rule = subject?.passRule || 'fixed_score';
if (rule === 'none') return { qualified: null, passScore: null, cutoffRank: null, ...resultRankInfo(db, result, scoreOverride) };
if (rule === 'rank_percent') {
const rankInfo = resultRankInfo(db, result, scoreOverride);
const cutoffRank = Math.max(1, Math.ceil(rankInfo.cohortSize * Number(subject.passValue ?? 60) / 100));
return { ...rankInfo, qualified: rankInfo.rank <= cutoffRank, passScore: null, cutoffRank };
}
const passScore = subjectPassThreshold(subject);
return { ...resultRankInfo(db, result, scoreOverride), qualified: Number(scoreOverride) >= passScore, passScore, cutoffRank: null };
}
function examResultSummary(db, registration) {
const exam = db.exams.find(item => item.id === registration.examId);
if (!exam) return null;
@@ -294,15 +340,17 @@ function examResultSummary(db, registration) {
const total = subjects.reduce((sum, subject) => sum + Number(resultsBySubject.get(subject.id)?.score || 0), 0);
const fullScore = subjects.reduce((sum, subject) => sum + Number(subject.fullScore || 0), 0);
const scoreRatio = fullScore ? total / fullScore * 100 : 0;
const policy = exam.passPolicy || 'score_ratio';
const policy = exam.passPolicy === 'score_ratio' ? 'rank_percent' : (exam.passPolicy || 'rank_percent');
const value = Number(exam.passValue ?? 60);
let qualified = null;
let rank = null;
let cohortSize = null;
if (complete && policy === 'fixed_score') qualified = total >= value;
if (complete && policy === 'score_ratio') qualified = scoreRatio >= value;
if (complete && policy === 'subject_scores') qualified = subjects.every(subject => Number(resultsBySubject.get(subject.id).score) >= Number(subject.passScore));
if (complete && policy === 'subject_scores') qualified = subjects.every(subject => {
const subjectResult = resultsBySubject.get(subject.id);
return subjectPassEvaluation(db, subjectResult, subject).qualified !== false;
});
if (complete && policy === 'none') qualified = null;
if (complete && policy === 'rank_percent') {
const subjectKey = [...registration.subjectIds].sort().join('|');
@@ -432,12 +480,24 @@ function excelRowsForResource(db, user, resource, searchParams) {
}));
});
if (resource === 'results') {
const examId = cleanText(searchParams.get('examId'), 64);
const scopedRegistrations = db.registrations.filter(item => registrationInScope(db, user, item));
return db.results.filter(result => scopedRegistrations.some(item => item.id === result.registrationId)).map(result => {
return db.results.filter(result => scopedRegistrations.some(item => item.id === result.registrationId && (!examId || item.examId === examId))).map(result => {
const registration = db.registrations.find(item => item.id === result.registrationId);
const exam = db.exams.find(item => item.id === registration?.examId);
const subject = exam?.subjects.find(item => item.id === result.subjectId);
return { candidateNumber: db.users.find(item => item.id === registration?.userId)?.candidateNumber || '', examCode: exam?.code || '', subjectName: subject?.name || '', fullScore: subject?.fullScore || '', passScore: subject?.passScore || '', score: result.score, grade: result.grade, published: result.published ? '发布' : '不发布' };
const account = db.users.find(item => item.id === registration?.userId);
const profile = db.candidateProfiles.find(item => item.userId === registration?.userId);
const evaluation = subjectPassEvaluation(db, result, subject);
const rank = resultRankInfo(db, result);
return {
candidateNumber: account?.candidateNumber || '', candidateName: profile?.name || account?.displayName || '',
examCode: exam?.code || '', examName: exam?.name || '', subjectName: subject?.name || '',
fullScore: subject?.fullScore || '', passRule: subjectPassText(subject), passScore: evaluation.passScore ?? '', score: result.score,
rank: rank.rank, rankPercent: rank.rankPercent,
qualified: evaluation.qualified == null ? '不判定' : evaluation.qualified ? '达线' : '未达线',
grade: result.published ? resultRankInfo(db, result).grade : '待发布', published: result.published ? '发布' : '不发布', updatedAt: result.updatedAt || result.publishedAt || ''
};
});
}
if (resource === 'admit_cards') {
@@ -452,6 +512,112 @@ function excelImportError(row, message) {
return Object.assign(new Error(`Excel 第 ${row.__row || '?'} 行:${message}`), { status: 400 });
}
function prepareResultImport(db, rows) {
const normalized = [];
const errors = [];
const seen = new Set();
for (const source of Array.isArray(rows) ? rows : []) {
const sourceRow = Number(source.__row || source.sourceRow || normalized.length + 3);
const candidateNumber = cleanText(source.candidateNumber, 120);
const examCode = cleanText(source.examCode, 60);
const subjectName = cleanText(source.subjectName, 50);
const account = db.users.find(item => item.candidateNumber === candidateNumber);
const exam = db.exams.find(item => item.code.toUpperCase() === examCode.toUpperCase());
const registration = db.registrations.find(item => item.userId === account?.id && item.examId === exam?.id && item.status === 'approved');
const subject = exam?.subjects.find(item => item.name.toLowerCase() === subjectName.toLowerCase());
const score = source.score == null || String(source.score).trim() === '' ? Number.NaN : Number(source.score);
const rowErrors = [];
if (!candidateNumber) rowErrors.push('报名号不能为空');
else if (!account) rowErrors.push('报名号不存在');
if (!examCode) rowErrors.push('考试代码不能为空');
else if (!exam) rowErrors.push('考试代码不存在');
if (!subjectName) rowErrors.push('科目不能为空');
else if (exam && !subject) rowErrors.push('该考试中不存在此科目');
if (exam && account && !registration) rowErrors.push('该考生没有已通过的本场考试报名');
if (registration && subject && !registration.subjectIds.includes(subject.id)) rowErrors.push('该考生未报考此科目');
if (!Number.isFinite(score) || score < 0 || (subject && score > Number(subject.fullScore))) rowErrors.push(`成绩须在 0—${subject?.fullScore ?? 0} 之间`);
const publishText = typeof source.published === 'boolean' ? (source.published ? '发布' : '不发布') : cleanText(source.published, 20) || '不发布';
if (!['发布', '不发布'].includes(publishText)) rowErrors.push('发布状态只能是“发布”或“不发布”');
const key = registration && subject ? `${registration.id}|${subject.id}` : `${candidateNumber}|${examCode}|${subjectName}`;
if (seen.has(key)) rowErrors.push('同一考生、考试和科目在文件中重复');
seen.add(key);
const existing = registration && subject ? db.results.find(item => item.registrationId === registration.id && item.subjectId === subject.id) : null;
const evaluation = subject ? subjectPassEvaluation(db, existing || { id: `preview-${sourceRow}`, subjectId: subject.id, score, published: publishText === '发布' }, subject, score) : null;
const profile = db.candidateProfiles.find(item => item.userId === account?.id);
const schoolClass = db.classes.find(item => item.id === profile?.classId);
const rank = subject && Number.isFinite(score) ? resultRankInfo(db, { id: existing?.id || `preview-${sourceRow}`, subjectId: subject.id, score, published: publishText === '发布' }, score) : null;
const row = {
sourceRow, candidateNumber, candidateName: profile?.name || account?.displayName || '', schoolName: profile?.school || '', className: schoolClass?.name || profile?.grade || '',
examId: exam?.id || '', examCode, examName: exam?.name || '', registrationId: registration?.id || '',
subjectId: subject?.id || '', subjectName, fullScore: subject?.fullScore ?? null,
passRule: subject?.passRule || 'fixed_score', passValue: subject?.passValue ?? subject?.passScore ?? null,
passScore: evaluation?.passScore ?? null, passText: subject ? subjectPassText(subject) : '', score,
rank: rank?.rank ?? null, cohortSize: rank?.cohortSize ?? null, rankPercent: rank?.rankPercent ?? null,
qualified: !Number.isFinite(score) ? null : evaluation?.qualified ?? null,
grade: rank?.grade || '',
published: publishText === '发布', existingResultId: existing?.id || '', mode: existing ? 'update' : 'create', errors: rowErrors
};
normalized.push(row);
errors.push(...rowErrors.map(message => ({ row: sourceRow, message })));
}
const previewResults = db.results.map(item => ({ ...item }));
for (const row of normalized.filter(item => !item.errors.length)) {
const existingIndex = row.existingResultId ? previewResults.findIndex(item => item.id === row.existingResultId) : -1;
const previewResult = {
...(existingIndex >= 0 ? previewResults[existingIndex] : {}),
id: row.existingResultId || `preview-${row.sourceRow}`,
registrationId: row.registrationId,
subjectId: row.subjectId,
score: row.score,
published: row.published
};
if (existingIndex >= 0) previewResults[existingIndex] = previewResult;
else previewResults.push(previewResult);
}
const previewDb = { ...db, results: previewResults };
for (const row of normalized.filter(item => !item.errors.length)) {
const previewResult = previewResults.find(item => item.id === (row.existingResultId || `preview-${row.sourceRow}`));
const subject = db.exams.find(item => item.id === row.examId)?.subjects.find(item => item.id === row.subjectId);
const rank = resultRankInfo(previewDb, previewResult, row.score);
const evaluation = subjectPassEvaluation(previewDb, previewResult, subject, row.score);
Object.assign(row, { rank: rank.rank, cohortSize: rank.cohortSize, rankPercent: rank.rankPercent, grade: row.published ? rank.grade : '待发布', passScore: evaluation.passScore, cutoffRank: evaluation.cutoffRank, qualified: evaluation.qualified });
}
return {
rows: normalized,
errors,
summary: {
total: normalized.length,
valid: normalized.filter(item => !item.errors.length).length,
invalid: normalized.filter(item => item.errors.length).length,
create: normalized.filter(item => !item.errors.length && item.mode === 'create').length,
update: normalized.filter(item => !item.errors.length && item.mode === 'update').length,
publish: normalized.filter(item => !item.errors.length && item.published).length
}
};
}
async function commitResultImport(db, user, rows) {
if (user.adminLevel !== 'super') throw Object.assign(new Error('只有超级管理员可以批量提交成绩'), { status: 403 });
const prepared = prepareResultImport(db, rows);
if (prepared.errors.length) {
const first = prepared.errors[0];
throw Object.assign(new Error(`${first.row} 行:${first.message};请返回预览修正后重试`), { status: 400 });
}
const entries = prepared.rows.map(row => {
const existing = row.existingResultId ? db.results.find(item => item.id === row.existingResultId) : null;
const result = existing || { id: uid('result'), registrationId: row.registrationId, subjectId: row.subjectId };
Object.assign(result, {
score: row.score, grade: row.grade, published: row.published, updatedAt: nowIso(),
publishedAt: row.published ? (existing?.publishedAt || nowIso()) : null
});
const log = logAction(db, user, row.published ? 'Excel 批量发布成绩' : 'Excel 批量保存成绩', `${row.candidateNumber} · ${row.examName} · ${row.subjectName} · ${row.score}`);
return { result, isNew: !existing, log };
});
await database.saveResults(entries);
for (const entry of entries) if (entry.isNew) db.results.push(entry.result);
return { count: entries.length, summary: prepared.summary };
}
async function importExcelResource(db, user, resource, rows) {
if (resource === 'classes') {
if (!['school', 'super'].includes(user.adminLevel)) throw Object.assign(new Error('当前账号不能导入班级'), { status: 403 });
@@ -554,23 +720,7 @@ async function importExcelResource(db, user, resource, rows) {
return { count: groups.size };
}
if (resource === 'results') {
if (user.adminLevel !== 'super') throw Object.assign(new Error('只有超级管理员可以导入成绩'), { status: 403 });
for (const row of rows) {
const account = db.users.find(item => item.candidateNumber === cleanText(row.candidateNumber, 120));
const exam = db.exams.find(item => item.code === cleanText(row.examCode, 60));
const registration = db.registrations.find(item => item.userId === account?.id && item.examId === exam?.id && item.status === 'approved');
const subject = exam?.subjects.find(item => item.name === cleanText(row.subjectName, 50));
const score = Number(row.score);
if (!registration || !subject || !registration.subjectIds.includes(subject.id) || !Number.isFinite(score) || score < 0 || score > subject.fullScore) throw excelImportError(row, `报名号、考试、科目无效,或成绩不在 0—${subject?.fullScore || 0} 之间`);
let result = db.results.find(item => item.registrationId === registration.id && item.subjectId === subject.id);
const isNew = !result;
if (!result) result = { id: uid('result'), registrationId: registration.id, subjectId: subject.id };
const ratio = score / subject.fullScore;
Object.assign(result, { score, grade: cleanText(row.grade, 10) || (ratio >= .9 ? 'A+' : ratio >= .8 ? 'A' : ratio >= .7 ? 'B+' : ratio >= .6 ? 'B' : ratio >= .4 ? 'C' : 'D'), published: row.published === '发布', updatedAt: nowIso(), publishedAt: row.published === '发布' ? nowIso() : null });
await database.saveResult(result, isNew, logAction(db, user, 'Excel 导入成绩', `${row.candidateNumber} · ${exam.name} · ${subject.name}`));
if (isNew) db.results.push(result);
}
return { count: rows.length };
return commitResultImport(db, user, rows);
}
throw Object.assign(new Error('该 Excel 类型仅支持导出'), { status: 400 });
}
@@ -648,12 +798,17 @@ const routeContext = {
publicExam,
examRegistrationView,
examResultSummary,
subjectPassText,
subjectPassEvaluation,
resultRankInfo,
logAction,
excelResourceNames,
excelRowsForResource,
admissionRowsForRegistrations,
centerMaterialRows,
importExcelResource,
prepareResultImport,
commitResultImport,
admitCardHtml,
admitCardsHtml,
hashPassword,