全站大数据台账补齐跨页搜索、筛选和分页,包括投档监督、报到审批、成绩复议、公开录取名单等。

后台重构为一级业务域,并将招生录取拆为 5 个二级菜单。
修复成绩等级排名遮挡科目。
成绩单、录取通知书均支持带防伪码和二维码的 PDF 下载。
录取通知书移除“录取专用章”,支持学校自定义模板。
通知书编号采用“学校代码 + 考试代码 + 学校独立流水号”。
完成招生学校报到台账:Y/N/P 状态、暂存、Excel 导入导出、二维码扫描确认。
完成报到提交、计划完成率、补录决定、超级管理员审批和自动公开闭环。
自动公告现已进入公开首页、考生通知及招生学校工作台。
验证结果:
完整 npm test 全部通过。
浏览器端三个角色工作台验收通过。
Excel 模板已实际导出并渲染验证。
两类 PDF 已实际生成、转图检查,二维码、编号和无印章版式正常。
This commit is contained in:
2026-07-22 09:50:52 +08:00 Unverified
parent d029931c31
commit 3e1bced990
22 changed files with 813 additions and 94 deletions
+155 -4
View File
@@ -1,5 +1,6 @@
import { admissionRecords, approvedPlans, remainingPlanQuota } from '../services/volunteer-admission.mjs';
import { admissionPlanProgress, admissionRecords, admissionReportingRecord, approvedPlans, remainingPlanQuota } from '../services/volunteer-admission.mjs';
import { isValidSpecialty, resolveProfileSpecialty, specialtyLabel } from '../data/specialty-types.mjs';
import { systemNotificationItems } from '../services/system-notifications.mjs';
function normalizeCategories(input, cleanText) {
const source = Array.isArray(input) ? input : [];
@@ -16,7 +17,49 @@ function normalizeCategories(input, cleanText) {
}
export function createAdmissionRoutes(context) {
const { database, readDb, sendJson, sendError, readJson, sendWorkbook, buildWorkbook, requireUser, cleanText, maskId, uid, nowIso, logAction } = context;
const { database, readDb, sendJson, sendError, readJson, readBodyBuffer, sendWorkbook, buildWorkbook, parseWorkbook, requireUser, cleanText, maskId, uid, nowIso, logAction, documentVerificationSecret, admissionNoticeCode, safeCodeEqual } = context;
const reportingStatusByCode = { Y: 'reported', N: 'not_reported', P: 'pending' };
const reportingCodeByStatus = { reported: 'Y', not_reported: 'N', pending: 'P' };
function reportingRows(db, plan, record) {
const exam = db.exams.find(item => item.id === plan.examId) || {};
const school = db.schools.find(item => item.id === plan.schoolId) || {};
const rowByPlacement = new Map((record?.payload?.rows || []).map(item => [item.placementId, item]));
const placementIds = new Set((record?.payload?.rows || []).map(item => item.placementId));
const round = Number(record?.payload?.round || 1);
const placements = admissionRecords(db, 'placement', plan.examId).filter(item => item.schoolId === plan.schoolId && item.status === 'final' && (placementIds.has(item.id) || (!record && Number(item.payload?.finalizedRound || 1) === round)));
return placements.map(placement => {
const account = db.users.find(item => item.id === placement.userId) || {};
const profile = db.candidateProfiles.find(item => item.userId === placement.userId) || {};
const row = rowByPlacement.get(placement.id) || {};
return {
placementId: placement.id,
noticeNumber: placement.payload?.noticeNumber || '',
candidateNumber: account.candidateNumber || '',
name: profile.name || account.displayName || '',
idNumberMasked: maskId(profile.idNumber),
examCode: exam.code || '',
schoolCode: school.code || '',
categoryName: placement.payload?.categoryName || '',
status: row.status || 'pending',
statusCode: reportingCodeByStatus[row.status] || 'P',
note: row.note || '',
updatedAt: row.updatedAt || null
};
}).sort((left, right) => left.candidateNumber.localeCompare(right.candidateNumber));
}
function reportingBatch(db, plan, record) {
const exam = db.exams.find(item => item.id === plan.examId) || {};
return { id: record?.id || '', exam: { id: exam.id, code: exam.code, name: exam.name }, round: Number(record?.payload?.round || 1), status: record?.status || 'not_started', rows: reportingRows(db, plan, record), progress: admissionPlanProgress(db, plan), supplementDecision: record?.payload?.supplementDecision || '', decisionNote: record?.payload?.decisionNote || '', approvalNote: record?.payload?.approvalNote || '', updatedAt: record?.updatedAt || null };
}
function editableReportingRecord(db, examId, schoolId) {
const setting = admissionRecords(db, 'setting', examId)[0];
const record = admissionReportingRecord(db, examId, schoolId, Number(setting?.payload?.round || 1)) || admissionReportingRecord(db, examId, schoolId);
return { setting, record };
}
async function handleAdmission(request, response, pathname) {
if (!pathname.startsWith('/api/admission/')) return false;
@@ -27,10 +70,12 @@ export function createAdmissionRoutes(context) {
if (!school) return sendError(response, 403, '招生学校账号未绑定有效学校');
if (request.method === 'GET' && pathname === '/api/admission/context') {
return sendJson(response, 200, { ok: true, school, exams: db.exams.filter(item => !item.archivedAt && admissionRecords(db, 'setting', item.id).some(setting => setting.payload?.enabled)) });
const plans = approvedPlans(db).filter(item => item.schoolId === school.id).map(item => ({ ...item, examName: db.exams.find(exam => exam.id === item.examId)?.name || '', progress: admissionPlanProgress(db, item) }));
const notifications = systemNotificationItems(db).filter(item => item.visible && (!item.schoolId || item.schoolId === school.id)).slice(0, 6).map(item => ({ ...item, id: item.noticeId }));
return sendJson(response, 200, { ok: true, school, plans, notifications, exams: db.exams.filter(item => !item.archivedAt && admissionRecords(db, 'setting', item.id).some(setting => setting.payload?.enabled)) });
}
if (request.method === 'GET' && pathname === '/api/admission/plans') {
const plans = admissionRecords(db, 'plan').filter(item => item.schoolId === school.id).map(plan => ({ ...plan, remainingCategories: remainingPlanQuota(db, plan) }));
const plans = admissionRecords(db, 'plan').filter(item => item.schoolId === school.id).map(plan => ({ ...plan, remainingCategories: remainingPlanQuota(db, plan), progress: admissionPlanProgress(db, plan) }));
return sendJson(response, 200, { ok: true, school, plans, exams: db.exams.filter(item => !item.archivedAt), sourceSchools: db.schools.filter(item => item.active && item.isSourceSchool) });
}
if (request.method === 'POST' && pathname === '/api/admission/plans') {
@@ -80,6 +125,112 @@ export function createAdmissionRoutes(context) {
await database.saveAdmissionRecord(record, logAction(db, user, '保存录取通知书模板', school.name));
return sendJson(response, 200, { ok: true, template, updatedAt: now });
}
if (request.method === 'GET' && pathname === '/api/admission/reporting') {
const plans = approvedPlans(db).filter(item => item.schoolId === school.id);
const batches = plans.map(plan => reportingBatch(db, plan, admissionReportingRecord(db, plan.examId, school.id)));
return sendJson(response, 200, { ok: true, school, batches });
}
if (request.method === 'GET' && pathname === '/api/admission/reporting/export') {
const examId = cleanText(new URL(request.url, 'http://localhost').searchParams.get('examId'), 64);
const plan = approvedPlans(db, examId).find(item => item.schoolId === school.id);
const { record } = editableReportingRecord(db, examId, school.id);
if (!plan || !record) return sendError(response, 404, '当前考试还没有可维护的报到批次');
const rows = reportingRows(db, plan, record).map(item => ({
noticeNumber: item.noticeNumber, candidateNumber: item.candidateNumber, name: item.name,
examCode: item.examCode, schoolCode: item.schoolCode, categoryName: item.categoryName,
reportingStatusCode: item.statusCode, reportingNote: item.note
}));
const buffer = Buffer.from(await buildWorkbook('admission_reporting', rows, { subtitle: `${record.payload?.round || 1} 轮|${school.name}` }));
return sendWorkbook(response, buffer, `${record.payload?.round || 1}轮-${school.name}-考生报到状态.xlsx`);
}
if (request.method === 'POST' && pathname === '/api/admission/reporting/import') {
const examId = cleanText(new URL(request.url, 'http://localhost').searchParams.get('examId'), 64);
const plan = approvedPlans(db, examId).find(item => item.schoolId === school.id);
const { record } = editableReportingRecord(db, examId, school.id);
if (!plan || !record || !['draft', 'rejected'].includes(record.status)) return sendError(response, 409, '当前报到批次不能导入暂存数据');
const imported = await parseWorkbook('admission_reporting', await readBodyBuffer(request));
const available = reportingRows(db, plan, record);
const byNotice = new Map(available.map(item => [item.noticeNumber, item]));
const byCandidate = new Map(available.map(item => [item.candidateNumber, item]));
const seen = new Set();
const updates = [];
for (const item of imported) {
const noticeNumber = cleanText(item.noticeNumber, 100);
const candidateNumber = cleanText(item.candidateNumber, 100);
const target = byNotice.get(noticeNumber);
if (!target || byCandidate.get(candidateNumber)?.placementId !== target.placementId) return sendError(response, 400, `Excel 第 ${item.__row} 行的通知书编号与报名号不属于本校当前报到批次`);
if (seen.has(target.placementId)) return sendError(response, 400, `Excel 第 ${item.__row} 行重复填写同一考生`);
const code = String(item.reportingStatusCode || '').trim().toUpperCase();
if (!reportingStatusByCode[code]) return sendError(response, 400, `Excel 第 ${item.__row} 行报到状态码只能填写 Y、N 或 P`);
seen.add(target.placementId);
updates.push({ placementId: target.placementId, status: reportingStatusByCode[code], note: cleanText(item.reportingNote, 300), updatedAt: nowIso(), source: 'excel' });
}
const merged = new Map((record.payload?.rows || []).map(item => [item.placementId, item]));
updates.forEach(item => merged.set(item.placementId, item));
record.status = 'draft'; record.updatedAt = nowIso(); record.payload = { ...record.payload, rows: [...merged.values()], lastImportedAt: record.updatedAt, lastImportedBy: user.displayName };
await database.saveAdmissionRecord(record, logAction(db, user, 'Excel 暂存考生报到状态', `${school.name} · ${updates.length}`));
return sendJson(response, 200, { ok: true, count: updates.length, batch: reportingBatch({ ...db, admissionRecords: db.admissionRecords.map(item => item.id === record.id ? record : item) }, plan, record) });
}
if (request.method === 'PUT' && pathname === '/api/admission/reporting/draft') {
const body = await readJson(request);
const examId = cleanText(body.examId, 64);
const plan = approvedPlans(db, examId).find(item => item.schoolId === school.id);
const { record } = editableReportingRecord(db, examId, school.id);
if (!plan || !record || !['draft', 'rejected'].includes(record.status)) return sendError(response, 409, '当前报到批次不能修改暂存状态');
const available = new Set(reportingRows(db, plan, record).map(item => item.placementId));
const updates = (Array.isArray(body.rows) ? body.rows : []).map(item => ({ placementId: cleanText(item.placementId, 64), status: cleanText(item.status, 30), note: cleanText(item.note, 300), updatedAt: nowIso(), source: 'manual' }));
if (!updates.length || updates.some(item => !available.has(item.placementId) || !['pending', 'reported', 'not_reported'].includes(item.status))) return sendError(response, 400, '报到暂存数据无效');
const merged = new Map((record.payload?.rows || []).map(item => [item.placementId, item]));
updates.forEach(item => merged.set(item.placementId, item));
record.status = 'draft'; record.updatedAt = nowIso(); record.payload = { ...record.payload, rows: [...merged.values()], savedAt: record.updatedAt, savedBy: user.displayName };
await database.saveAdmissionRecord(record, logAction(db, user, '暂存考生报到状态', `${school.name} · ${updates.length}`));
return sendJson(response, 200, { ok: true, batch: reportingBatch({ ...db, admissionRecords: db.admissionRecords.map(item => item.id === record.id ? record : item) }, plan, record) });
}
if (request.method === 'POST' && pathname === '/api/admission/reporting/scan') {
const body = await readJson(request);
const match = String(body.code || '').toUpperCase().match(/AN-[A-F0-9]{24}/);
if (!match) return sendError(response, 400, '未识别到有效的录取通知书防伪码');
const code = match[0];
const placement = admissionRecords(db, 'placement').find(item => item.schoolId === school.id && item.status === 'final' && safeCodeEqual(code, admissionNoticeCode(documentVerificationSecret, item, db.exams.find(exam => exam.id === item.examId) || {})));
if (!placement) return sendError(response, 404, '该二维码不属于本校有效录取通知书');
const plan = approvedPlans(db, placement.examId).find(item => item.schoolId === school.id);
const { record } = editableReportingRecord(db, placement.examId, school.id);
if (!plan || !record || !['draft', 'rejected'].includes(record.status) || !(record.payload?.rows || []).some(item => item.placementId === placement.id)) return sendError(response, 409, '该考生不在当前可维护的报到批次');
const merged = new Map((record.payload?.rows || []).map(item => [item.placementId, item]));
merged.set(placement.id, { placementId: placement.id, status: 'reported', note: cleanText(body.note, 300) || '扫描录取通知书二维码确认', updatedAt: nowIso(), source: 'qr_scan' });
record.status = 'draft'; record.updatedAt = nowIso(); record.payload = { ...record.payload, rows: [...merged.values()], savedAt: record.updatedAt, savedBy: user.displayName };
await database.saveAdmissionRecord(record, logAction(db, user, '扫码暂存考生报到', `${school.name} · ${placement.payload?.noticeNumber || placement.id}`));
const nextDb = { ...db, admissionRecords: db.admissionRecords.map(item => item.id === record.id ? record : item) };
return sendJson(response, 200, { ok: true, row: reportingRows(nextDb, plan, record).find(item => item.placementId === placement.id), batch: reportingBatch(nextDb, plan, record) });
}
if (request.method === 'POST' && pathname === '/api/admission/reporting/submit') {
const body = await readJson(request);
const examId = cleanText(body.examId, 64);
const plan = approvedPlans(db, examId).find(item => item.schoolId === school.id);
const { record } = editableReportingRecord(db, examId, school.id);
if (!plan || !record || !['draft', 'rejected'].includes(record.status)) return sendError(response, 409, '当前报到批次不能提交');
const rows = reportingRows(db, plan, record);
if (rows.some(item => item.status === 'pending')) return sendError(response, 409, `仍有 ${rows.filter(item => item.status === 'pending').length} 名考生待确认,请全部标记后提交`);
record.status = 'submitted'; record.updatedAt = nowIso(); record.payload = { ...record.payload, submittedAt: record.updatedAt, submittedBy: user.displayName };
await database.saveAdmissionRecord(record, logAction(db, user, '提交考生报到情况', `${school.name} · ${rows.length}`));
const nextDb = { ...db, admissionRecords: db.admissionRecords.map(item => item.id === record.id ? record : item) };
return sendJson(response, 200, { ok: true, batch: reportingBatch(nextDb, plan, record) });
}
if (request.method === 'POST' && pathname === '/api/admission/reporting/decision') {
const body = await readJson(request);
const examId = cleanText(body.examId, 64);
const plan = approvedPlans(db, examId).find(item => item.schoolId === school.id);
const { record } = editableReportingRecord(db, examId, school.id);
if (!plan || !record || record.status !== 'submitted') return sendError(response, 409, '请先提交本轮考生报到情况');
const nextDb = { ...db, admissionRecords: db.admissionRecords.map(item => item.id === record.id ? record : item) };
const progress = admissionPlanProgress(nextDb, plan);
const supplement = body.supplement === true && progress.reportingGap > 0;
const decisionNote = cleanText(body.decisionNote, 500);
if (supplement && decisionNote.length < 4) return sendError(response, 400, '申请补录时请填写至少 4 个字的补录说明');
record.status = 'pending_approval'; record.updatedAt = nowIso(); record.payload = { ...record.payload, supplementDecision: supplement ? 'supplement' : 'no_supplement', decisionNote: decisionNote || (progress.reportingGap ? '经学校研究决定,本轮不进行补录。' : '本校招生计划已完成。'), decisionSubmittedAt: record.updatedAt, decisionSubmittedBy: user.displayName, statistics: progress };
await database.saveAdmissionRecord(record, logAction(db, user, supplement ? '提交补录申请' : '提交不补录决定', `${school.name} · 缺额 ${progress.reportingGap}`));
return sendJson(response, 200, { ok: true, batch: reportingBatch({ ...db, admissionRecords: db.admissionRecords.map(item => item.id === record.id ? record : item) }, plan, record) });
}
if (request.method === 'GET' && pathname === '/api/admission/placements') {
const accountById = new Map(db.users.map(item => [item.id, item]));
const profileByUserId = new Map(db.candidateProfiles.map(item => [item.userId, item]));