Add independent subject pass rules and atomic score imports
This commit is contained in:
+177
-22
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user