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
+28 -5
View File
@@ -94,12 +94,14 @@ try {
const schemaVersion = inspector.prepare('SELECT schema_version FROM schema_metadata WHERE id = 1').get().schema_version;
const admitCardColumns = inspector.prepare('PRAGMA table_info(admit_cards)').all().map(row => row.name);
const admitSubjectColumns = inspector.prepare('PRAGMA table_info(admit_card_subjects)').all().map(row => row.name);
const examSubjectColumns = inspector.prepare('PRAGMA table_info(exam_subjects)').all().map(row => row.name);
inspector.close();
assert.deepEqual(tableNames, [...relationalTables].sort(), '业务数据必须按关系模型分表存储');
assert.ok(!tableNames.includes('app_state'), '不得使用单表 JSON 状态存储');
assert.equal(schemaVersion, 10, '准考证详细位置快照应使用 v10 数据结构');
assert.equal(schemaVersion, 12, '排名比例、排名等级与复议改分应使用 v12 数据结构');
assert.ok(['center_code', 'center_address'].every(column => admitCardColumns.includes(column)), '准考证应保存考点代码与详细地址快照');
assert.ok(['building', 'floor'].every(column => admitSubjectColumns.includes(column)), '分科考场应保存楼栋楼层快照');
assert.ok(['pass_rule', 'pass_value'].every(column => examSubjectColumns.includes(column)), '科目应保存独立及格线计算方式和规则数值');
const publicHome = await anonymous.request('/api/public/home');
assert.equal(publicHome.response.status, 200);
@@ -293,14 +295,16 @@ try {
examStart: new Date(now + 48 * hour).toISOString(), examEnd: new Date(now + 60 * hour).toISOString(),
admitDownloadStart: new Date(now - hour).toISOString(), admitDownloadEnd: new Date(now + 47 * hour).toISOString(),
location: '测试考点', status: 'published', subjects: [
{ name: '语文', date: new Date(now + 48 * hour).toISOString().slice(0, 10), start: '09:00', end: '11:00', fee: 0, fullScore: 150, passScore: 90 },
{ name: '语文', date: new Date(now + 48 * hour).toISOString().slice(0, 10), start: '09:00', end: '11:00', fee: 0, fullScore: 150, passRule: 'fixed_score', passValue: 90 },
{ name: '数学', date: new Date(now + 48 * hour).toISOString().slice(0, 10), start: '13:00', end: '15:00', fee: 0, fullScore: 150, passScore: 90 },
{ name: '外语', date: new Date(now + 48 * hour).toISOString().slice(0, 10), start: '15:30', end: '17:00', fee: 0, fullScore: 150, passScore: 90 }
{ name: '外语', date: new Date(now + 48 * hour).toISOString().slice(0, 10), start: '15:30', end: '17:00', fee: 0, fullScore: 150, passRule: 'rank_percent', passValue: 60 }
]
}
});
assert.equal(createExam.response.status, 201);
assert.equal(createExam.data.exam.subjects.length, 3, '管理员应可创建多科目考试');
assert.equal(createExam.data.exam.subjects[2].passRule, 'rank_percent', '每科应可独立按排名比例计算及格线');
assert.equal(createExam.data.exam.subjects[2].passScore, null, '排名比例不应伪造固定分数线');
const exam = createExam.data.exam;
const schoolCannotCreateExam = await schoolAdmin.request('/api/admin/exams', { method: 'POST', body: { name: '越权考试' } });
assert.equal(schoolCannotCreateExam.response.status, 403, '校级管理员不得管理全局考试计划');
@@ -519,6 +523,16 @@ try {
const refreshedHome = await anonymous.request('/api/public/home');
assert.ok(refreshedHome.data.notices.some(item => item.title === '系统测试成绩发布通知'), '管理员发布通知后首页应可见');
const stagedResultFile = Buffer.from(await buildWorkbook('results', [{ candidateNumber, examCode: exam.code, subjectName: exam.subjects[0].name, score: 125, grade: 'A', published: '不发布' }]));
const stagedPreview = await admin.request('/api/admin/excel/results', { method: 'POST', headers: { 'Content-Type': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }, body: stagedResultFile });
assert.equal(stagedPreview.response.status, 200, '成绩 Excel 应先解析为预览');
assert.equal(stagedPreview.data.preview, true);
assert.equal(stagedPreview.data.summary.valid, 1);
assert.ok(!(await admin.request('/api/admin/results')).data.results.some(item => item.registrationId === registrationId && item.subjectId === exam.subjects[0].id), '预览阶段不得写入数据库');
const stagedCommit = await admin.request('/api/admin/results/import', { method: 'POST', body: { rows: stagedPreview.data.rows } });
assert.equal(stagedCommit.response.status, 200, '确认后应批量提交预览成绩');
assert.equal(stagedCommit.data.count, 1);
assert.ok((await admin.request('/api/admin/results')).data.results.some(item => item.registrationId === registrationId && item.subjectId === exam.subjects[0].id && item.score === 125 && !item.published), '批量提交后成绩应以预览状态写库');
const publishResult = await admin.request('/api/admin/results', { method: 'POST', body: { registrationId, subjectId: exam.subjects[0].id, score: 126, grade: 'A', published: true } });
assert.equal(publishResult.response.status, 200);
assert.equal((await admin.request('/api/admin/results', { method: 'POST', body: { registrationId, subjectId: exam.subjects[0].id, score: 151, published: true } })).response.status, 400, '成绩不得超过该科配置的满分');
@@ -528,7 +542,8 @@ try {
const resultSummary = results.data.summaries.find(item => item.examId === exam.id);
assert.equal(resultSummary.total, 216, '考生端应汇总已报科目的总分');
assert.equal(resultSummary.fullScore, 300, '考生总分满分应按实际报考科目汇总');
assert.equal(resultSummary.qualified, true, '全部科目发布后应按得分率策略自动判定合格');
assert.equal(resultSummary.qualified, true, '全部科目发布后应按总成绩排名比例自动判定合格');
assert.ok(results.data.results.filter(item => item.examId === exam.id).every(item => item.rank === 1 && item.cohortSize === 1 && item.grade === 'A+'), '单科等级应按同场同科排名计算');
const classResults = await classAdmin.request('/api/admin/results');
assert.ok(classResults.data.results.some(item => item.score === 126 && item.candidateName === '测试考生新名'), '班级管理员应可查看本班成绩');
assert.equal((await classAdmin.request('/api/admin/results', { method: 'POST', body: { registrationId, subjectId: exam.subjects[0].id, score: 1 } })).response.status, 403, '班级管理员不得录入或发布成绩');
@@ -560,11 +575,19 @@ try {
const schoolApproved = await schoolClient.request(`/api/admin/score-appeals/${appeal.result.id}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '学校完成成绩复核' } });
assert.equal(schoolApproved.response.status, 200);
assert.equal(schoolApproved.data.workflow.currentStepDetail.adminLevel, 'super', '校级复核后应进入考试中心终审');
const finalApproved = await admin.request(`/api/admin/score-appeals/${appeal.result.id}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '考试中心终审完成' } });
const missingReviewedScore = await admin.request(`/api/admin/score-appeals/${appeal.result.id}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '遗漏复核分数' } });
assert.equal(missingReviewedScore.response.status, 400, '成绩复议终审批准时必须填写复核后分数');
const reviewedScore = appeal.result.subjectId === exam.subjects[0].id ? 128 : 89;
const finalApproved = await admin.request(`/api/admin/score-appeals/${appeal.result.id}`, { method: 'PATCH', body: { status: 'approved', reviewedScore, reviewNote: '考试中心终审完成' } });
assert.equal(finalApproved.data.workflow.status, 'approved', '成绩复议应可完整走完班级、学校和考试中心流程');
assert.equal(finalApproved.data.result.score, reviewedScore, '终审批准应在同一事务中更新复核后成绩');
assert.equal(finalApproved.data.result.grade, 'A+', '复议后等级应按当前同科排名重新判断');
if (appeal.result.subjectId === exam.subjects[2].id) assert.equal(finalApproved.data.result.qualified, true, '排名比例科目只判断复核后成绩所在当前排名区间');
}
const appealedResults = await candidate.request('/api/candidate/results');
assert.ok(appealedResults.data.results.filter(item => appealResults.some(result => result.id === item.id)).every(item => item.appeal?.status === 'approved'), '考生端应显示每科复议结果');
assert.equal(appealedResults.data.results.find(item => item.subjectId === exam.subjects[0].id).score, 128, '考生端应立即看到复议后的语文成绩');
assert.equal(appealedResults.data.results.find(item => item.subjectId === exam.subjects[2].id).score, 89, '考生端应立即看到复议后的外语成绩');
const workflowDefinitions = await admin.request('/api/admin/workflows');
const profileWorkflow = workflowDefinitions.data.workflows.find(item => item.businessType === 'profile_change');