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
+107 -16
View File
@@ -33,6 +33,10 @@ export function createAdminRoutes(context) {
parseCenterChange,
maskId,
publicExam,
examResultSummary,
subjectPassText,
resultRankInfo,
subjectPassEvaluation,
examRegistrationView,
logAction,
excelResourceNames,
@@ -40,6 +44,8 @@ export function createAdminRoutes(context) {
admissionRowsForRegistrations,
centerMaterialRows,
importExcelResource,
prepareResultImport,
commitResultImport,
admitCardHtml,
admitCardsHtml,
hashPassword,
@@ -56,7 +62,8 @@ export function createAdminRoutes(context) {
permissionsByLevel
} = context;
const passPolicies = new Set(['fixed_score', 'score_ratio', 'rank_percent', 'subject_scores', 'none']);
const passPolicies = new Set(['fixed_score', 'rank_percent', 'subject_scores', 'none']);
const subjectPassRules = new Set(['fixed_score', 'rank_percent', 'none']);
function normalizeSubjects(input, examStart) {
const source = Array.isArray(input) ? input : String(input || '').split(/[,]/);
@@ -65,6 +72,10 @@ export function createAdminRoutes(context) {
const name = cleanText(structured ? item.name : item, 50);
if (!name) return null;
const fullScore = Number(structured ? item.fullScore : 150);
const requestedRule = item?.passRule === 'score_ratio' ? 'rank_percent' : item?.passRule;
const passRule = subjectPassRules.has(requestedRule) ? requestedRule : 'fixed_score';
const passValue = passRule === 'none' ? 0 : Number(structured ? (item.passValue ?? item.passScore ?? fullScore * .6) : fullScore * .6);
const passScore = passRule === 'fixed_score' ? Number(passValue.toFixed(2)) : null;
return {
id: uid('sub'),
name,
@@ -73,7 +84,9 @@ export function createAdminRoutes(context) {
end: cleanText(structured ? item.end : '', 5) || '11:00',
fee: Number(structured ? item.fee ?? 0 : 0),
fullScore,
passScore: Number(structured ? item.passScore : fullScore * .6),
passRule,
passValue,
passScore,
order: index + 1
};
}).filter(Boolean);
@@ -82,12 +95,14 @@ export function createAdminRoutes(context) {
function validateExamScoring(subjects, passPolicy, passValue) {
if (!subjects.length) return '请至少添加一个考试科目';
if (subjects.some(item => !Number.isFinite(item.fullScore) || item.fullScore <= 0 || item.fullScore > 1000)) return '科目满分必须大于 0 且不超过 1000';
if (subjects.some(item => !Number.isFinite(item.passScore) || item.passScore < 0 || item.passScore > item.fullScore)) return '单科合格分必须在 0 与该科满分之间';
if (subjects.some(item => !subjectPassRules.has(item.passRule))) return '请选择有效的单科合格线计算方式';
if (subjects.some(item => item.passRule === 'fixed_score' && (!Number.isFinite(item.passValue) || item.passValue < 0 || item.passValue > item.fullScore))) return '固定单科合格分必须在 0 与该科满分之间';
if (subjects.some(item => item.passRule === 'rank_percent' && (!Number.isFinite(item.passValue) || item.passValue <= 0 || item.passValue > 100))) return '单科排名比例必须大于 0 且不超过 100%';
if (subjects.some(item => !Number.isFinite(item.fee) || item.fee < 0 || item.fee > 100000)) return '科目费用必须在有效范围内';
if (!passPolicies.has(passPolicy)) return '请选择有效的合格线策略';
const totalScore = subjects.reduce((sum, item) => sum + item.fullScore, 0);
if (passPolicy === 'fixed_score' && (!Number.isFinite(passValue) || passValue < 0 || passValue > totalScore)) return `固定合格线必须在 0 与总分 ${totalScore} 之间`;
if (['score_ratio', 'rank_percent'].includes(passPolicy) && (!Number.isFinite(passValue) || passValue <= 0 || passValue > 100)) return '百分比必须大于 0 且不超过 100';
if (passPolicy === 'rank_percent' && (!Number.isFinite(passValue) || passValue <= 0 || passValue > 100)) return '排名比例必须大于 0 且不超过 100';
return '';
}
@@ -128,6 +143,10 @@ export function createAdminRoutes(context) {
const resource = excelMatch[1];
if (resource === 'account_results') return sendError(response, 400, '账号结果清单只支持导出');
const rows = await parseWorkbook(resource, await readBodyBuffer(request));
if (resource === 'results') {
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以预览导入成绩');
return sendJson(response, 200, { ok: true, preview: true, ...prepareResultImport(db, rows) });
}
const result = await importExcelResource(db, user, resource, rows);
return sendJson(response, 200, { ok: true, ...result });
}
@@ -458,13 +477,21 @@ export function createAdminRoutes(context) {
const appealRegistration = appealResult ? db.registrations.find(item => item.id === appealResult.registrationId) : null;
const appealExam = appealRegistration ? db.exams.find(item => item.id === appealRegistration.examId) : null;
const appealSubject = appealExam?.subjects.find(item => item.id === appealResult?.subjectId);
const appealRank = appealResult ? resultRankInfo(db, appealResult) : null;
const appealPass = appealResult ? subjectPassEvaluation(db, appealResult, appealSubject) : null;
return {
...workflowView(db, instance), candidateName: profile?.name || '', schoolName: profile?.school || '', className: profile?.grade || '',
examName: registration ? db.exams.find(item => item.id === registration.examId)?.name || '' : '',
centerName: centerChange?.name || '', requestType: centerChange?.requestType || '', centerChange: centerChange ? centerChangeView(db, centerChange) : null,
accountBatch: accountBatch ? candidateAccountBatchView(db, accountBatch) : null,
batchTotalCount: accountBatch ? db.candidateAccountBatchItems.filter(item => item.batchId === accountBatch.id).length : 0,
appealResult: appealResult ? { score: appealResult.score, grade: appealResult.grade, examName: appealExam?.name || '', subjectName: appealSubject?.name || '' } : null
appealResult: appealResult ? {
score: appealResult.score, grade: appealRank?.grade || appealResult.grade, rank: appealRank?.rank, cohortSize: appealRank?.cohortSize, rankPercent: appealRank?.rankPercent,
examName: appealExam?.name || '', examCode: appealExam?.code || '', subjectName: appealSubject?.name || '',
fullScore: appealSubject?.fullScore, passRule: appealSubject?.passRule || 'fixed_score', passValue: appealSubject?.passValue ?? appealSubject?.passScore,
passScore: appealPass?.passScore ?? null, cutoffRank: appealPass?.cutoffRank ?? null,
passText: subjectPassText(appealSubject), qualified: appealPass?.qualified ?? null
} : null
};
});
const availableAdmins = db.users.filter(item => item.role === 'admin' && item.active).map(safeUser);
@@ -718,6 +745,14 @@ export function createAdminRoutes(context) {
if (user.adminLevel !== 'super' && (instance.assigneeId !== user.id || step.adminLevel !== user.adminLevel)) return sendError(response, 403, '该流程当前未分配给你,可由当前处理人转交');
const note = cleanText(body.reviewNote, 300);
const action = { id: uid('flow_action'), instanceId: instance.id, actorId: user.id, action: body.status === 'approved' ? 'approve' : 'reject', note, fromAssigneeId: instance.assigneeId, toAssigneeId: null, createdAt: nowIso() };
const exam = db.exams.find(item => item.id === registration.examId);
const subject = exam?.subjects.find(item => item.id === result.subjectId);
const finalStep = instance.currentStep >= workflow.steps.length;
let reviewedScore = null;
if (body.status === 'approved' && finalStep) {
reviewedScore = body.reviewedScore == null || String(body.reviewedScore).trim() === '' ? Number.NaN : Number(body.reviewedScore);
if (!Number.isFinite(reviewedScore) || reviewedScore < 0 || reviewedScore > Number(subject?.fullScore || 0)) return sendError(response, 400, `最终审批必须填写 0—${subject?.fullScore || 0} 之间的复核后分数`);
}
if (body.status === 'rejected') {
instance.status = 'rejected'; instance.completedAt = nowIso(); instance.assigneeId = null;
} else if (instance.currentStep < workflow.steps.length) {
@@ -727,12 +762,25 @@ export function createAdminRoutes(context) {
instance.currentStep += 1; instance.assigneeId = nextAssignee.id; action.toAssigneeId = nextAssignee.id;
} else {
instance.status = 'approved'; instance.completedAt = nowIso(); instance.assigneeId = null;
const originalScore = result.score;
result.score = reviewedScore;
const rank = resultRankInfo(db, result, reviewedScore);
result.grade = rank.grade;
result.updatedAt = nowIso();
const pass = subjectPassEvaluation(db, result, subject, reviewedScore);
const passConclusion = pass.qualified == null
? '本科不判定单科达线'
: `${pass.qualified ? '达到' : '未达到'}${subject.passRule === 'rank_percent' ? `排名前 ${subject.passValue}%(当前第 ${pass.rank}/${pass.cohortSize} 名,截止第 ${pass.cutoffRank} 名)` : `固定及格线 ${pass.passScore}`}`;
action.note = [`原分 ${originalScore} → 复核后 ${reviewedScore}`, passConclusion, note].filter(Boolean).join('');
}
const exam = db.exams.find(item => item.id === registration.examId);
const subject = exam?.subjects.find(item => item.id === result.subjectId);
const log = logAction(db, user, body.status === 'approved' ? '处理成绩复议' : '退回成绩复议', `${profile.name} · ${exam?.name || ''} · ${subject?.name || ''}`);
await database.processWorkflow(instance, action, null, log);
return sendJson(response, 200, { ok: true, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance) });
const log = logAction(db, user, body.status === 'approved' ? (finalStep ? '复议终审并更新成绩' : '处理成绩复议') : '退回成绩复议', `${profile.name} · ${exam?.name || ''} · ${subject?.name || ''}${finalStep && body.status === 'approved' ? ` · ${result.score}` : ''}`);
await database.processWorkflow(instance, action, finalStep && body.status === 'approved' ? result : null, log);
const rank = resultRankInfo(db, result);
const pass = subjectPassEvaluation(db, result, subject);
return sendJson(response, 200, {
ok: true, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance),
result: { ...result, ...rank, passScore: pass.passScore, cutoffRank: pass.cutoffRank, qualified: pass.qualified }
});
}
const arrangementMatch = pathname.match(/^\/api\/admin\/exams\/([^/]+)\/admission-arrangement(\/preview)?$/);
if (request.method === 'POST' && arrangementMatch) {
@@ -783,7 +831,8 @@ export function createAdminRoutes(context) {
const name = cleanText(body.name, 100);
if (!name || !body.registrationStart || !body.registrationEnd || !body.examStart || !body.examEnd) return sendError(response, 400, '请完整填写考试名称和关键日期');
const subjects = normalizeSubjects(body.subjects, body.examStart);
const passPolicy = passPolicies.has(body.passPolicy) ? body.passPolicy : 'score_ratio';
const requestedPolicy = body.passPolicy === 'score_ratio' ? 'rank_percent' : body.passPolicy;
const passPolicy = passPolicies.has(requestedPolicy) ? requestedPolicy : 'rank_percent';
const passValue = ['subject_scores', 'none'].includes(passPolicy) ? 0 : Number(body.passValue ?? 60);
const scoringError = validateExamScoring(subjects, passPolicy, passValue);
if (scoringError) return sendError(response, 400, scoringError);
@@ -804,7 +853,10 @@ export function createAdminRoutes(context) {
if (editingDetails && originalStatus !== 'draft') return sendError(response, 409, '请先将考试撤回为草稿后再编辑');
if (body.status && ['draft', 'published', 'closed'].includes(body.status)) exam.status = body.status;
detailFields.forEach(field => { if (body[field] != null) exam[field] = cleanText(body[field], field === 'description' ? 500 : 100); });
if (body.passPolicy != null && passPolicies.has(body.passPolicy)) exam.passPolicy = body.passPolicy;
if (body.passPolicy != null) {
const requestedPolicy = body.passPolicy === 'score_ratio' ? 'rank_percent' : body.passPolicy;
if (passPolicies.has(requestedPolicy)) exam.passPolicy = requestedPolicy;
}
if (body.passValue != null) exam.passValue = Number(body.passValue);
let replaceSubjects = false;
if (body.subjects != null) {
@@ -857,16 +909,55 @@ export function createAdminRoutes(context) {
const results = db.results.filter(result => scopedRegistrations.some(item => item.id === result.registrationId)).map(result => {
const registration = db.registrations.find(item => item.id === result.registrationId);
const profile = db.candidateProfiles.find(item => item.userId === registration?.userId);
const account = db.users.find(item => item.id === registration?.userId);
const exam = db.exams.find(item => item.id === registration?.examId);
const subject = exam?.subjects.find(item => item.id === result.subjectId);
return { ...result, candidateName: profile?.name, examName: exam?.name, subjectName: subject?.name };
const pass = subjectPassEvaluation(db, result, subject);
const rank = resultRankInfo(db, result);
return {
...result, grade: result.published ? rank.grade : result.grade, rank: rank.rank, cohortSize: rank.cohortSize, rankPercent: rank.rankPercent,
candidateName: profile?.name, candidateNumber: account?.candidateNumber || registration?.registrationNumber || '',
schoolName: profile?.school || '', className: db.classes.find(item => item.id === profile?.classId)?.name || profile?.grade || '',
examId: exam?.id, examCode: exam?.code, examName: exam?.name, subjectName: subject?.name,
fullScore: subject?.fullScore, passRule: subject?.passRule || 'fixed_score', passValue: subject?.passValue ?? subject?.passScore,
passScore: pass.passScore, cutoffRank: pass.cutoffRank, passText: subjectPassText(subject),
qualified: pass.qualified
};
});
const appeals = db.workflowInstances.filter(instance => instance.businessType === 'score_appeal' && results.some(result => result.id === instance.businessId)).map(instance => {
const result = results.find(item => item.id === instance.businessId);
const workflow = workflowView(db, instance);
return { ...workflow, result, reason: workflow.actions.find(action => action.action === 'submit')?.note || '' };
});
return sendJson(response, 200, { ok: true, results, appeals, registrations: user.adminLevel === 'super' ? scopedRegistrations.filter(item => item.status === 'approved').map(item => examRegistrationView(db, item)) : [] });
const approved = scopedRegistrations.filter(item => item.status === 'approved');
const exams = db.exams.map(exam => {
const examRegistrations = approved.filter(item => item.examId === exam.id);
const examResults = results.filter(item => item.examId === exam.id);
const summaries = examRegistrations.map(item => examResultSummary(db, item)).filter(Boolean);
const enrolledSubjects = examRegistrations.reduce((sum, item) => sum + item.subjectIds.length, 0);
const scored = examResults.length;
return {
...publicExam(exam), registrationCount: examRegistrations.length, enrolledSubjects, scored,
published: examResults.filter(item => item.published).length, missing: Math.max(0, enrolledSubjects - scored),
complete: summaries.filter(item => item.complete).length,
qualified: summaries.filter(item => item.complete && item.qualified === true).length,
unqualified: summaries.filter(item => item.complete && item.qualified === false).length,
appeals: appeals.filter(item => item.result?.examId === exam.id).length
};
}).filter(exam => exam.registrationCount || results.some(item => item.examId === exam.id) || user.adminLevel === 'super');
const registrations = user.adminLevel === 'super' ? approved.map(item => {
const view = examRegistrationView(db, item);
const profile = db.candidateProfiles.find(profileItem => profileItem.userId === item.userId);
const account = db.users.find(accountItem => accountItem.id === item.userId);
return { ...view, candidateName: profile?.name || account?.displayName || '', candidateNumber: account?.candidateNumber || item.registrationNumber || '', schoolName: profile?.school || '', className: db.classes.find(classItem => classItem.id === profile?.classId)?.name || profile?.grade || '' };
}) : [];
return sendJson(response, 200, { ok: true, results, appeals, registrations, exams });
}
if (request.method === 'POST' && pathname === '/api/admin/results/import') {
if (!requirePermission(user, response, '*')) return true;
const body = await readJson(request);
const result = await commitResultImport(db, user, body.rows);
return sendJson(response, 200, { ok: true, ...result });
}
if (request.method === 'POST' && pathname === '/api/admin/results') {
if (!requirePermission(user, response, '*')) return true;
@@ -884,8 +975,8 @@ export function createAdminRoutes(context) {
result = { id: uid('result'), registrationId: registration.id, subjectId: body.subjectId };
db.results.push(result);
}
const ratio = score / subject.fullScore;
Object.assign(result, { score, grade: cleanText(body.grade, 10) || (ratio >= .9 ? 'A+' : ratio >= .8 ? 'A' : ratio >= .7 ? 'B+' : ratio >= .6 ? 'B' : ratio >= .4 ? 'C' : 'D'), published: Boolean(body.published), updatedAt: nowIso(), publishedAt: body.published ? nowIso() : null });
Object.assign(result, { score, published: Boolean(body.published), updatedAt: nowIso(), publishedAt: body.published ? (result.publishedAt || nowIso()) : null });
result.grade = result.published ? resultRankInfo(db, result, score).grade : '待发布';
const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
const log = logAction(db, user, body.published ? '发布成绩' : '保存成绩', `${profile?.name} · ${subject?.name} · ${score}`);
await database.saveResult(result, isNew, log);