准考证编排
This commit is contained in:
@@ -26,7 +26,7 @@ export function createAdminViews(context) {
|
||||
const meta = {
|
||||
dashboard: ['考务工作台', '掌握当前报名、审核和发布任务。'], candidates: ['考生资料审核', '核验考生实名、学籍与联系信息。'],
|
||||
registrations: ['考试报名审核', '确认考生所报考试、科目与缴费状态。'], exams: ['考试与科目', '创建考试、配置报名时间与考试科目。'],
|
||||
notices: ['通知发布', '发布后立即展示在公开首页和考生中心。'], admit: ['准考证生成', '为已审核报名分配考点、考场与座位。'],
|
||||
notices: ['通知发布', '发布后立即展示在公开首页和考生中心。'], admit: ['准考证编排', '按整场考试预检容量,并批量分配固定考点、分科考场与准考证号。'],
|
||||
results: [state.user.adminLevel === 'super' ? '成绩发布' : '成绩查看', state.user.adminLevel === 'super' ? '录入单科成绩并控制是否对考生公开。' : '按数据范围查看已录入成绩。'],
|
||||
admins: ['分级管理员', '同一级可以配置多名管理员,并分别绑定学校或班级。'],
|
||||
centers: ['考务场所档案', state.user.adminLevel === 'school' ? '查看本校考点与结构化考场,所有变更提交后进入审批。' : '管理各校考点、考场容量与变更审批台账。'],
|
||||
@@ -40,12 +40,12 @@ export function createAdminViews(context) {
|
||||
if (!meta[page] || !allowedPages.includes(page)) page = 'dashboard';
|
||||
app.innerHTML = portalShell('admin', page, loadingPanel(), ...meta[page]);
|
||||
try {
|
||||
const endpoint = page === 'admit' ? 'registrations' : page === 'flows' ? 'workflow-instances' : page === 'flow-design' ? 'workflows' : page === 'account-batches' ? 'candidate-account-batches' : page === 'organization' ? 'school-organization' : page;
|
||||
const endpoint = page === 'admit' ? 'admission-arrangements' : page === 'flows' ? 'workflow-instances' : page === 'flow-design' ? 'workflows' : page === 'account-batches' ? 'candidate-account-batches' : page === 'organization' ? 'school-organization' : page;
|
||||
const data = await api(`/api/admin/${endpoint}`);
|
||||
state.pageData = data;
|
||||
const content = {
|
||||
dashboard: () => adminDashboard(data), candidates: () => adminCandidates(data.candidates), registrations: () => adminRegistrations(data.registrations),
|
||||
exams: () => adminExams(data.exams), notices: () => adminNotices(data.notices), admit: () => adminAdmit(data.registrations), results: () => adminResults(data),
|
||||
exams: () => adminExams(data.exams), notices: () => adminNotices(data.notices), admit: () => adminAdmit(data), results: () => adminResults(data),
|
||||
admins: () => adminUsers(data), centers: () => adminCenters(data), flows: () => adminFlows(data), organization: () => adminSchoolOrganization(data), 'account-batches': () => adminAccountBatches(data),
|
||||
'flow-design': () => adminFlowDesign(data.workflows), 'number-rules': () => adminNumberRules(data)
|
||||
}[page]();
|
||||
@@ -87,9 +87,23 @@ export function createAdminViews(context) {
|
||||
return `<section class="panel data-panel"><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="noticeTable" placeholder="搜索通知标题或分类"></label><p>发布状态会实时同步到公开首页和考生中心。</p></div><div class="table-scroll"><table id="noticeTable"><thead><tr><th>通知标题</th><th>分类</th><th>作者</th><th>发布时间</th><th>展示</th><th>状态</th><th>操作</th></tr></thead><tbody>${notices.map(notice => `<tr data-status="${h(notice.status)}"><td><strong>${h(notice.title)}</strong><small>${h(notice.summary)}</small></td><td>${h(notice.category)}</td><td>${h(notice.author)}</td><td>${formatDate(notice.publishAt || notice.createdAt,true)}</td><td>${notice.pinned ? '<span class="pin-label">首页置顶</span>' : '普通'}</td><td>${badge(notice.status)}</td><td><button class="row-action" data-action="toggle-notice" data-id="${h(notice.id)}" data-status="${notice.status === 'published' ? 'draft' : 'published'}">${notice.status === 'published' ? '撤回' : '发布'}</button></td></tr>`).join('')}</tbody></table></div></section>`;
|
||||
}
|
||||
|
||||
function adminAdmit(registrations) {
|
||||
const approved = registrations.filter(reg => reg.status === 'approved');
|
||||
return `<section class="panel data-panel"><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="admitTable" placeholder="搜索考生、考试、准考证号"></label><p>仅已通过报名审核的考生可生成准考证。</p></div><div class="table-scroll"><table id="admitTable"><thead><tr><th>考生</th><th>考试</th><th>报考科目</th><th>准考证号</th><th>考点 / 考场</th><th>下载时间</th><th>操作</th></tr></thead><tbody>${approved.map(reg => `<tr><td><div class="person-cell"><span>${h((reg.candidate?.name || '?').slice(0,1))}</span><div><strong>${h(reg.candidate?.name)}</strong><small>${h(reg.candidate?.phone)}</small></div></div></td><td><strong>${h(reg.exam.name)}</strong><small>${h(reg.exam.code)}</small></td><td>${reg.subjects.map(subject => h(subject.name)).join('、')}</td><td class="mono">${h(reg.admitCard?.number || '尚未生成')}</td><td>${reg.admitCard ? `${h(reg.admitCard.testCenter)}<small>${h(reg.admitCard.room)} · ${h(reg.admitCard.seat)} 号</small>` : '—'}</td><td>${dateRange(reg.exam.admitDownloadStart, reg.exam.admitDownloadEnd)}</td><td><button class="row-action ${reg.admitCard ? '' : 'primary'}" data-action="generate-admit" data-id="${h(reg.id)}">${reg.admitCard ? '重新查看' : '生成准考证'}</button></td></tr>`).join('') || '<tr><td colspan="7" class="empty-state">暂无已通过的考试报名</td></tr>'}</tbody></table></div></section>`;
|
||||
function adminAdmit(data) {
|
||||
const approved = data.registrations || [];
|
||||
const selectedExam = data.exams.find(exam => exam.approvedCount > 0) || data.exams[0];
|
||||
const selectedPlan = selectedExam?.plan;
|
||||
const scopeName = code => data.mixingScopes.find(item => item.code === code)?.name || code;
|
||||
const form = selectedExam ? `<section class="panel arrangement-config"><div class="panel-title"><div><h2>整场编排控制台</h2><p>先预检容量与档案,再以一个事务生成或替换整套编排。</p></div><span>${data.centers.length} 个启用考点 · ${data.centers.reduce((sum, item) => sum + item.capacity, 0)} 个常规席位</span></div><form data-form="admission-arrangement"><div class="arrangement-fields"><label><span>考试 *</span><select name="examId" required>${data.exams.map(exam => `<option value="${h(exam.id)}" ${exam.id === selectedExam.id ? 'selected' : ''}>${h(exam.name)}(审核通过 ${exam.approvedCount} 人 / 已编排 ${exam.arrangedCount} 人)</option>`).join('')}</select></label><label><span>混编范围 *</span><select name="mixingScope" required>${data.mixingScopes.map(item => `<option value="${h(item.code)}" ${item.code === (selectedPlan?.mixingScope || 'school') ? 'selected' : ''}>${h(item.name)}</option>`).join('')}</select></label><label><span>准考证号规则 *</span><select name="numberRuleId" required>${data.rules.map(rule => `<option value="${h(rule.id)}" ${rule.id === selectedPlan?.numberRuleId ? 'selected' : ''}>${h(rule.name)}</option>`).join('')}</select></label><label><span>稳定编排种子</span><input name="seed" value="${h(selectedPlan?.randomSeed || selectedExam.code)}" maxlength="80"><small>相同数据与种子会得到相同顺序,便于复核。</small></label></div><div class="arrangement-actions"><button type="button" class="ghost-button" data-action="preview-arrangement">仅预检,不写入</button><button type="submit" class="solid-button">${selectedPlan ? '重新编排整场考试' : '生成整场编排'}</button></div></form><div data-arrangement-preview></div></section>` : emptyState('还没有可编排考试', '请先创建考试并完成报名审核。');
|
||||
const ruleCards = `<section class="arrangement-rules">${data.rules.map(rule => `<article><span>号码规则</span><h3>${h(rule.name)}</h3><p>${h(rule.description)}</p><code>${h(rule.example)}</code></article>`).join('')}</section>`;
|
||||
const planCards = data.plans.length ? `<section class="arrangement-plan-grid">${data.plans.map(plan => `<article class="panel"><header><div><span>${h(scopeName(plan.mixingScope))}</span><h3>${h(plan.examName)}</h3></div><time>${formatDate(plan.generatedAt, true)}</time></header><div><strong>${plan.candidateCount}</strong><small>名考生</small><strong>${plan.centerCount}</strong><small>个考点</small><strong>${plan.subjectCombinationCount}</strong><small>种科目组合</small></div><p>${h(plan.ruleName)} · 本校考点率 ${h(plan.sameSchoolCenterRate)}%</p>${plan.warnings.length ? `<ul>${plan.warnings.map(item => `<li>${h(item)}</li>`).join('')}</ul>` : '<p class="arrangement-ok">容量、档案与时间冲突检查通过</p>'}</article>`).join('')}</section>` : '';
|
||||
const rows = approved.map(reg => {
|
||||
const assignments = new Map((reg.admitCard?.assignments || []).map(item => [item.subjectId, item]));
|
||||
const subjects = reg.subjects.map(subject => {
|
||||
const assignment = assignments.get(subject.id);
|
||||
return `<span><b>${h(subject.name)}</b><small>${assignment ? `${h(assignment.roomName || assignment.room)} · ${h(assignment.seat)} 号` : '待编排'}</small></span>`;
|
||||
}).join('');
|
||||
return `<tr><td><div class="person-cell"><span>${h((reg.candidate?.name || '?').slice(0,1))}</span><div><strong>${h(reg.candidate?.name)}</strong><small>${h(reg.candidate?.school || '')} · ${h(reg.candidate?.grade || '')}</small></div></div></td><td><strong>${h(reg.exam.name)}</strong><small>${h(reg.exam.code)}</small></td><td class="mono">${h(reg.admitCard?.number || '待编排')}</td><td>${h(reg.admitCard?.testCenter || '—')}</td><td><div class="subject-room-list">${subjects}</div></td><td>${reg.admitCard ? `<button class="row-action" data-action="generate-admit" data-id="${h(reg.id)}">查看</button>` : '—'}</td></tr>`;
|
||||
}).join('');
|
||||
return `${form}${ruleCards}${planCards}<section class="panel data-panel"><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="admitTable" placeholder="搜索考生、考试、准考证号或考点"></label><p>同一考生所有科目固定在一个考点;考场和座位按科目分别展示。</p></div><div class="table-scroll"><table id="admitTable"><thead><tr><th>考生</th><th>考试</th><th>准考证号</th><th>固定考点</th><th>分科考场 / 座位</th><th>操作</th></tr></thead><tbody>${rows || '<tr><td colspan="6" class="empty-state">暂无已通过的考试报名</td></tr>'}</tbody></table></div></section>`;
|
||||
}
|
||||
|
||||
function adminResults(data) {
|
||||
|
||||
@@ -28,7 +28,7 @@ export function createCandidateViews(context) {
|
||||
if (level === 'class') return [core[0], core[1], core[2], ['flows', '流程中心', 'check'], core[3]];
|
||||
const operations = [['flows', '流程中心', 'check'], ['centers', '考场信息', 'exam']];
|
||||
if (level === 'school') return [core[0], ['organization', '本校组织', 'users'], ['account-batches', '批量建号', 'ticket'], core[1], core[2], ...operations, core[3]];
|
||||
return [core[0], ['admins', '管理员', 'users'], core[1], core[2], ['flows', '流程监督', 'check'], ['flow-design', '流程设计', 'exam'], ['number-rules', '报名号规则', 'ticket'], ['centers', '考场信息', 'exam'], ['exams', '考试与科目', 'exam'], ['notices', '通知发布', 'bell'], ['admit', '准考证生成', 'ticket'], core[3]];
|
||||
return [core[0], ['admins', '管理员', 'users'], core[1], core[2], ['flows', '流程监督', 'check'], ['flow-design', '流程设计', 'exam'], ['number-rules', '报名号规则', 'ticket'], ['centers', '考场信息', 'exam'], ['exams', '考试与科目', 'exam'], ['notices', '通知发布', 'bell'], ['admit', '准考证编排', 'ticket'], core[3]];
|
||||
}
|
||||
|
||||
function portalShell(role, page, content, title, description) {
|
||||
@@ -131,7 +131,16 @@ export function createCandidateViews(context) {
|
||||
|
||||
function candidateAdmit(registrations) {
|
||||
const cards = registrations.filter(reg => reg.admitCard);
|
||||
return cards.length ? `<div class="admit-list">${cards.map(reg => { const now = Date.now(); const open = now >= new Date(reg.exam.admitDownloadStart).getTime() && now <= new Date(reg.exam.admitDownloadEnd).getTime(); return `<article class="admit-ticket"><div class="admit-main"><header><span>${h(reg.exam.code)}</span>${badge(open ? 'open' : now < new Date(reg.exam.admitDownloadStart) ? 'upcoming' : 'closed')}</header><h2>${h(reg.exam.name)}</h2><div class="admit-number"><small>准考证号</small><strong>${h(reg.admitCard.number)}</strong></div><dl><div><dt>考点</dt><dd>${h(reg.admitCard.testCenter)}</dd></div><div><dt>考场 / 座位</dt><dd>${h(reg.admitCard.room)} / ${h(reg.admitCard.seat)}</dd></div><div><dt>下载时间</dt><dd>${dateRange(reg.exam.admitDownloadStart, reg.exam.admitDownloadEnd)}</dd></div></dl></div><div class="admit-stub"><span>ADMISSION<br>CARD</span><i></i><button class="solid-button" data-action="download-admit" data-id="${h(reg.id)}" ${open ? '' : 'disabled'}>${open ? '下载准考证' : now < new Date(reg.exam.admitDownloadStart) ? '尚未开放' : '下载已结束'}</button><small>下载后请使用 A4 纸打印</small></div></article>`; }).join('')}</div>` : emptyState('准考证尚未生成', '考试报名审核通过后,由管理员统一生成准考证。', 'candidate/registrations', '查看报名状态');
|
||||
return cards.length ? `<div class="admit-list">${cards.map(reg => {
|
||||
const now = Date.now();
|
||||
const open = now >= new Date(reg.exam.admitDownloadStart).getTime() && now <= new Date(reg.exam.admitDownloadEnd).getTime();
|
||||
const assignments = new Map((reg.admitCard.assignments || []).map(item => [item.subjectId, item]));
|
||||
const subjectRows = reg.subjects.map(subject => {
|
||||
const assignment = assignments.get(subject.id) || {};
|
||||
return `<span><b>${h(subject.name)}</b><small>${h(subject.date)} ${h(subject.start)} · ${h(assignment.roomName || assignment.room || '待定')} · ${h(assignment.seat || '—')} 号</small></span>`;
|
||||
}).join('');
|
||||
return `<article class="admit-ticket"><div class="admit-main"><header><span>${h(reg.exam.code)}</span>${badge(open ? 'open' : now < new Date(reg.exam.admitDownloadStart) ? 'upcoming' : 'closed')}</header><h2>${h(reg.exam.name)}</h2><div class="admit-number"><small>准考证号</small><strong>${h(reg.admitCard.number)}</strong></div><dl><div><dt>固定考点</dt><dd>${h(reg.admitCard.testCenter)}</dd></div><div><dt>逐科安排</dt><dd class="admit-subject-rooms">${subjectRows}</dd></div><div><dt>下载时间</dt><dd>${dateRange(reg.exam.admitDownloadStart, reg.exam.admitDownloadEnd)}</dd></div></dl></div><div class="admit-stub"><span>ADMISSION<br>CARD</span><i></i><button class="solid-button" data-action="download-admit" data-id="${h(reg.id)}" ${open ? '' : 'disabled'}>${open ? '下载准考证' : now < new Date(reg.exam.admitDownloadStart) ? '尚未开放' : '下载已结束'}</button><small>下载后请使用 A4 纸打印</small></div></article>`;
|
||||
}).join('')}</div>` : emptyState('准考证尚未生成', '考试报名审核通过后,由管理员统一编排准考证。', 'candidate/registrations', '查看报名状态');
|
||||
}
|
||||
|
||||
function candidateResults(data) {
|
||||
|
||||
+56
-2
@@ -6,7 +6,7 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
||||
const examId = 'exam_autumn_2026';
|
||||
const registrationId = 'reg_demo_2026';
|
||||
return {
|
||||
meta: { version: 8, createdAt: nowIso() },
|
||||
meta: { version: 9, createdAt: nowIso() },
|
||||
settings: { selfRegistrationEnabled: false },
|
||||
organization: {
|
||||
name: '海州市教育考试中心',
|
||||
@@ -76,7 +76,16 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
||||
{
|
||||
id: registrationId, userId: candidateId, examId, subjectIds: ['sub_chinese', 'sub_math', 'sub_physics', 'sub_english', 'sub_chemistry'],
|
||||
status: 'approved', paymentStatus: 'paid', createdAt: '2026-07-08T05:18:00.000Z', reviewedAt: '2026-07-18T08:32:00.000Z', registrationNumber: '2026-HZ01-F-0001', numberRuleId: 'rule_default',
|
||||
admitCard: { number: '260816-031-08', testCenter: '海州市第三中学', room: '031 考场', seat: '08', generatedAt: '2026-07-19T02:00:00.000Z' }
|
||||
admitCard: {
|
||||
planId: 'arrangement_demo_2026', number: '32070600108', centerId: 'center_hz3', testCenter: '海州市第三中学考点',
|
||||
room: '第 001 考场', seat: '08', generatedAt: '2026-07-19T02:00:00.000Z', assignments: [
|
||||
{ subjectId: 'sub_chinese', roomId: 'room_hz3_001', roomName: '第 001 考场', roomCode: '001', examRoomCode: '001', seat: '08', subjectSignature: 'sub_chinese|sub_math|sub_physics|sub_english|sub_chemistry' },
|
||||
{ subjectId: 'sub_math', roomId: 'room_hz3_001', roomName: '第 001 考场', roomCode: '001', examRoomCode: '003', seat: '08', subjectSignature: 'sub_chinese|sub_math|sub_physics|sub_english|sub_chemistry' },
|
||||
{ subjectId: 'sub_physics', roomId: 'room_hz3_002', roomName: '第 002 考场', roomCode: '002', examRoomCode: '006', seat: '08', subjectSignature: 'sub_chinese|sub_math|sub_physics|sub_english|sub_chemistry' },
|
||||
{ subjectId: 'sub_english', roomId: 'room_hz3_001', roomName: '第 001 考场', roomCode: '001', examRoomCode: '009', seat: '08', subjectSignature: 'sub_chinese|sub_math|sub_physics|sub_english|sub_chemistry' },
|
||||
{ subjectId: 'sub_chemistry', roomId: 'room_hz3_002', roomName: '第 002 考场', roomCode: '002', examRoomCode: '011', seat: '08', subjectSignature: 'sub_chinese|sub_math|sub_physics|sub_english|sub_chemistry' }
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
results: [
|
||||
@@ -96,6 +105,51 @@ export function createSeedDatabase({ nowIso, hashPassword }) {
|
||||
],
|
||||
centerChangeRequests: [],
|
||||
centerChangeRooms: [],
|
||||
admissionNumberRules: [
|
||||
{
|
||||
id: 'admit_rule_district_room_seat', code: 'district_room_seat', name: '县区编号 + 考场号 + 座位号',
|
||||
description: '适合县区统一组织,号码直接反映县区、考试考场与座位。', separator: '', example: '32070603108', active: true, createdAt: nowIso(),
|
||||
segments: [
|
||||
{ source: 'district_code', label: '县区编号', width: 6 },
|
||||
{ source: 'exam_room_code', label: '考场号', width: 3 },
|
||||
{ source: 'seat', label: '座位号', width: 2 }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'admit_rule_district_room_sequence', code: 'district_room_sequence', name: '县区号 + 考场号 + 流水号',
|
||||
description: '以县区为流水边界,适合不希望座位号直接出现在号码中的场景。', separator: '', example: '3207060310028', active: true, createdAt: nowIso(),
|
||||
segments: [
|
||||
{ source: 'district_code', label: '县区号', width: 6 },
|
||||
{ source: 'exam_room_code', label: '考场号', width: 3 },
|
||||
{ source: 'sequence', label: '流水号', width: 4 }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'admit_rule_center_school_room_seat', code: 'center_school_room_seat', name: '考点学校代码 + 考场号 + 座位号',
|
||||
description: '号码前缀取考点所属学校代码,便于考点现场快速识别。', separator: '', example: 'HZ0303108', active: true, createdAt: nowIso(),
|
||||
segments: [
|
||||
{ source: 'center_school_code', label: '考点学校代码' },
|
||||
{ source: 'exam_room_code', label: '考场号', width: 3 },
|
||||
{ source: 'seat', label: '座位号', width: 2 }
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 'admit_rule_candidate_school_room_seat', code: 'candidate_school_room_seat', name: '考生学校代码 + 考场号 + 座位号',
|
||||
description: '号码前缀保留考生学籍学校代码,适合按生源学校归档。', separator: '', example: 'HZ0103108', active: true, createdAt: nowIso(),
|
||||
segments: [
|
||||
{ source: 'candidate_school_code', label: '考生学校代码' },
|
||||
{ source: 'exam_room_code', label: '考场号', width: 3 },
|
||||
{ source: 'seat', label: '座位号', width: 2 }
|
||||
]
|
||||
}
|
||||
],
|
||||
arrangementPlans: [
|
||||
{
|
||||
id: 'arrangement_demo_2026', examId, numberRuleId: 'admit_rule_district_room_seat', mixingScope: 'city', randomSeed: 'EX-2026-AUT',
|
||||
candidateCount: 1, centerCount: 1, subjectAssignmentCount: 5, subjectCombinationCount: 1, sameSchoolCenterRate: 0,
|
||||
warnings: [], generatedBy: adminId, generatedAt: '2026-07-19T02:00:00.000Z'
|
||||
}
|
||||
],
|
||||
candidateAccountBatches: [],
|
||||
candidateAccountBatchItems: [],
|
||||
numberRules: [
|
||||
|
||||
@@ -95,7 +95,30 @@ export function createMysqlAdapter(context) {
|
||||
if (legacyRegistrationNumberIndexes.length) await pool.execute('ALTER TABLE registrations DROP INDEX uq_registrations_number');
|
||||
const [existing] = await pool.execute('SELECT id FROM schema_metadata WHERE id = 1');
|
||||
if (existing.length) {
|
||||
const [metadataRows] = await pool.execute('SELECT app_version FROM schema_metadata WHERE id = 1');
|
||||
const [metadataRows] = await pool.execute('SELECT app_version, schema_version FROM schema_metadata WHERE id = 1');
|
||||
if (Number(metadataRows[0]?.schema_version || 1) < 9) {
|
||||
await pool.execute('DROP TABLE IF EXISTS admit_card_subjects');
|
||||
await pool.execute('DROP TABLE IF EXISTS admit_cards');
|
||||
await pool.execute('DROP TABLE IF EXISTS exam_arrangement_plans');
|
||||
await pool.execute('DROP TABLE IF EXISTS admission_number_rules');
|
||||
const admissionTables = ['admission_number_rules', 'exam_arrangement_plans', 'admit_cards', 'admit_card_subjects'];
|
||||
for (const table of admissionTables) {
|
||||
const statement = mysqlSchema.find(item => item.includes(`CREATE TABLE IF NOT EXISTS ${table} (`));
|
||||
if (!statement) throw new Error(`缺少 ${table} 的 MySQL 表定义`);
|
||||
await pool.execute(statement);
|
||||
}
|
||||
const extension = seed();
|
||||
for (const rule of extension.admissionNumberRules) await pool.execute(
|
||||
`INSERT INTO admission_number_rules (
|
||||
id, code, name, description, separator, segments_json, example, active, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[rule.id, rule.code, rule.name, rule.description, rule.separator || '', JSON.stringify(rule.segments || []),
|
||||
rule.example || '', rule.active === false ? 0 : 1, rule.createdAt]
|
||||
);
|
||||
await pool.execute('UPDATE schema_metadata SET schema_version = 9, app_version = 9 WHERE id = 1');
|
||||
metadataRows[0].schema_version = 9;
|
||||
metadataRows[0].app_version = 9;
|
||||
}
|
||||
if (Number(metadataRows[0]?.app_version || 1) < 2) {
|
||||
const extension = seed();
|
||||
const connection = await pool.getConnection();
|
||||
@@ -275,7 +298,7 @@ export function createMysqlAdapter(context) {
|
||||
await connection.beginTransaction();
|
||||
const [insert] = await connection.execute(`
|
||||
INSERT IGNORE INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at)
|
||||
VALUES (1, 7, ?, ?, ?)
|
||||
VALUES (1, 9, ?, ?, ?)
|
||||
`, [Number(initialState.meta?.version || 1), initialState.settings?.selfRegistrationEnabled ? 1 : 0, initialState.meta?.createdAt || new Date().toISOString()]);
|
||||
if (insert.affectedRows === 1) {
|
||||
for (const item of buildSeedOperations(initialState)) await connection.execute(item.sql, item.params);
|
||||
|
||||
+107
-13
@@ -149,15 +149,56 @@ export const sqliteSchema = `
|
||||
PRIMARY KEY (registration_id, subject_id)
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS admission_number_rules (
|
||||
id TEXT PRIMARY KEY,
|
||||
code TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT NOT NULL,
|
||||
separator TEXT NOT NULL DEFAULT '',
|
||||
segments_json TEXT NOT NULL,
|
||||
example TEXT NOT NULL,
|
||||
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
|
||||
created_at TEXT NOT NULL
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS exam_arrangement_plans (
|
||||
id TEXT PRIMARY KEY,
|
||||
exam_id TEXT NOT NULL UNIQUE REFERENCES exams(id) ON DELETE CASCADE,
|
||||
number_rule_id TEXT NOT NULL REFERENCES admission_number_rules(id),
|
||||
mixing_scope TEXT NOT NULL CHECK (mixing_scope IN ('class', 'school', 'district', 'city', 'province')),
|
||||
random_seed TEXT NOT NULL,
|
||||
candidate_count INTEGER NOT NULL CHECK (candidate_count >= 0),
|
||||
center_count INTEGER NOT NULL CHECK (center_count >= 0),
|
||||
subject_assignment_count INTEGER NOT NULL CHECK (subject_assignment_count >= 0),
|
||||
subject_combination_count INTEGER NOT NULL CHECK (subject_combination_count >= 0),
|
||||
same_school_center_rate REAL NOT NULL,
|
||||
warnings_json TEXT NOT NULL,
|
||||
generated_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||||
generated_at TEXT NOT NULL
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS admit_cards (
|
||||
registration_id TEXT PRIMARY KEY REFERENCES registrations(id) ON DELETE CASCADE,
|
||||
plan_id TEXT NOT NULL REFERENCES exam_arrangement_plans(id) ON DELETE CASCADE,
|
||||
card_number TEXT NOT NULL UNIQUE,
|
||||
center_id TEXT REFERENCES test_centers(id) ON DELETE SET NULL,
|
||||
test_center TEXT NOT NULL,
|
||||
room TEXT NOT NULL,
|
||||
seat TEXT NOT NULL,
|
||||
generated_at TEXT NOT NULL
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS admit_card_subjects (
|
||||
registration_id TEXT NOT NULL REFERENCES admit_cards(registration_id) ON DELETE CASCADE,
|
||||
subject_id TEXT NOT NULL REFERENCES exam_subjects(id) ON DELETE CASCADE,
|
||||
room_id TEXT REFERENCES test_rooms(id) ON DELETE SET NULL,
|
||||
room TEXT NOT NULL,
|
||||
room_code TEXT NOT NULL,
|
||||
exam_room_code TEXT NOT NULL,
|
||||
seat TEXT NOT NULL,
|
||||
subject_signature TEXT NOT NULL,
|
||||
PRIMARY KEY (registration_id, subject_id),
|
||||
UNIQUE (subject_id, room_id, seat)
|
||||
) STRICT;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS results (
|
||||
id TEXT PRIMARY KEY,
|
||||
registration_id TEXT NOT NULL REFERENCES registrations(id) ON DELETE CASCADE,
|
||||
@@ -357,6 +398,8 @@ export const sqliteSchema = `
|
||||
CREATE INDEX IF NOT EXISTS idx_subjects_exam ON exam_subjects(exam_id, position);
|
||||
CREATE INDEX IF NOT EXISTS idx_registrations_status ON registrations(status);
|
||||
CREATE INDEX IF NOT EXISTS idx_registrations_exam ON registrations(exam_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_arrangement_plans_exam ON exam_arrangement_plans(exam_id, generated_at);
|
||||
CREATE INDEX IF NOT EXISTS idx_admit_subjects_room ON admit_card_subjects(subject_id, room_id, seat);
|
||||
CREATE INDEX IF NOT EXISTS idx_workflow_inbox ON workflow_instances(status, assignee_id, business_type);
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_test_centers_code ON test_centers(code);
|
||||
CREATE INDEX IF NOT EXISTS idx_rooms_center ON test_rooms(center_id, status, code);
|
||||
@@ -543,17 +586,6 @@ export const mysqlSchema = [
|
||||
CONSTRAINT fk_registration_subjects_registration FOREIGN KEY (registration_id) REFERENCES registrations(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_registration_subjects_subject FOREIGN KEY (subject_id) REFERENCES exam_subjects(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS admit_cards (
|
||||
registration_id VARCHAR(64) NOT NULL,
|
||||
card_number VARCHAR(100) NOT NULL,
|
||||
test_center VARCHAR(200) NOT NULL,
|
||||
room VARCHAR(100) NOT NULL,
|
||||
seat VARCHAR(30) NOT NULL,
|
||||
generated_at VARCHAR(35) NOT NULL,
|
||||
PRIMARY KEY (registration_id),
|
||||
UNIQUE KEY uq_admit_cards_number (card_number),
|
||||
CONSTRAINT fk_admit_cards_registration FOREIGN KEY (registration_id) REFERENCES registrations(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS results (
|
||||
id VARCHAR(64) NOT NULL,
|
||||
registration_id VARCHAR(64) NOT NULL,
|
||||
@@ -616,6 +648,68 @@ export const mysqlSchema = [
|
||||
KEY idx_rooms_center (center_id, status, code),
|
||||
CONSTRAINT fk_rooms_center FOREIGN KEY (center_id) REFERENCES test_centers(id) ON DELETE CASCADE
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS admission_number_rules (
|
||||
id VARCHAR(64) NOT NULL,
|
||||
code VARCHAR(80) NOT NULL,
|
||||
name VARCHAR(160) NOT NULL,
|
||||
description VARCHAR(500) NOT NULL,
|
||||
separator VARCHAR(10) NOT NULL DEFAULT '',
|
||||
segments_json JSON NOT NULL,
|
||||
example VARCHAR(120) NOT NULL,
|
||||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at VARCHAR(35) NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_admission_rules_code (code)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS exam_arrangement_plans (
|
||||
id VARCHAR(64) NOT NULL,
|
||||
exam_id VARCHAR(64) NOT NULL,
|
||||
number_rule_id VARCHAR(64) NOT NULL,
|
||||
mixing_scope ENUM('class', 'school', 'district', 'city', 'province') NOT NULL,
|
||||
random_seed VARCHAR(80) NOT NULL,
|
||||
candidate_count INT UNSIGNED NOT NULL,
|
||||
center_count INT UNSIGNED NOT NULL,
|
||||
subject_assignment_count INT UNSIGNED NOT NULL,
|
||||
subject_combination_count INT UNSIGNED NOT NULL,
|
||||
same_school_center_rate DOUBLE NOT NULL,
|
||||
warnings_json JSON NOT NULL,
|
||||
generated_by VARCHAR(64) NULL,
|
||||
generated_at VARCHAR(35) NOT NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uq_arrangement_plans_exam (exam_id),
|
||||
CONSTRAINT fk_arrangement_plans_exam FOREIGN KEY (exam_id) REFERENCES exams(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_arrangement_plans_rule FOREIGN KEY (number_rule_id) REFERENCES admission_number_rules(id),
|
||||
CONSTRAINT fk_arrangement_plans_generator FOREIGN KEY (generated_by) REFERENCES users(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS admit_cards (
|
||||
registration_id VARCHAR(64) NOT NULL,
|
||||
plan_id VARCHAR(64) NOT NULL,
|
||||
card_number VARCHAR(100) NOT NULL,
|
||||
center_id VARCHAR(64) NULL,
|
||||
test_center VARCHAR(200) NOT NULL,
|
||||
generated_at VARCHAR(35) NOT NULL,
|
||||
PRIMARY KEY (registration_id),
|
||||
UNIQUE KEY uq_admit_cards_number (card_number),
|
||||
CONSTRAINT fk_admit_cards_registration FOREIGN KEY (registration_id) REFERENCES registrations(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_admit_cards_plan FOREIGN KEY (plan_id) REFERENCES exam_arrangement_plans(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_admit_cards_center FOREIGN KEY (center_id) REFERENCES test_centers(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS admit_card_subjects (
|
||||
registration_id VARCHAR(64) NOT NULL,
|
||||
subject_id VARCHAR(64) NOT NULL,
|
||||
room_id VARCHAR(64) NULL,
|
||||
room VARCHAR(120) NOT NULL,
|
||||
room_code VARCHAR(40) NOT NULL,
|
||||
exam_room_code VARCHAR(20) NOT NULL,
|
||||
seat VARCHAR(30) NOT NULL,
|
||||
subject_signature VARCHAR(1000) NOT NULL,
|
||||
PRIMARY KEY (registration_id, subject_id),
|
||||
UNIQUE KEY uq_admit_subject_room_seat (subject_id, room_id, seat),
|
||||
KEY idx_admit_subjects_room (subject_id, room_id, seat),
|
||||
CONSTRAINT fk_admit_subjects_card FOREIGN KEY (registration_id) REFERENCES admit_cards(registration_id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_admit_subjects_subject FOREIGN KEY (subject_id) REFERENCES exam_subjects(id) ON DELETE CASCADE,
|
||||
CONSTRAINT fk_admit_subjects_room FOREIGN KEY (room_id) REFERENCES test_rooms(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||||
`CREATE TABLE IF NOT EXISTS center_change_requests (
|
||||
id VARCHAR(64) NOT NULL,
|
||||
center_id VARCHAR(64) NULL,
|
||||
|
||||
@@ -114,6 +114,64 @@ export function createSqliteAdapter(context) {
|
||||
connection.exec('CREATE UNIQUE INDEX IF NOT EXISTS uq_users_candidate_number ON users(candidate_number)');
|
||||
|
||||
const existingSystem = connection.prepare('SELECT * FROM schema_metadata WHERE id = 1').get();
|
||||
if (existingSystem && Number(existingSystem.schema_version || 1) < 9) {
|
||||
const extension = seed();
|
||||
connection.exec('PRAGMA foreign_keys = OFF;');
|
||||
connection.exec('BEGIN IMMEDIATE');
|
||||
try {
|
||||
connection.exec(`
|
||||
DROP TABLE IF EXISTS admit_card_subjects;
|
||||
DROP TABLE IF EXISTS admit_cards;
|
||||
DROP TABLE IF EXISTS exam_arrangement_plans;
|
||||
DROP TABLE IF EXISTS admission_number_rules;
|
||||
CREATE TABLE admission_number_rules (
|
||||
id TEXT PRIMARY KEY, code TEXT NOT NULL UNIQUE, name TEXT NOT NULL, description TEXT NOT NULL,
|
||||
separator TEXT NOT NULL DEFAULT '', segments_json TEXT NOT NULL, example TEXT NOT NULL,
|
||||
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)), created_at TEXT NOT NULL
|
||||
) STRICT;
|
||||
CREATE TABLE exam_arrangement_plans (
|
||||
id TEXT PRIMARY KEY, exam_id TEXT NOT NULL UNIQUE REFERENCES exams(id) ON DELETE CASCADE,
|
||||
number_rule_id TEXT NOT NULL REFERENCES admission_number_rules(id),
|
||||
mixing_scope TEXT NOT NULL CHECK (mixing_scope IN ('class', 'school', 'district', 'city', 'province')),
|
||||
random_seed TEXT NOT NULL, candidate_count INTEGER NOT NULL CHECK (candidate_count >= 0),
|
||||
center_count INTEGER NOT NULL CHECK (center_count >= 0),
|
||||
subject_assignment_count INTEGER NOT NULL CHECK (subject_assignment_count >= 0),
|
||||
subject_combination_count INTEGER NOT NULL CHECK (subject_combination_count >= 0),
|
||||
same_school_center_rate REAL NOT NULL, warnings_json TEXT NOT NULL,
|
||||
generated_by TEXT REFERENCES users(id) ON DELETE SET NULL, generated_at TEXT NOT NULL
|
||||
) STRICT;
|
||||
CREATE TABLE admit_cards (
|
||||
registration_id TEXT PRIMARY KEY REFERENCES registrations(id) ON DELETE CASCADE,
|
||||
plan_id TEXT NOT NULL REFERENCES exam_arrangement_plans(id) ON DELETE CASCADE,
|
||||
card_number TEXT NOT NULL UNIQUE, center_id TEXT REFERENCES test_centers(id) ON DELETE SET NULL,
|
||||
test_center TEXT NOT NULL, generated_at TEXT NOT NULL
|
||||
) STRICT;
|
||||
CREATE TABLE admit_card_subjects (
|
||||
registration_id TEXT NOT NULL REFERENCES admit_cards(registration_id) ON DELETE CASCADE,
|
||||
subject_id TEXT NOT NULL REFERENCES exam_subjects(id) ON DELETE CASCADE,
|
||||
room_id TEXT REFERENCES test_rooms(id) ON DELETE SET NULL, room TEXT NOT NULL, room_code TEXT NOT NULL,
|
||||
exam_room_code TEXT NOT NULL, seat TEXT NOT NULL, subject_signature TEXT NOT NULL,
|
||||
PRIMARY KEY (registration_id, subject_id), UNIQUE (subject_id, room_id, seat)
|
||||
) STRICT;
|
||||
CREATE INDEX idx_arrangement_plans_exam ON exam_arrangement_plans(exam_id, generated_at);
|
||||
CREATE INDEX idx_admit_subjects_room ON admit_card_subjects(subject_id, room_id, seat);
|
||||
`);
|
||||
for (const rule of extension.admissionNumberRules) connection.prepare(
|
||||
`INSERT INTO admission_number_rules (
|
||||
id, code, name, description, separator, segments_json, example, active, created_at
|
||||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(rule.id, rule.code, rule.name, rule.description, rule.separator || '', JSON.stringify(rule.segments || []),
|
||||
rule.example || '', rule.active === false ? 0 : 1, rule.createdAt);
|
||||
connection.prepare('UPDATE schema_metadata SET schema_version = 9, app_version = 9 WHERE id = 1').run();
|
||||
connection.exec('COMMIT');
|
||||
} catch (error) {
|
||||
connection.exec('ROLLBACK');
|
||||
connection.close();
|
||||
throw error;
|
||||
} finally {
|
||||
try { connection.exec('PRAGMA foreign_keys = ON;'); } catch {}
|
||||
}
|
||||
}
|
||||
if (existingSystem && Number(existingSystem.app_version || 1) < 2) {
|
||||
const extension = seed();
|
||||
connection.exec('BEGIN IMMEDIATE');
|
||||
@@ -271,7 +329,7 @@ export function createSqliteAdapter(context) {
|
||||
try {
|
||||
connection.prepare(`
|
||||
INSERT INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at)
|
||||
VALUES (1, 7, ?, ?, ?)
|
||||
VALUES (1, 9, ?, ?, ?)
|
||||
`).run(Number(initialState.meta?.version || 1), initialState.settings?.selfRegistrationEnabled ? 1 : 0, initialState.meta?.createdAt || new Date().toISOString());
|
||||
for (const item of buildSeedOperations(initialState)) connection.prepare(item.sql).run(...item.params);
|
||||
connection.exec('COMMIT');
|
||||
|
||||
+72
-21
@@ -1,3 +1,5 @@
|
||||
import { admissionMixingScopes, buildAdmissionArrangement } from '../services/admission-arrangement.mjs';
|
||||
|
||||
export function createAdminRoutes(context) {
|
||||
const {
|
||||
database,
|
||||
@@ -65,7 +67,7 @@ export function createAdminRoutes(context) {
|
||||
date: cleanText(structured ? item.date : '', 10) || String(examStart).slice(0, 10),
|
||||
start: cleanText(structured ? item.start : '', 5) || '09:00',
|
||||
end: cleanText(structured ? item.end : '', 5) || '11:00',
|
||||
fee: Number(structured ? item.fee : 0),
|
||||
fee: Number(structured ? item.fee ?? 0 : 0),
|
||||
fullScore,
|
||||
passScore: Number(structured ? item.passScore : fullScore * .6),
|
||||
order: index + 1
|
||||
@@ -590,6 +592,38 @@ export function createAdminRoutes(context) {
|
||||
});
|
||||
return sendJson(response, 200, { ok: true, registrations });
|
||||
}
|
||||
if (request.method === 'GET' && pathname === '/api/admin/admission-arrangements') {
|
||||
if (!requirePermission(user, response, '*')) return true;
|
||||
const registrations = db.registrations.filter(item => item.status === 'approved').map(registration => {
|
||||
const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
|
||||
return { ...examRegistrationView(db, registration), candidate: profile ? { ...profile, idNumber: maskId(profile.idNumber) } : null };
|
||||
});
|
||||
const plans = db.arrangementPlans.map(plan => ({
|
||||
...plan,
|
||||
examName: db.exams.find(item => item.id === plan.examId)?.name || '',
|
||||
ruleName: db.admissionNumberRules.find(item => item.id === plan.numberRuleId)?.name || '',
|
||||
mixingScopeName: admissionMixingScopes.find(item => item.code === plan.mixingScope)?.name || plan.mixingScope
|
||||
}));
|
||||
const exams = db.exams.map(exam => ({
|
||||
...publicExam(exam),
|
||||
approvedCount: db.registrations.filter(item => item.examId === exam.id && item.status === 'approved').length,
|
||||
arrangedCount: db.registrations.filter(item => item.examId === exam.id && item.admitCard).length,
|
||||
plan: plans.find(item => item.examId === exam.id) || null
|
||||
}));
|
||||
return sendJson(response, 200, {
|
||||
ok: true,
|
||||
exams,
|
||||
registrations,
|
||||
plans,
|
||||
rules: db.admissionNumberRules.filter(item => item.active),
|
||||
mixingScopes: admissionMixingScopes,
|
||||
centers: db.testCenters.filter(item => item.status === 'active').map(center => ({
|
||||
...center,
|
||||
roomCount: db.testRooms.filter(room => room.centerId === center.id && room.status === 'active').length,
|
||||
capacity: db.testRooms.filter(room => room.centerId === center.id && room.status === 'active' && room.roomType !== 'spare').reduce((sum, room) => sum + room.capacity, 0)
|
||||
}))
|
||||
});
|
||||
}
|
||||
const registrationMatch = pathname.match(/^\/api\/admin\/registrations\/([^/]+)$/);
|
||||
if (request.method === 'PATCH' && registrationMatch) {
|
||||
if (!requirePermission(user, response, 'registrations.review')) return true;
|
||||
@@ -627,7 +661,6 @@ export function createAdminRoutes(context) {
|
||||
await database.processWorkflow(instance, action, registration, log);
|
||||
return sendJson(response, 200, { ok: true, registration, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance) });
|
||||
}
|
||||
const admitMatch = pathname.match(/^\/api\/admin\/registrations\/([^/]+)\/admit-card$/);
|
||||
const scoreAppealMatch = pathname.match(/^\/api\/admin\/score-appeals\/([^/]+)$/);
|
||||
if (request.method === 'PATCH' && scoreAppealMatch) {
|
||||
if (!requirePermission(user, response, 'workflows.inbox')) return true;
|
||||
@@ -661,26 +694,44 @@ export function createAdminRoutes(context) {
|
||||
await database.processWorkflow(instance, action, null, log);
|
||||
return sendJson(response, 200, { ok: true, workflow: workflowView({ ...db, workflowActions: [...db.workflowActions, action] }, instance) });
|
||||
}
|
||||
if (request.method === 'POST' && admitMatch) {
|
||||
const arrangementMatch = pathname.match(/^\/api\/admin\/exams\/([^/]+)\/admission-arrangement(\/preview)?$/);
|
||||
if (request.method === 'POST' && arrangementMatch) {
|
||||
if (!requirePermission(user, response, '*')) return true;
|
||||
const registration = db.registrations.find(item => item.id === admitMatch[1]);
|
||||
if (!registration) return sendError(response, 404, '报名记录不存在');
|
||||
if (registration.status !== 'approved') return sendError(response, 400, '报名审核通过后才能生成准考证');
|
||||
if (!registration.admitCard) {
|
||||
const exam = db.exams.find(item => item.id === registration.examId);
|
||||
const sequence = String(db.registrations.filter(item => item.examId === exam.id && item.admitCard).length + 1).padStart(4, '0');
|
||||
registration.admitCard = {
|
||||
number: `${exam.code.replace(/[^a-z0-9]/gi, '').toUpperCase().slice(-12) || String(new Date().getFullYear())}-${sequence}`,
|
||||
testCenter: cleanText((await readJson(request)).testCenter || '海州市第一中学', 80),
|
||||
room: `0${Math.ceil(Number(sequence) / 30) || 1} 考场`,
|
||||
seat: String(((Number(sequence) - 1) % 30) + 1).padStart(2, '0'),
|
||||
generatedAt: nowIso()
|
||||
};
|
||||
const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
|
||||
const log = logAction(db, user, '生成准考证', `${profile?.name || registration.userId} · ${registration.admitCard.number}`);
|
||||
await database.createAdmitCard(registration.id, registration.admitCard, log);
|
||||
}
|
||||
return sendJson(response, 200, { ok: true, admitCard: registration.admitCard });
|
||||
const body = await readJson(request);
|
||||
const generatedAt = nowIso();
|
||||
const result = buildAdmissionArrangement(db, {
|
||||
examId: arrangementMatch[1],
|
||||
mixingScope: cleanText(body.mixingScope, 20),
|
||||
numberRuleId: cleanText(body.numberRuleId, 64),
|
||||
seed: cleanText(body.seed, 80),
|
||||
generatedAt
|
||||
});
|
||||
if (arrangementMatch[2]) return sendJson(response, 200, {
|
||||
ok: true,
|
||||
preview: true,
|
||||
summary: result.summary,
|
||||
warnings: result.warnings,
|
||||
samples: result.cards.slice(0, 5)
|
||||
});
|
||||
const plan = {
|
||||
id: uid('arrangement'),
|
||||
examId: result.exam.id,
|
||||
numberRuleId: result.rule.id,
|
||||
mixingScope: result.mixingScope,
|
||||
randomSeed: result.seed,
|
||||
...result.summary,
|
||||
warnings: result.warnings,
|
||||
generatedBy: user.id,
|
||||
generatedAt
|
||||
};
|
||||
const log = logAction(db, user, db.arrangementPlans.some(item => item.examId === result.exam.id) ? '重新编排准考证' : '批量编排准考证',
|
||||
`${result.exam.name} · ${plan.candidateCount} 人 · ${plan.centerCount} 个考点 · ${result.rule.name}`);
|
||||
await database.saveAdmissionArrangement(plan, result.cards, log);
|
||||
return sendJson(response, 200, { ok: true, plan, summary: result.summary, warnings: result.warnings, cards: result.cards });
|
||||
}
|
||||
const legacyAdmitMatch = pathname.match(/^\/api\/admin\/registrations\/([^/]+)\/admit-card$/);
|
||||
if (request.method === 'POST' && legacyAdmitMatch) {
|
||||
return sendError(response, 410, '单人生成已停用,请在“准考证编排”中按整场考试预检并批量生成');
|
||||
}
|
||||
if (request.method === 'GET' && pathname === '/api/admin/exams') {
|
||||
if (!requirePermission(user, response, '*')) return true;
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
export const admissionMixingScopes = [
|
||||
{ code: 'class', name: '班内混编', description: '以班级为边界,同班考生按科目组合穿插编排。' },
|
||||
{ code: 'school', name: '校内混编', description: '同校跨班混编,优先安排在本校考点。' },
|
||||
{ code: 'district', name: '县区内混编', description: '同县区跨学校混编,优先使用本县区考点。' },
|
||||
{ code: 'city', name: '市内混编', description: '同市跨县区混编,优先使用本市考点。' },
|
||||
{ code: 'province', name: '省内混编', description: '全省范围混编,按容量和科目组合选择考点。' }
|
||||
];
|
||||
|
||||
const mixingScopeCodes = new Set(admissionMixingScopes.map(item => item.code));
|
||||
|
||||
function arrangementError(message, status = 409) {
|
||||
return Object.assign(new Error(message), { status });
|
||||
}
|
||||
|
||||
function normalizedCode(value, fallback = '') {
|
||||
return String(value || fallback).replace(/[^0-9A-Z]/gi, '').toUpperCase();
|
||||
}
|
||||
|
||||
function hashText(value) {
|
||||
let hash = 2166136261;
|
||||
for (const char of String(value)) {
|
||||
hash ^= char.charCodeAt(0);
|
||||
hash = Math.imul(hash, 16777619);
|
||||
}
|
||||
return hash >>> 0;
|
||||
}
|
||||
|
||||
function stableCompare(seed, left, right) {
|
||||
return hashText(`${seed}:${left.id}`) - hashText(`${seed}:${right.id}`) || left.id.localeCompare(right.id);
|
||||
}
|
||||
|
||||
function scopeValue(profile, scope) {
|
||||
if (scope === 'class') return profile.classId;
|
||||
if (scope === 'school') return profile.schoolId;
|
||||
if (scope === 'district') return profile.districtCode;
|
||||
if (scope === 'city') return profile.cityCode;
|
||||
return profile.provinceCode;
|
||||
}
|
||||
|
||||
function centerMatchesProfile(center, profile, scope) {
|
||||
if (scope === 'class' || scope === 'school') return center.schoolId === profile.schoolId;
|
||||
if (scope === 'district') return Boolean(profile.districtCode && center.districtCode === profile.districtCode);
|
||||
if (scope === 'city') return Boolean(profile.cityCode && center.cityCode === profile.cityCode);
|
||||
return Boolean(profile.provinceCode && center.provinceCode === profile.provinceCode);
|
||||
}
|
||||
|
||||
function localityScore(center, profile) {
|
||||
if (center.schoolId === profile.schoolId) return 5000;
|
||||
if (profile.districtCode && center.districtCode === profile.districtCode) return 1000;
|
||||
if (profile.cityCode && center.cityCode === profile.cityCode) return 300;
|
||||
if (profile.provinceCode && center.provinceCode === profile.provinceCode) return 100;
|
||||
return 0;
|
||||
}
|
||||
|
||||
function minutes(value) {
|
||||
const [hour, minute] = String(value || '').split(':').map(Number);
|
||||
return Number.isFinite(hour) && Number.isFinite(minute) ? hour * 60 + minute : NaN;
|
||||
}
|
||||
|
||||
function overlappingSubjects(subjects) {
|
||||
for (let left = 0; left < subjects.length; left += 1) {
|
||||
for (let right = left + 1; right < subjects.length; right += 1) {
|
||||
const first = subjects[left];
|
||||
const second = subjects[right];
|
||||
if (first.date !== second.date) continue;
|
||||
const firstStart = minutes(first.start);
|
||||
const firstEnd = minutes(first.end);
|
||||
const secondStart = minutes(second.start);
|
||||
const secondEnd = minutes(second.end);
|
||||
if ([firstStart, firstEnd, secondStart, secondEnd].every(Number.isFinite)
|
||||
&& firstStart < secondEnd && secondStart < firstEnd) return [first, second];
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseRuleSegments(rule) {
|
||||
if (Array.isArray(rule.segments)) return rule.segments;
|
||||
try { return JSON.parse(rule.segmentsJson || '[]'); } catch { return []; }
|
||||
}
|
||||
|
||||
function segmentValue(segment, sources, sequence) {
|
||||
const source = segment.source === 'sequence' ? String(sequence) : String(sources[segment.source] || '');
|
||||
if (!source) throw arrangementError(`准考证号规则需要“${segment.label || segment.source}”,但考生或考点档案中缺少该值`);
|
||||
const width = Math.max(0, Number(segment.width || 0));
|
||||
return width ? source.padStart(width, '0') : source;
|
||||
}
|
||||
|
||||
function formatAdmissionNumber(rule, sources, sequence) {
|
||||
const segments = parseRuleSegments(rule);
|
||||
if (!segments.length) throw arrangementError('所选准考证号规则没有可用的组成段');
|
||||
return segments.map(segment => segmentValue(segment, sources, sequence)).join(rule.separator || '');
|
||||
}
|
||||
|
||||
function sequenceGroup(rule, sources) {
|
||||
if (rule.code.startsWith('district_')) return sources.district_code;
|
||||
if (rule.code.startsWith('center_school_')) return sources.center_school_code;
|
||||
if (rule.code.startsWith('candidate_school_')) return sources.candidate_school_code;
|
||||
return 'all';
|
||||
}
|
||||
|
||||
export function buildAdmissionArrangement(db, options) {
|
||||
const exam = db.exams.find(item => item.id === options.examId);
|
||||
if (!exam) throw arrangementError('考试不存在', 404);
|
||||
const mixingScope = String(options.mixingScope || 'school');
|
||||
if (!mixingScopeCodes.has(mixingScope)) throw arrangementError('请选择有效的混编范围', 400);
|
||||
const rule = db.admissionNumberRules.find(item => item.id === options.numberRuleId && item.active !== false);
|
||||
if (!rule) throw arrangementError('请选择有效的准考证号规则', 400);
|
||||
const seed = String(options.seed || exam.code || exam.id).slice(0, 80);
|
||||
const warnings = [];
|
||||
const warn = message => { if (!warnings.includes(message)) warnings.push(message); };
|
||||
const subjectOrder = new Map(exam.subjects.map((subject, index) => [subject.id, index]));
|
||||
const registrations = db.registrations.filter(item => item.examId === exam.id && item.status === 'approved');
|
||||
if (!registrations.length) throw arrangementError('该考试没有已审核通过的报名,无法编排');
|
||||
|
||||
const candidates = registrations.map(registration => {
|
||||
const profile = db.candidateProfiles.find(item => item.userId === registration.userId);
|
||||
if (!profile) throw arrangementError(`报名 ${registration.id} 缺少考生档案`);
|
||||
const boundary = scopeValue(profile, mixingScope);
|
||||
if (!boundary) throw arrangementError(`${profile.name} 缺少${admissionMixingScopes.find(item => item.code === mixingScope)?.name.replace('内混编', '') || '范围'}信息`);
|
||||
const subjects = registration.subjectIds.map(id => exam.subjects.find(subject => subject.id === id)).filter(Boolean);
|
||||
if (subjects.length !== registration.subjectIds.length || !subjects.length) throw arrangementError(`${profile.name} 的报考科目无效`);
|
||||
const overlap = overlappingSubjects(subjects);
|
||||
if (overlap) throw arrangementError(`${profile.name} 报考的“${overlap[0].name}”与“${overlap[1].name}”时间冲突`);
|
||||
const orderedSubjectIds = subjects.sort((a, b) => subjectOrder.get(a.id) - subjectOrder.get(b.id)).map(subject => subject.id);
|
||||
return {
|
||||
id: registration.id,
|
||||
registration,
|
||||
profile,
|
||||
subjectIds: orderedSubjectIds,
|
||||
signature: orderedSubjectIds.join('|'),
|
||||
scopeKey: `${mixingScope}:${boundary}`,
|
||||
centerId: null
|
||||
};
|
||||
});
|
||||
|
||||
const activeCenters = db.testCenters.filter(center => center.status === 'active').map(center => {
|
||||
const allRooms = db.testRooms.filter(room => room.centerId === center.id && room.status === 'active');
|
||||
const regularRooms = allRooms.filter(room => room.roomType !== 'spare');
|
||||
const rooms = regularRooms.length ? regularRooms : allRooms;
|
||||
if (!regularRooms.length && allRooms.length) warn(`${center.name} 没有普通启用考场,本次将使用备用考场`);
|
||||
return { ...center, rooms, allRooms };
|
||||
}).filter(center => center.rooms.length);
|
||||
if (!activeCenters.length) throw arrangementError('没有可用的启用考点和考场');
|
||||
|
||||
const remaining = new Map(activeCenters.map(center => [center.id, new Map(
|
||||
exam.subjects.map(subject => [subject.id, center.rooms.reduce((sum, room) => sum + Number(room.capacity), 0)])
|
||||
)]));
|
||||
const assignedCounts = new Map(activeCenters.map(center => [center.id, 0]));
|
||||
const affinity = new Map();
|
||||
const groups = new Map();
|
||||
for (const candidate of candidates) {
|
||||
const key = `${candidate.scopeKey}:${candidate.signature}`;
|
||||
const group = groups.get(key) || [];
|
||||
group.push(candidate);
|
||||
groups.set(key, group);
|
||||
}
|
||||
const orderedGroups = [...groups.entries()].sort((left, right) => right[1].length - left[1].length || left[0].localeCompare(right[0]));
|
||||
|
||||
for (const [, group] of orderedGroups) {
|
||||
group.sort((left, right) => stableCompare(seed, left, right));
|
||||
for (const candidate of group) {
|
||||
const eligible = activeCenters.filter(center => candidate.subjectIds.every(subjectId => (remaining.get(center.id).get(subjectId) || 0) > 0));
|
||||
if (!eligible.length) throw arrangementError(`${candidate.profile.name} 的全部报考科目无法在同一考点容纳;请增加考场容量或缩小本次报名范围`);
|
||||
const local = eligible.filter(center => centerMatchesProfile(center, candidate.profile, mixingScope));
|
||||
if (!local.length) warn(`${candidate.profile.name} 所属范围内没有足够考点,已跨范围使用可用考点`);
|
||||
const pool = local.length ? local : eligible;
|
||||
const selected = [...pool].sort((left, right) => {
|
||||
const leftAffinity = affinity.get(`${left.id}:${candidate.signature}`) || 0;
|
||||
const rightAffinity = affinity.get(`${right.id}:${candidate.signature}`) || 0;
|
||||
const leftCapacity = Math.min(...candidate.subjectIds.map(id => remaining.get(left.id).get(id)));
|
||||
const rightCapacity = Math.min(...candidate.subjectIds.map(id => remaining.get(right.id).get(id)));
|
||||
const leftScore = leftAffinity * 100000 + localityScore(left, candidate.profile) + leftCapacity - (assignedCounts.get(left.id) || 0);
|
||||
const rightScore = rightAffinity * 100000 + localityScore(right, candidate.profile) + rightCapacity - (assignedCounts.get(right.id) || 0);
|
||||
return rightScore - leftScore || left.code.localeCompare(right.code);
|
||||
})[0];
|
||||
candidate.centerId = selected.id;
|
||||
candidate.subjectIds.forEach(subjectId => remaining.get(selected.id).set(subjectId, remaining.get(selected.id).get(subjectId) - 1));
|
||||
assignedCounts.set(selected.id, (assignedCounts.get(selected.id) || 0) + 1);
|
||||
affinity.set(`${selected.id}:${candidate.signature}`, (affinity.get(`${selected.id}:${candidate.signature}`) || 0) + 1);
|
||||
}
|
||||
}
|
||||
|
||||
const assignments = new Map(candidates.map(candidate => [candidate.id, []]));
|
||||
const roomUses = [];
|
||||
for (const subject of exam.subjects) {
|
||||
for (const center of activeCenters) {
|
||||
const subjectCandidates = candidates.filter(candidate => candidate.centerId === center.id && candidate.subjectIds.includes(subject.id));
|
||||
if (!subjectCandidates.length) continue;
|
||||
subjectCandidates.sort((left, right) => left.scopeKey.localeCompare(right.scopeKey)
|
||||
|| left.signature.localeCompare(right.signature) || stableCompare(seed, left, right));
|
||||
let roomIndex = 0;
|
||||
let seatIndex = 0;
|
||||
let currentScopeKey = '';
|
||||
const rooms = [...center.rooms].sort((left, right) => left.code.localeCompare(right.code) || left.id.localeCompare(right.id));
|
||||
for (const candidate of subjectCandidates) {
|
||||
if (currentScopeKey && candidate.scopeKey !== currentScopeKey && seatIndex > 0) { roomIndex += 1; seatIndex = 0; }
|
||||
currentScopeKey = candidate.scopeKey;
|
||||
while (roomIndex < rooms.length && seatIndex >= Number(rooms[roomIndex].capacity)) { roomIndex += 1; seatIndex = 0; }
|
||||
const room = rooms[roomIndex];
|
||||
if (!room) throw arrangementError(`${center.name} 在“${subject.name}”科目下按${admissionMixingScopes.find(item => item.code === mixingScope)?.name || '当前范围'}隔离后考场不足;请增加考场或扩大混编范围`);
|
||||
const seatNumber = Number(room.seatStart || 1) + seatIndex;
|
||||
const assignment = {
|
||||
registrationId: candidate.id,
|
||||
subjectId: subject.id,
|
||||
centerId: center.id,
|
||||
centerName: center.name,
|
||||
roomId: room.id,
|
||||
roomName: room.name,
|
||||
roomCode: room.code,
|
||||
examRoomCode: '',
|
||||
seat: String(seatNumber).padStart(2, '0'),
|
||||
subjectSignature: candidate.signature
|
||||
};
|
||||
assignments.get(candidate.id).push(assignment);
|
||||
roomUses.push({ subjectId: subject.id, subjectOrder: subjectOrder.get(subject.id), centerCode: center.code, roomCode: room.code, roomId: room.id });
|
||||
seatIndex += 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const examRoomCodes = new Map([...new Map(roomUses.map(item => [`${item.subjectId}:${item.roomId}`, item])).values()]
|
||||
.sort((left, right) => left.subjectOrder - right.subjectOrder || left.centerCode.localeCompare(right.centerCode) || left.roomCode.localeCompare(right.roomCode))
|
||||
.map((item, index) => [`${item.subjectId}:${item.roomId}`, String(index + 1).padStart(3, '0')]));
|
||||
for (const subjectAssignments of assignments.values()) {
|
||||
subjectAssignments.sort((left, right) => subjectOrder.get(left.subjectId) - subjectOrder.get(right.subjectId));
|
||||
subjectAssignments.forEach(item => { item.examRoomCode = examRoomCodes.get(`${item.subjectId}:${item.roomId}`); });
|
||||
}
|
||||
|
||||
const sequenceCounters = new Map();
|
||||
const numbers = new Set();
|
||||
const cards = candidates.sort((left, right) => left.scopeKey.localeCompare(right.scopeKey) || stableCompare(seed, left, right)).map(candidate => {
|
||||
const center = activeCenters.find(item => item.id === candidate.centerId);
|
||||
const primary = assignments.get(candidate.id)[0];
|
||||
const candidateSchool = db.schools.find(item => item.id === candidate.profile.schoolId);
|
||||
const centerSchool = db.schools.find(item => item.id === center.schoolId);
|
||||
const sources = {
|
||||
district_code: normalizedCode(candidate.profile.districtCode || center.districtCode),
|
||||
center_school_code: normalizedCode(centerSchool?.code),
|
||||
candidate_school_code: normalizedCode(candidateSchool?.code),
|
||||
exam_room_code: primary.examRoomCode,
|
||||
seat: primary.seat
|
||||
};
|
||||
const counterKey = sequenceGroup(rule, sources);
|
||||
const sequence = (sequenceCounters.get(counterKey) || 0) + 1;
|
||||
sequenceCounters.set(counterKey, sequence);
|
||||
const number = formatAdmissionNumber(rule, sources, sequence);
|
||||
if (numbers.has(number)) throw arrangementError(`规则“${rule.name}”生成了重复准考证号 ${number},请检查规则组成`);
|
||||
numbers.add(number);
|
||||
return {
|
||||
registrationId: candidate.id,
|
||||
number,
|
||||
centerId: center.id,
|
||||
testCenter: center.name,
|
||||
generatedAt: options.generatedAt,
|
||||
assignments: assignments.get(candidate.id)
|
||||
};
|
||||
});
|
||||
|
||||
const usedCenters = new Set(cards.map(card => card.centerId));
|
||||
const sameSchoolCenterCount = candidates.filter(candidate => activeCenters.find(center => center.id === candidate.centerId)?.schoolId === candidate.profile.schoolId).length;
|
||||
const summary = {
|
||||
candidateCount: candidates.length,
|
||||
centerCount: usedCenters.size,
|
||||
subjectAssignmentCount: cards.reduce((sum, card) => sum + card.assignments.length, 0),
|
||||
subjectCombinationCount: new Set(candidates.map(candidate => candidate.signature)).size,
|
||||
sameSchoolCenterCount,
|
||||
sameSchoolCenterRate: Number((sameSchoolCenterCount * 100 / candidates.length).toFixed(1)),
|
||||
reservedSpareRooms: activeCenters.reduce((sum, center) => sum + center.allRooms.filter(room => room.roomType === 'spare' && !center.rooms.includes(room)).length, 0)
|
||||
};
|
||||
return { exam, rule, mixingScope, seed, warnings, summary, cards };
|
||||
}
|
||||
Reference in New Issue
Block a user