From 95665424149f2111ea9a2dac60006fe54e2fb5a8 Mon Sep 17 00:00:00 2001 From: biss Date: Mon, 20 Jul 2026 13:06:00 +0800 Subject: [PATCH] =?UTF-8?q?=E5=87=86=E8=80=83=E8=AF=81=E7=BC=96=E6=8E=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 12 +- app.js | 32 ++- database.mjs | 163 ++++++++++++--- server.mjs | 8 +- src/client/admin-views.mjs | 26 ++- src/client/candidate-views.mjs | 13 +- src/data/seed.mjs | 58 +++++- src/database/mysql-adapter.mjs | 27 ++- src/database/schema.mjs | 120 +++++++++-- src/database/sqlite-adapter.mjs | 60 +++++- src/routes/admin.routes.mjs | 93 +++++++-- src/services/admission-arrangement.mjs | 272 +++++++++++++++++++++++++ styles.css | 8 + tests/system.test.mjs | 40 +++- 14 files changed, 846 insertions(+), 86 deletions(-) create mode 100644 src/services/admission-arrangement.mjs diff --git a/README.md b/README.md index 0e4da03..1749257 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,10 @@ - 创建考试并结构化配置科目日期、时间、费用、满分与单科合格分,自动汇总总分 - 支持固定总分线、总分得分率、排名前百分比、单科均达线及不判定五类合格策略 - 通知发布、草稿、撤回及首页置顶 -- 为审核通过的报名生成准考证号、考点、考场和座位 +- 按整场考试预检并批量编排准考证,支持班内、校内、县区内、市内和省内五级混编 +- 预置“县区编号+考场号+座位号”“县区号+考场号+流水号”“考点学校代码+考场号+座位号”“考生学校代码+考场号+座位号”四种号码规则 +- 多科目考生固定在同一考点,各科独立分配考场和座位;同科目组合优先相邻编排 +- 编排前校验科目时间冲突、考点容量、档案完整性和号码唯一性,默认保留备用考场并支持稳定种子复现 - 按科目满分校验单科成绩、按得分率计算等级,并在全部科目发布后汇总总分与合格结果 - 管理员与考生接口权限隔离 @@ -77,7 +80,7 @@ npm start 打开 。 -本地开发无需额外配置,首次运行会自动创建 `data/exam.sqlite` 和完整关系型数据库结构。当前处于开发阶段,数据库结构版本为 v8,不兼容旧数据;升级本分支后请运行 `npm run reset-db` 重建开发库。v8 增加班级审批节点、成绩复议流程、严格校班匹配和多管理员均分。 +本地开发无需额外配置,首次运行会自动创建 `data/exam.sqlite` 和完整关系型数据库结构。当前处于开发阶段,数据库结构版本为 v9,不兼容旧准考证编排数据;启动时会替换旧准考证相关表,也可运行 `npm run reset-db` 重建完整开发库。v9 增加整场编排方案、五级混编、可扩展号码规则和逐科考场座位。 需要清空并重建本地演示库时运行 `npm run reset-db`。省市区县下拉数据位于 `src/data/china-regions.mjs`,当前版本为国家地名信息库截至 2025-12-31 的三级快照,并补入和康县(653228)与和安县(653229);从新版 CSV 更新时可运行 `node scripts/build-regions.mjs src/data/china-regions.mjs`。 @@ -109,7 +112,8 @@ GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER ON exam_information.* TO 'ex - `schools`、`school_classes`、`users`、`candidate_profiles` - `exams`、`exam_subjects` -- `registrations`、`registration_subjects`、`admit_cards` +- `registrations`、`registration_subjects` +- `admission_number_rules`、`exam_arrangement_plans`、`admit_cards`、`admit_card_subjects` - `results`、`notices`、`audit_logs` - `test_centers`、`test_rooms`、`center_change_requests`、`center_change_rooms` - `number_rules`、`number_rule_segments` @@ -151,7 +155,7 @@ npm start npm test ``` -测试使用独立临时 SQLite 数据库,覆盖固定报名号跨考试复用、首次登录强制改密、完整资料补录、自主注册开关、三级管理员数据范围、本校班级与班级管理员管理、多级审批、同级转交、校级按班级批量申领与终审原子建号、结构化考点考场及变更审批、多资源 Excel 导入导出、多科目报名、准考证、成绩复议、校班严格匹配和多人均分。 +测试使用独立临时 SQLite 数据库,覆盖固定报名号跨考试复用、首次登录强制改密、完整资料补录、自主注册开关、三级管理员数据范围、本校班级与班级管理员管理、多级审批、同级转交、校级按班级批量申领与终审原子建号、结构化考点考场及变更审批、多资源 Excel 导入导出、多科目报名、五级准考证混编、四种号码规则、多科目同考点、成绩复议、校班严格匹配和多人均分。 ## 项目结构 diff --git a/app.js b/app.js index 3155911..77435ef 100644 --- a/app.js +++ b/app.js @@ -179,10 +179,23 @@ document.addEventListener('click', async event => { if (action === 'generate-admit') { const registration = state.pageData.registrations.find(item => item.id === target.dataset.id); if (registration.admitCard) return openAdmitPreview(registration); - const testCenter = prompt('请输入考点名称:', '海州市第一中学'); - if (!testCenter) return; - await api(`/api/admin/registrations/${target.dataset.id}/admit-card`, { method: 'POST', body: { testCenter } }); - toast('准考证已生成', '考生可在开放时间内下载'); return renderRoute(); + return toast('尚未编排', '请使用页面上方的整场编排控制台'); + } + if (action === 'preview-arrangement') { + const form = target.closest('form'); + const body = formObject(form); + const output = form.parentElement.querySelector('[data-arrangement-preview]'); + target.disabled = true; + target.textContent = '正在预检…'; + try { + const data = await api(`/api/admin/exams/${body.examId}/admission-arrangement/preview`, { method: 'POST', body }); + const summary = data.summary; + output.innerHTML = `
${data.warnings.length ? '预检完成,有提示' : '预检通过,可以生成'}${summary.candidateCount} 人 · ${summary.centerCount} 个考点 · ${summary.subjectAssignmentCount} 个科次座位 · ${summary.subjectCombinationCount} 种科目组合${data.warnings.length ? `
    ${data.warnings.map(item => `
  • ${h(item)}
  • `).join('')}
` : '多科目同考点、容量、科目时间和号码唯一性检查均已通过。'}
`; + } finally { + target.disabled = false; + target.textContent = '仅预检,不写入'; + } + return; } if (action === 'toggle-exam') { await api(`/api/admin/exams/${target.dataset.id}`, { method: 'PATCH', body: { status: target.dataset.status } }); @@ -367,6 +380,10 @@ document.addEventListener('submit', async event => { })).sort((a, b) => a.position - b.position); await api('/api/admin/number-rules', { method: 'POST', body: { id: raw.id, name: raw.name, separator: raw.separator, segments } }); toast('报名号规则已启用', '后续创建的考生账户将按此规则生成固定号码'); renderRoute(); + } else if (kind === 'admission-arrangement') { + const body = formObject(form); + const data = await api(`/api/admin/exams/${body.examId}/admission-arrangement`, { method: 'POST', body }); + toast('整场编排已生成', `${data.summary.candidateCount} 名考生 · ${data.summary.subjectAssignmentCount} 个科次座位`); renderRoute(); } else if (kind === 'workflow-design') { const names = [...form.querySelectorAll('[name="stepName"]')]; const levels = [...form.querySelectorAll('[name="stepLevel"]')]; @@ -542,7 +559,12 @@ function openExamForm(exam = null) { } function openAdmitPreview(reg) { - setModal(`
${h(reg.admitCard.number)}
考点
${h(reg.admitCard.testCenter)}
考场
${h(reg.admitCard.room)}
座位号
${h(reg.admitCard.seat)}
生成时间
${formatDate(reg.admitCard.generatedAt,true)}

考生可在 ${dateRange(reg.exam.admitDownloadStart, reg.exam.admitDownloadEnd)} 下载。

`); + const assignments = new Map((reg.admitCard.assignments || []).map(item => [item.subjectId, item])); + const rows = reg.subjects.map(subject => { + const assignment = assignments.get(subject.id) || {}; + return `${h(subject.name)}${h(subject.date)} ${h(subject.start)}${h(assignment.roomName || assignment.room || '待定')}${h(assignment.seat || '—')}`; + }).join(''); + setModal(`
${h(reg.admitCard.number)}
固定考点
${h(reg.admitCard.testCenter)}
生成时间
${formatDate(reg.admitCard.generatedAt,true)}
${rows}
科目时间考场座位

考生可在 ${dateRange(reg.exam.admitDownloadStart, reg.exam.admitDownloadEnd)} 下载。

`); } window.addEventListener('hashchange', renderRoute); diff --git a/database.mjs b/database.mjs index c6d4cf8..db59193 100644 --- a/database.mjs +++ b/database.mjs @@ -16,7 +16,10 @@ export const relationalTables = [ 'exam_subjects', 'registrations', 'registration_subjects', + 'admission_number_rules', + 'exam_arrangement_plans', 'admit_cards', + 'admit_card_subjects', 'results', 'test_centers', 'test_rooms', @@ -37,6 +40,7 @@ function validateState(state, source = '数据库') { const collections = [ 'schools', 'classes', 'users', 'candidateProfiles', 'notices', 'exams', 'registrations', 'results', 'testCenters', 'testRooms', 'centerChangeRequests', 'centerChangeRooms', + 'admissionNumberRules', 'arrangementPlans', 'numberRules', 'candidateAccountBatches', 'candidateAccountBatchItems', 'workflows', 'workflowInstances', 'workflowActions', 'auditLogs' ]; @@ -53,7 +57,7 @@ function buildSeedOperations(state) { const nullable = value => value == null || value === '' ? null : value; add( - 'UPDATE schema_metadata SET schema_version = 8, app_version = ?, self_registration_enabled = ?, created_at = ? WHERE id = 1', + 'UPDATE schema_metadata SET schema_version = 9, app_version = ?, self_registration_enabled = ?, created_at = ? WHERE id = 1', Number(state.meta?.version || 1), state.settings?.selfRegistrationEnabled ? 1 : 0, state.meta?.createdAt || new Date().toISOString() ); @@ -150,15 +154,6 @@ function buildSeedOperations(state) { for (const subjectId of registration.subjectIds) { add('INSERT INTO registration_subjects (registration_id, subject_id) VALUES (?, ?)', registration.id, subjectId); } - if (registration.admitCard) { - add( - `INSERT INTO admit_cards ( - registration_id, card_number, test_center, room, seat, generated_at - ) VALUES (?, ?, ?, ?, ?, ?)`, - registration.id, registration.admitCard.number, registration.admitCard.testCenter, - registration.admitCard.room, registration.admitCard.seat, registration.admitCard.generatedAt - ); - } } for (const result of state.results) { @@ -197,6 +192,46 @@ function buildSeedOperations(state) { ); } + for (const rule of state.admissionNumberRules) { + add( + `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 + ); + } + + for (const plan of state.arrangementPlans) { + add( + `INSERT INTO exam_arrangement_plans ( + id, exam_id, number_rule_id, mixing_scope, random_seed, candidate_count, center_count, + subject_assignment_count, subject_combination_count, same_school_center_rate, warnings_json, generated_by, generated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + plan.id, plan.examId, plan.numberRuleId, plan.mixingScope, plan.randomSeed, + Number(plan.candidateCount || 0), Number(plan.centerCount || 0), Number(plan.subjectAssignmentCount || 0), + Number(plan.subjectCombinationCount || 0), Number(plan.sameSchoolCenterRate || 0), JSON.stringify(plan.warnings || []), + nullable(plan.generatedBy), plan.generatedAt + ); + } + + for (const registration of state.registrations.filter(item => item.admitCard)) { + const card = registration.admitCard; + add( + `INSERT INTO admit_cards ( + registration_id, plan_id, card_number, center_id, test_center, generated_at + ) VALUES (?, ?, ?, ?, ?, ?)`, + registration.id, card.planId, card.number, nullable(card.centerId), card.testCenter, card.generatedAt + ); + for (const assignment of card.assignments || []) add( + `INSERT INTO admit_card_subjects ( + registration_id, subject_id, room_id, room, room_code, exam_room_code, seat, subject_signature + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + registration.id, assignment.subjectId, nullable(assignment.roomId), assignment.roomName || assignment.room, + assignment.roomCode, assignment.examRoomCode, assignment.seat, assignment.subjectSignature || '' + ); + } + for (const request of state.centerChangeRequests) { add( `INSERT INTO center_change_requests ( @@ -328,13 +363,39 @@ function stateFromRows(rows) { subjectIds.push(row.subject_id); registrationSubjects.set(row.registration_id, subjectIds); } - const admitCards = new Map(rows.admitCards.map(row => [row.registration_id, { - number: row.card_number, - testCenter: row.test_center, - room: row.room, - seat: row.seat, - generatedAt: row.generated_at - }])); + const subjectPositions = new Map(rows.subjects.map(row => [row.id, Number(row.position)])); + const admitAssignments = new Map(); + for (const row of rows.admitCardSubjects) { + const assignments = admitAssignments.get(row.registration_id) || []; + assignments.push({ + subjectId: row.subject_id, + roomId: row.room_id, + roomName: row.room, + room: row.room, + roomCode: row.room_code, + examRoomCode: row.exam_room_code, + seat: row.seat, + subjectSignature: row.subject_signature || '' + }); + admitAssignments.set(row.registration_id, assignments); + } + for (const assignments of admitAssignments.values()) assignments.sort((left, right) => + (subjectPositions.get(left.subjectId) || 0) - (subjectPositions.get(right.subjectId) || 0) + ); + const admitCards = new Map(rows.admitCards.map(row => { + const assignments = admitAssignments.get(row.registration_id) || []; + const primary = assignments[0] || {}; + return [row.registration_id, { + planId: row.plan_id, + number: row.card_number, + centerId: row.center_id, + testCenter: row.test_center, + room: primary.roomName || '', + seat: primary.seat || '', + assignments, + generatedAt: row.generated_at + }]; + })); const ruleSegments = new Map(); for (const row of rows.numberRuleSegments) { const segments = ruleSegments.get(row.rule_id) || []; @@ -567,6 +628,32 @@ function stateFromRows(rows) { status: row.status, notes: row.notes || '' })), + admissionNumberRules: rows.admissionNumberRules.map(row => ({ + id: row.id, + code: row.code, + name: row.name, + description: row.description, + separator: row.separator || '', + segments: Array.isArray(row.segments_json) ? row.segments_json : JSON.parse(row.segments_json || '[]'), + example: row.example || '', + active: Boolean(row.active), + createdAt: row.created_at + })), + arrangementPlans: rows.arrangementPlans.map(row => ({ + id: row.id, + examId: row.exam_id, + numberRuleId: row.number_rule_id, + mixingScope: row.mixing_scope, + randomSeed: row.random_seed, + candidateCount: Number(row.candidate_count), + centerCount: Number(row.center_count), + subjectAssignmentCount: Number(row.subject_assignment_count), + subjectCombinationCount: Number(row.subject_combination_count), + sameSchoolCenterRate: Number(row.same_school_center_rate), + warnings: Array.isArray(row.warnings_json) ? row.warnings_json : JSON.parse(row.warnings_json || '[]'), + generatedBy: row.generated_by, + generatedAt: row.generated_at + })), numberRules: rows.numberRules.map(row => ({ id: row.id, name: row.name, @@ -649,7 +736,10 @@ function readSqliteRows(connection) { subjects: connection.prepare('SELECT * FROM exam_subjects ORDER BY exam_id, position, id').all(), registrations: connection.prepare('SELECT * FROM registrations ORDER BY created_at, id').all(), registrationSubjects: connection.prepare('SELECT * FROM registration_subjects ORDER BY registration_id, subject_id').all(), + admissionNumberRules: connection.prepare('SELECT * FROM admission_number_rules ORDER BY created_at, id').all(), + arrangementPlans: connection.prepare('SELECT * FROM exam_arrangement_plans ORDER BY generated_at DESC, id').all(), admitCards: connection.prepare('SELECT * FROM admit_cards ORDER BY registration_id').all(), + admitCardSubjects: connection.prepare('SELECT * FROM admit_card_subjects ORDER BY registration_id, subject_id').all(), results: connection.prepare('SELECT * FROM results ORDER BY id').all(), testCenters: connection.prepare('SELECT * FROM test_centers ORDER BY school_id, name, id').all(), testRooms: connection.prepare('SELECT * FROM test_rooms ORDER BY center_id, code, id').all(), @@ -682,7 +772,10 @@ async function readMysqlRows(connection) { subjects: await query('SELECT * FROM exam_subjects ORDER BY exam_id, position, id'), registrations: await query('SELECT * FROM registrations ORDER BY created_at, id'), registrationSubjects: await query('SELECT * FROM registration_subjects ORDER BY registration_id, subject_id'), + admissionNumberRules: await query('SELECT * FROM admission_number_rules ORDER BY created_at, id'), + arrangementPlans: await query('SELECT * FROM exam_arrangement_plans ORDER BY generated_at DESC, id'), admitCards: await query('SELECT * FROM admit_cards ORDER BY registration_id'), + admitCardSubjects: await query('SELECT * FROM admit_card_subjects ORDER BY registration_id, subject_id'), results: await query('SELECT * FROM results ORDER BY id'), testCenters: await query('SELECT * FROM test_centers ORDER BY school_id, name, id'), testRooms: await query('SELECT * FROM test_rooms ORDER BY center_id, code, id'), @@ -1154,16 +1247,38 @@ function createRepository({ client, location, read, transaction, close }) { operations.push(auditOperation(log)); await transaction(operations); }, - async createAdmitCard(registrationId, admitCard, log) { - await transaction([ + async saveAdmissionArrangement(plan, cards, log) { + const operations = [ + operation('DELETE FROM exam_arrangement_plans WHERE exam_id = ?', plan.examId), operation( + `INSERT INTO exam_arrangement_plans ( + id, exam_id, number_rule_id, mixing_scope, random_seed, candidate_count, center_count, + subject_assignment_count, subject_combination_count, same_school_center_rate, + warnings_json, generated_by, generated_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + plan.id, plan.examId, plan.numberRuleId, plan.mixingScope, plan.randomSeed, + Number(plan.candidateCount), Number(plan.centerCount), Number(plan.subjectAssignmentCount), + Number(plan.subjectCombinationCount), Number(plan.sameSchoolCenterRate), JSON.stringify(plan.warnings || []), + optional(plan.generatedBy), plan.generatedAt + ) + ]; + for (const card of cards) { + operations.push(operation( `INSERT INTO admit_cards ( - registration_id, card_number, test_center, room, seat, generated_at + registration_id, plan_id, card_number, center_id, test_center, generated_at ) VALUES (?, ?, ?, ?, ?, ?)`, - registrationId, admitCard.number, admitCard.testCenter, admitCard.room, admitCard.seat, admitCard.generatedAt - ), - auditOperation(log) - ]); + card.registrationId, plan.id, card.number, optional(card.centerId), card.testCenter, card.generatedAt + )); + for (const assignment of card.assignments) operations.push(operation( + `INSERT INTO admit_card_subjects ( + registration_id, subject_id, room_id, room, room_code, exam_room_code, seat, subject_signature + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + card.registrationId, assignment.subjectId, optional(assignment.roomId), assignment.roomName, + assignment.roomCode, assignment.examRoomCode, assignment.seat, assignment.subjectSignature + )); + } + operations.push(auditOperation(log)); + await transaction(operations); }, async createExam(exam, log) { const operations = [operation( diff --git a/server.mjs b/server.mjs index 729c5a7..af92bbc 100644 --- a/server.mjs +++ b/server.mjs @@ -542,8 +542,12 @@ function escapeHtml(value) { function admitCardHtml(db, user, profile, registration) { const exam = db.exams.find(item => item.id === registration.examId); const subjects = exam.subjects.filter(subject => registration.subjectIds.includes(subject.id)); - const rows = subjects.map(subject => `${escapeHtml(subject.name)}${escapeHtml(subject.date)}${escapeHtml(subject.start)}—${escapeHtml(subject.end)}${escapeHtml(registration.admitCard.room)}`).join(''); - return `${escapeHtml(exam.name)}准考证
衡准 · 准考证

${escapeHtml(exam.name)}

${escapeHtml(registration.admitCard.number)}
姓名${escapeHtml(profile.name || user.displayName)}
证件号码${escapeHtml(maskId(profile.idNumber))}
考点${escapeHtml(registration.admitCard.testCenter)}
考场 / 座位${escapeHtml(registration.admitCard.room)} / ${escapeHtml(registration.admitCard.seat)}
${rows}
科目日期时间考场
考试须知:请携带本人有效身份证件及本准考证,至少提前 40 分钟到达考点。严禁携带手机、智能手表等通讯设备进入考场。
${escapeHtml(db.organization.name)}生成时间:${new Date(registration.admitCard.generatedAt).toLocaleString('zh-CN')}
`; + const assignments = new Map((registration.admitCard.assignments || []).map(item => [item.subjectId, item])); + const rows = subjects.map(subject => { + const assignment = assignments.get(subject.id) || {}; + return `${escapeHtml(subject.name)}${escapeHtml(subject.date)}${escapeHtml(subject.start)}—${escapeHtml(subject.end)}${escapeHtml(assignment.roomName || assignment.room || '待定')}${escapeHtml(assignment.seat || '—')}`; + }).join(''); + return `${escapeHtml(exam.name)}准考证
衡准 · 准考证

${escapeHtml(exam.name)}

${escapeHtml(registration.admitCard.number)}
姓名${escapeHtml(profile.name || user.displayName)}
证件号码${escapeHtml(maskId(profile.idNumber))}
固定考点${escapeHtml(registration.admitCard.testCenter)}
${rows}
科目日期时间考场座位
所有科目均安排在同一考点,但不同科目可能对应不同考场和座位。请逐科核对,并携带本人有效身份证件及本准考证至少提前 40 分钟到达考点。
${escapeHtml(db.organization.name)}生成时间:${new Date(registration.admitCard.generatedAt).toLocaleString('zh-CN')}
`; } const routeContext = { diff --git a/src/client/admin-views.mjs b/src/client/admin-views.mjs index fd69656..132aee6 100644 --- a/src/client/admin-views.mjs +++ b/src/client/admin-views.mjs @@ -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 `

发布状态会实时同步到公开首页和考生中心。

${notices.map(notice => ``).join('')}
通知标题分类作者发布时间展示状态操作
${h(notice.title)}${h(notice.summary)}${h(notice.category)}${h(notice.author)}${formatDate(notice.publishAt || notice.createdAt,true)}${notice.pinned ? '首页置顶' : '普通'}${badge(notice.status)}
`; } - function adminAdmit(registrations) { - const approved = registrations.filter(reg => reg.status === 'approved'); - return `

仅已通过报名审核的考生可生成准考证。

${approved.map(reg => ``).join('') || ''}
考生考试报考科目准考证号考点 / 考场下载时间操作
${h((reg.candidate?.name || '?').slice(0,1))}
${h(reg.candidate?.name)}${h(reg.candidate?.phone)}
${h(reg.exam.name)}${h(reg.exam.code)}${reg.subjects.map(subject => h(subject.name)).join('、')}${h(reg.admitCard?.number || '尚未生成')}${reg.admitCard ? `${h(reg.admitCard.testCenter)}${h(reg.admitCard.room)} · ${h(reg.admitCard.seat)} 号` : '—'}${dateRange(reg.exam.admitDownloadStart, reg.exam.admitDownloadEnd)}
暂无已通过的考试报名
`; + 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 ? `

整场编排控制台

先预检容量与档案,再以一个事务生成或替换整套编排。

${data.centers.length} 个启用考点 · ${data.centers.reduce((sum, item) => sum + item.capacity, 0)} 个常规席位
` : emptyState('还没有可编排考试', '请先创建考试并完成报名审核。'); + const ruleCards = `
${data.rules.map(rule => `
号码规则

${h(rule.name)}

${h(rule.description)}

${h(rule.example)}
`).join('')}
`; + const planCards = data.plans.length ? `
${data.plans.map(plan => `
${h(scopeName(plan.mixingScope))}

${h(plan.examName)}

${plan.candidateCount}名考生${plan.centerCount}个考点${plan.subjectCombinationCount}种科目组合

${h(plan.ruleName)} · 本校考点率 ${h(plan.sameSchoolCenterRate)}%

${plan.warnings.length ? `
    ${plan.warnings.map(item => `
  • ${h(item)}
  • `).join('')}
` : '

容量、档案与时间冲突检查通过

'}
`).join('')}
` : ''; + 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 `${h(subject.name)}${assignment ? `${h(assignment.roomName || assignment.room)} · ${h(assignment.seat)} 号` : '待编排'}`; + }).join(''); + return `
${h((reg.candidate?.name || '?').slice(0,1))}
${h(reg.candidate?.name)}${h(reg.candidate?.school || '')} · ${h(reg.candidate?.grade || '')}
${h(reg.exam.name)}${h(reg.exam.code)}${h(reg.admitCard?.number || '待编排')}${h(reg.admitCard?.testCenter || '—')}
${subjects}
${reg.admitCard ? `` : '—'}`; + }).join(''); + return `${form}${ruleCards}${planCards}

同一考生所有科目固定在一个考点;考场和座位按科目分别展示。

${rows || ''}
考生考试准考证号固定考点分科考场 / 座位操作
暂无已通过的考试报名
`; } function adminResults(data) { diff --git a/src/client/candidate-views.mjs b/src/client/candidate-views.mjs index 9a3709a..8831414 100644 --- a/src/client/candidate-views.mjs +++ b/src/client/candidate-views.mjs @@ -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 ? `
${cards.map(reg => { const now = Date.now(); const open = now >= new Date(reg.exam.admitDownloadStart).getTime() && now <= new Date(reg.exam.admitDownloadEnd).getTime(); return `
${h(reg.exam.code)}${badge(open ? 'open' : now < new Date(reg.exam.admitDownloadStart) ? 'upcoming' : 'closed')}

${h(reg.exam.name)}

准考证号${h(reg.admitCard.number)}
考点
${h(reg.admitCard.testCenter)}
考场 / 座位
${h(reg.admitCard.room)} / ${h(reg.admitCard.seat)}
下载时间
${dateRange(reg.exam.admitDownloadStart, reg.exam.admitDownloadEnd)}
ADMISSION
CARD
下载后请使用 A4 纸打印
`; }).join('')}
` : emptyState('准考证尚未生成', '考试报名审核通过后,由管理员统一生成准考证。', 'candidate/registrations', '查看报名状态'); + return cards.length ? `
${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 `${h(subject.name)}${h(subject.date)} ${h(subject.start)} · ${h(assignment.roomName || assignment.room || '待定')} · ${h(assignment.seat || '—')} 号`; + }).join(''); + return `
${h(reg.exam.code)}${badge(open ? 'open' : now < new Date(reg.exam.admitDownloadStart) ? 'upcoming' : 'closed')}

${h(reg.exam.name)}

准考证号${h(reg.admitCard.number)}
固定考点
${h(reg.admitCard.testCenter)}
逐科安排
${subjectRows}
下载时间
${dateRange(reg.exam.admitDownloadStart, reg.exam.admitDownloadEnd)}
ADMISSION
CARD
下载后请使用 A4 纸打印
`; + }).join('')}
` : emptyState('准考证尚未生成', '考试报名审核通过后,由管理员统一编排准考证。', 'candidate/registrations', '查看报名状态'); } function candidateResults(data) { diff --git a/src/data/seed.mjs b/src/data/seed.mjs index 7241d35..e5325e7 100644 --- a/src/data/seed.mjs +++ b/src/data/seed.mjs @@ -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: [ diff --git a/src/database/mysql-adapter.mjs b/src/database/mysql-adapter.mjs index 5b92a39..2c3c0f5 100644 --- a/src/database/mysql-adapter.mjs +++ b/src/database/mysql-adapter.mjs @@ -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); diff --git a/src/database/schema.mjs b/src/database/schema.mjs index f27b536..e66cefb 100644 --- a/src/database/schema.mjs +++ b/src/database/schema.mjs @@ -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, diff --git a/src/database/sqlite-adapter.mjs b/src/database/sqlite-adapter.mjs index 5980a23..f7bd908 100644 --- a/src/database/sqlite-adapter.mjs +++ b/src/database/sqlite-adapter.mjs @@ -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'); diff --git a/src/routes/admin.routes.mjs b/src/routes/admin.routes.mjs index 17c0971..06d6428 100644 --- a/src/routes/admin.routes.mjs +++ b/src/routes/admin.routes.mjs @@ -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; diff --git a/src/services/admission-arrangement.mjs b/src/services/admission-arrangement.mjs new file mode 100644 index 0000000..dc179ac --- /dev/null +++ b/src/services/admission-arrangement.mjs @@ -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 }; +} diff --git a/styles.css b/styles.css index 582acae..430a450 100644 --- a/styles.css +++ b/styles.css @@ -316,6 +316,14 @@ button:disabled { cursor: not-allowed; opacity: .5; } .region-selects { grid-template-columns:1fr; } .public-header { height:68px; }.public-nav nav { top:67px; }.public-nav .solid-button { display:none; }.brand strong { font-size:22px; }.brand-symbol { width:32px; height:32px; }.hero-grid,.content-section { width:calc(100% - 32px); }.notice-ticker { margin-bottom:28px; }.hero-copy h1 { font-size:40px; }.hero-copy h1 em::after { width:35px; }.hero-lead { font-size:12px; }.hero-actions { align-items:stretch; flex-direction:column; }.hero-stats { justify-content:space-between; gap:10px; }.hero-ticket { grid-template-columns:1fr 82px; transform:none; }.hero-ticket::before,.hero-ticket::after { right:70px; }.ticket-main { padding:23px; }.ticket-main h2 { font-size:22px; }.ticket-main dl div { grid-template-columns:62px 1fr; }.ticket-stub strong { font-size:25px; }.content-section { padding:65px 0; }.section-heading { align-items:flex-start; flex-direction:column; gap:10px; }.section-heading > p { text-align:left; }.section-heading h2 { font-size:30px; }.notice-row { grid-template-columns:55px 1fr 18px; padding:11px 13px; }.featured-notice { min-height:280px; }.exam-meta { grid-template-columns:1fr; }.public-exam-card footer { align-items:flex-start; flex-direction:column; gap:12px; }.flow-track { grid-template-columns:1fr; }.public-footer { align-items:flex-start; flex-direction:column; gap:25px; }.auth-story { min-height:315px; padding:27px 24px; }.auth-story h1 { font-size:35px; }.auth-panel { padding:70px 20px 35px; }.back-link { top:22px; right:20px; }.field-row,.form-grid { grid-template-columns:1fr; }.portal-topbar { height:62px; }.portal-user > span:nth-of-type(2) { display:none; }.portal-user .notification-button { display:none; }.portal-content { padding:20px 14px; }.portal-heading { align-items:flex-start; flex-direction:column; }.portal-heading .solid-button { width:100%; }.portal-heading h1 { font-size:28px; }.candidate-welcome { padding:24px; }.welcome-seal { display:none; }.candidate-welcome h2 { font-size:22px; }.summary-grid,.admin-metrics { grid-template-columns:1fr; }.candidate-progress { grid-template-columns:1fr; gap:0; padding:18px; }.progress-step { min-height:58px; grid-template-columns:30px 1fr; justify-items:start; align-items:center; text-align:left; }.progress-step::before { top:-50%; bottom:50%; left:14px; width:2px; height:auto; right:auto; }.progress-step div { justify-items:start; }.subject-options { grid-template-columns:1fr; }.registration-card > footer,.form-actions { align-items:flex-start; flex-direction:column; gap:10px; }.admit-ticket { grid-template-columns:1fr; }.admit-ticket::before,.admit-ticket::after { display:none; }.admit-stub { border-top:1px dashed rgba(255,255,255,.18); border-left:0; }.admit-main dl { grid-template-columns:1fr; }.score-grid { grid-template-columns:1fr 1fr; }.score-grid article,.score-grid article:nth-child(3) { border-right:1px solid var(--line); border-top:1px solid var(--line); }.score-grid article:nth-child(2n) { border-right:0; }.result-panel > header,.result-panel > footer { align-items:flex-start; flex-direction:column; gap:8px; }.notice-center-list > button { grid-template-columns:45px 1fr 18px; gap:10px; padding:12px; }.notice-center button > i { display:none; }.data-toolbar { align-items:stretch; flex-direction:column; }.search-box { width:100%; }.filter-pills { overflow-x:auto; }.filter-pills button { white-space:nowrap; }.admin-exam-card dl { grid-template-columns:1fr; }.admin-exam-card dl div:last-child { grid-column:auto; }.review-profile dl,.registration-review dl,.admit-preview dl { grid-template-columns:1fr; }.modal-layer { padding:10px; }.modal-card { max-height:94vh; }.modal-head,.modal-form { padding-left:18px; padding-right:18px; }.modal-foot { margin-left:-18px; margin-right:-18px; padding-left:18px; padding-right:18px; }.toast { right:14px; bottom:14px; left:14px; min-width:0; } } + +/* Admission-card arrangement engine */ +.arrangement-config { margin-bottom:15px; overflow:hidden; }.arrangement-config .panel-title { height:auto; min-height:74px; }.arrangement-config .panel-title p { margin:4px 0 0; color:var(--muted); font-size:8px; }.arrangement-config form { padding:20px; }.arrangement-fields { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:12px; }.arrangement-fields label { display:grid; align-content:start; gap:6px; }.arrangement-fields label > span { color:#59657c; font-size:8px; font-weight:700; }.arrangement-fields select,.arrangement-fields input { width:100%; min-height:42px; padding:8px 10px; border:1px solid var(--line); border-radius:8px; color:var(--ink); background:#fff; font-size:9px; }.arrangement-fields label small { color:#9199a9; font-size:7px; line-height:1.6; }.arrangement-actions { display:flex; justify-content:flex-end; gap:9px; margin-top:16px; }.arrangement-preview { display:grid; gap:5px; margin:0 20px 20px; padding:13px 15px; border-radius:9px; }.arrangement-preview.ok { color:#245f4c; background:#e7f4ef; }.arrangement-preview.warning { color:#795a27; background:#fff3dc; }.arrangement-preview strong { font-size:9px; }.arrangement-preview span,.arrangement-preview small,.arrangement-preview li { font-size:8px; }.arrangement-preview ul { margin:5px 0 0; padding-left:18px; } +.arrangement-rules { display:grid; grid-template-columns:repeat(4,minmax(0,1fr)); gap:10px; margin-bottom:15px; }.arrangement-rules article { display:grid; align-content:start; gap:6px; min-height:155px; padding:16px; border:1px solid var(--line); border-radius:11px; background:linear-gradient(145deg,#fff,#f5f7fb); }.arrangement-rules span { color:#8792a9; font-size:7px; }.arrangement-rules h3 { margin:0; font-family:"STKaiti"; font-size:16px; font-weight:400; }.arrangement-rules p { margin:0; color:#818b9f; font-size:7px; line-height:1.7; }.arrangement-rules code { margin-top:auto; color:var(--blue); font-size:10px; } +.arrangement-plan-grid { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:12px; margin-bottom:15px; }.arrangement-plan-grid article { padding:17px; }.arrangement-plan-grid header { display:flex; justify-content:space-between; gap:10px; }.arrangement-plan-grid header span,.arrangement-plan-grid time { color:#8691a6; font-size:7px; }.arrangement-plan-grid h3 { margin:4px 0 0; font-size:12px; }.arrangement-plan-grid article > div { display:grid; grid-template-columns:auto 1fr auto 1fr auto 1fr; align-items:baseline; gap:5px; margin:15px 0; }.arrangement-plan-grid article > div strong { color:var(--navy); font-family:Georgia,serif; font-size:20px; font-weight:400; }.arrangement-plan-grid article > div small,.arrangement-plan-grid p,.arrangement-plan-grid li { color:#858fa1; font-size:7px; }.arrangement-plan-grid ul { margin:8px 0 0; padding-left:17px; }.arrangement-ok { color:#2c755c !important; } +.subject-room-list,.admit-subject-rooms { display:grid; gap:4px; }.subject-room-list span,.admit-subject-rooms span { display:grid; grid-template-columns:minmax(45px,auto) 1fr; gap:7px; }.subject-room-list b,.admit-subject-rooms b { font-size:8px; }.subject-room-list small,.admit-subject-rooms small { color:#7f899c; font-size:7px; }.admit-preview table { width:100%; margin-top:16px; border-collapse:collapse; }.admit-preview th,.admit-preview td { padding:8px; border:1px solid var(--line); font-size:8px; text-align:left; } +@media (max-width: 1000px) { .arrangement-fields,.arrangement-rules { grid-template-columns:repeat(2,minmax(0,1fr)); } } +@media (max-width: 620px) { .arrangement-fields,.arrangement-rules,.arrangement-plan-grid { grid-template-columns:1fr; }.arrangement-actions { align-items:stretch; flex-direction:column; } } @media (max-width: 620px) { .exam-score-band { grid-template-columns:1fr; }.exam-score-band > span + span { border-top:1px solid rgba(255,255,255,.12); border-left:0; } .exam-form-section { padding:13px; }.exam-form-section > header { align-items:flex-start; }.exam-form-section > header aside { display:grid; gap:2px; } diff --git a/tests/system.test.mjs b/tests/system.test.mjs index 69f995c..1bde7dd 100644 --- a/tests/system.test.mjs +++ b/tests/system.test.mjs @@ -286,7 +286,11 @@ try { registrationStart: new Date(now - hour).toISOString(), registrationEnd: new Date(now + 24 * hour).toISOString(), examStart: new Date(now + 48 * hour).toISOString(), examEnd: new Date(now + 60 * hour).toISOString(), admitDownloadStart: new Date(now - hour).toISOString(), admitDownloadEnd: new Date(now + 47 * hour).toISOString(), - location: '测试考点', status: 'published', subjects: ['语文', '数学', '外语'] + location: '测试考点', status: 'published', subjects: [ + { name: '语文', date: new Date(now + 48 * hour).toISOString().slice(0, 10), start: '09:00', end: '11:00', fee: 0, fullScore: 150, passScore: 90 }, + { name: '数学', date: new Date(now + 48 * hour).toISOString().slice(0, 10), start: '13:00', end: '15:00', fee: 0, fullScore: 150, passScore: 90 }, + { name: '外语', date: new Date(now + 48 * hour).toISOString().slice(0, 10), start: '15:30', end: '17:00', fee: 0, fullScore: 150, passScore: 90 } + ] } }); assert.equal(createExam.response.status, 201); @@ -379,8 +383,32 @@ try { assert.equal(approveRegistration.data.registration.status, 'approved'); assert.equal(approveRegistration.data.registration.registrationNumber, candidateNumber, '考试报名必须复用考生账户的固定报名号'); - const generateAdmit = await admin.request(`/api/admin/registrations/${registrationId}/admit-card`, { method: 'POST', body: { testCenter: '海州市测试中学' } }); - assert.ok(generateAdmit.data.admitCard.number, '管理员应能生成准考证号'); + const arrangementContext = await admin.request('/api/admin/admission-arrangements'); + assert.equal(arrangementContext.data.rules.length, 4, '系统应预置四种准考证号规则'); + assert.deepEqual(arrangementContext.data.mixingScopes.map(item => item.code), ['class', 'school', 'district', 'city', 'province'], '应支持班、校、县区、市、省五级混编'); + for (const scope of arrangementContext.data.mixingScopes) { + const preview = await admin.request(`/api/admin/exams/${exam.id}/admission-arrangement/preview`, { method: 'POST', body: { + mixingScope: scope.code, numberRuleId: arrangementContext.data.rules[0].id, seed: `scope-${scope.code}` + } }); + assert.equal(preview.response.status, 200, `${scope.name}应通过容量与同考点约束预检`); + } + for (const rule of arrangementContext.data.rules) { + const preview = await admin.request(`/api/admin/exams/${exam.id}/admission-arrangement/preview`, { method: 'POST', body: { + mixingScope: 'school', numberRuleId: rule.id, seed: `rule-${rule.code}` + } }); + assert.equal(preview.response.status, 200, `${rule.name}应能生成有效号码`); + assert.equal(new Set(preview.data.samples.map(item => item.number)).size, preview.data.samples.length, `${rule.name}生成的样例号码不得重复`); + } + const arrangementBody = { mixingScope: 'school', numberRuleId: 'admit_rule_district_room_seat', seed: 'system-test-stable' }; + const arrangementPreview = await admin.request(`/api/admin/exams/${exam.id}/admission-arrangement/preview`, { method: 'POST', body: arrangementBody }); + assert.equal(arrangementPreview.response.status, 200, '正式写入前应可预检整场编排'); + assert.ok(arrangementPreview.data.summary.subjectAssignmentCount >= arrangementPreview.data.summary.candidateCount, '预检应统计全部科次座位'); + const generateAdmit = await admin.request(`/api/admin/exams/${exam.id}/admission-arrangement`, { method: 'POST', body: arrangementBody }); + const generatedCard = generateAdmit.data.cards.find(item => item.registrationId === registrationId); + assert.ok(generatedCard.number, '管理员应能按整场考试批量生成准考证号'); + assert.equal(new Set(generatedCard.assignments.map(item => item.centerId)).size, 1, '同一考生所有科目必须固定在同一考点'); + assert.equal(generatedCard.assignments.length, submitRegistration.data.registration.subjectIds.length, '每个报考科目都必须有独立考场座位'); + assert.equal((await admin.request(`/api/admin/registrations/${registrationId}/admit-card`, { method: 'POST', body: {} })).response.status, 410, '旧单人生成入口应明确停用'); const candidateRegistrations = await candidate.request('/api/candidate/registrations'); const candidateRegistration = candidateRegistrations.data.registrations.find(item => item.id === registrationId); @@ -409,7 +437,10 @@ try { await futureSchoolClient.request(`/api/admin/registrations/${futureRegistrationId}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '学校通过' } }); const approvedFutureRegistration = await admin.request(`/api/admin/registrations/${futureRegistrationId}`, { method: 'PATCH', body: { status: 'approved', reviewNote: '通过' } }); assert.equal(approvedFutureRegistration.data.registration.registrationNumber, candidateNumber, '同一考生参加不同考试必须保持相同报名号'); - await admin.request(`/api/admin/registrations/${futureRegistrationId}/admit-card`, { method: 'POST', body: { testCenter: '海州市测试中学' } }); + const futureArrangement = await admin.request(`/api/admin/exams/${futureExam.id}/admission-arrangement`, { method: 'POST', body: { + mixingScope: 'district', numberRuleId: 'admit_rule_candidate_school_room_seat', seed: 'future-window-test' + } }); + assert.equal(futureArrangement.response.status, 200, '不同考试应分别保存独立编排方案'); const earlyDownload = await candidate.request(`/api/candidate/registrations/${futureRegistrationId}/admit-card`); assert.equal(earlyDownload.response.status, 403, '准考证下载窗口开放前必须拒绝下载'); @@ -479,6 +510,7 @@ try { console.log('✓ 超级、校级、班级管理员的数据范围与权限隔离'); console.log('✓ 固定报名号账户、首次强制改密、完整资料与注册开关'); console.log('✓ 多科目考试创建与考生自主选科报名'); + console.log('✓ 五级混编、四种号码规则、多科目同考点与逐科座位'); console.log('✓ 审批流程设计、同级转交与超级管理员监督退回'); console.log('✓ 校级按班级批量申领、终审原子建号与结果返回'); console.log('✓ 结构化考点考场档案、变更审批与班级只读边界');