准考证编排

This commit is contained in:
2026-07-20 13:06:00 +08:00 Unverified
parent 4c7fafc03d
commit 9566542414
14 changed files with 846 additions and 86 deletions
+20 -6
View File
@@ -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) {
+11 -2
View File
@@ -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
View File
@@ -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: [
+25 -2
View File
@@ -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
View File
@@ -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,
+59 -1
View File
@@ -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
View File
@@ -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;
+272
View File
@@ -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 };
}