增加勾选、全选本页、批量设置状态和统一备注;应用后点击“暂存当前页”保存。 修复 Excel 文件选择事件被错误放在键盘监听中的问题。 导入后显示读取行数、实际更新人数、未变化人数及状态变化明细;重复导入会明确提示“没有产生变化”。 “拍摄二维码”改为调用 getUserMedia 实时相机,图片选择仅作为备用方式。 扫码后先展示考生确认页,默认选择“确认报到”,点击“暂存”后才修改数据。 后端新增扫码预览接口,预览过程不会提前修改报到状态。
357 lines
31 KiB
JavaScript
357 lines
31 KiB
JavaScript
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 : [];
|
||
return source.map((item, index) => ({
|
||
code: cleanText(item.code || `category_${index + 1}`, 40),
|
||
name: cleanText(item.name, 80),
|
||
quota: Math.max(0, Math.trunc(Number(item.quota || 0))),
|
||
specialtyCategory: cleanText(item.specialtyCategory, 30),
|
||
specialtyType: cleanText(item.specialtyType, 80),
|
||
indicatorAllocations: (Array.isArray(item.indicatorAllocations) ? item.indicatorAllocations : []).map(allocation => ({
|
||
sourceSchoolId: cleanText(allocation.sourceSchoolId, 64), quota: Math.max(0, Math.trunc(Number(allocation.quota || 0)))
|
||
})).filter(item => item.sourceSchoolId && item.quota > 0)
|
||
})).filter(item => item.code && item.name && item.quota > 0);
|
||
}
|
||
|
||
export function createAdmissionRoutes(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 };
|
||
}
|
||
|
||
function reportingScanTarget(db, school, rawCode) {
|
||
const match = String(rawCode || '').toUpperCase().match(/AN-[A-F0-9]{24}/);
|
||
if (!match) return { error: [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 { error: [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 { error: [409, '该考生不在当前可维护的报到批次'] };
|
||
return { code, placement, plan, record };
|
||
}
|
||
|
||
async function handleAdmission(request, response, pathname) {
|
||
if (!pathname.startsWith('/api/admission/')) return false;
|
||
const user = await requireUser(request, response, 'admission_school');
|
||
if (!user) return true;
|
||
const db = request.authDb || await readDb();
|
||
const school = db.schools.find(item => item.id === user.schoolId && item.active && item.isAdmissionSchool);
|
||
if (!school) return sendError(response, 403, '招生学校账号未绑定有效学校');
|
||
|
||
if (request.method === 'GET' && pathname === '/api/admission/context') {
|
||
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), 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') {
|
||
const body = await readJson(request);
|
||
const exam = db.exams.find(item => item.id === cleanText(body.examId, 64) && !item.archivedAt);
|
||
if (!exam) return sendError(response, 404, '考试不存在或已经归档');
|
||
const categories = normalizeCategories(body.categories, cleanText);
|
||
if (!categories.length) return sendError(response, 400, '请至少填写一个有效招生类别和计划人数');
|
||
if (new Set(categories.map(item => item.code)).size !== categories.length) return sendError(response, 400, '招生类别代码不能重复');
|
||
if (categories.some(item => !isValidSpecialty(item.specialtyCategory, item.specialtyType))) return sendError(response, 400, '特长生招生类别的大类与小类不对应');
|
||
if (categories.some(item => new Set(item.indicatorAllocations.map(allocation => allocation.sourceSchoolId)).size !== item.indicatorAllocations.length)) return sendError(response, 400, '同一招生类别不能重复分配同一生源校指标');
|
||
if (categories.some(item => item.indicatorAllocations.reduce((sum, entry) => sum + entry.quota, 0) > item.quota)) return sendError(response, 400, '指标分配合计不能超过该类别计划人数');
|
||
if (categories.some(item => item.indicatorAllocations.some(allocation => !db.schools.some(entry => entry.id === allocation.sourceSchoolId && entry.active && entry.isSourceSchool)))) return sendError(response, 400, '指标分配中包含无效的生源学校');
|
||
const existing = admissionRecords(db, 'plan', exam.id).find(item => item.schoolId === school.id);
|
||
if (existing?.status === 'approved') return sendError(response, 409, '已审核通过的招生计划只能由超级管理员调整');
|
||
const now = nowIso();
|
||
const plan = existing || { id: uid('plan'), kind: 'plan', examId: exam.id, userId: user.id, schoolId: school.id, createdAt: now };
|
||
Object.assign(plan, { status: 'pending', updatedAt: now, payload: { categories, note: cleanText(body.note, 500), submittedBy: user.displayName, reviewNote: '' } });
|
||
await database.saveAdmissionRecord(plan, logAction(db, user, '提交招生计划', `${school.name} · ${exam.name}`));
|
||
return sendJson(response, existing ? 200 : 201, { ok: true, plan });
|
||
}
|
||
if (request.method === 'GET' && pathname === '/api/admission/notice-template') {
|
||
const record = admissionRecords(db, 'notification').find(item => item.schoolId === school.id && item.status === 'template');
|
||
const template = record?.payload?.template || {
|
||
eyebrow: 'ADMISSION NOTICE', title: '录 取 通 知 书',
|
||
body: '经审核,你已被我校 {{录取类别}} 正式录取。谨向你表示祝贺!请按学校通知要求办理报到手续。',
|
||
footer: '请妥善保管本通知书,报到时出示。', primaryColor: '#8d2028', accentColor: '#c9a45b'
|
||
};
|
||
return sendJson(response, 200, { ok: true, school, exams: db.exams.filter(item => !item.archivedAt), template, updatedAt: record?.updatedAt || null });
|
||
}
|
||
if (request.method === 'PUT' && pathname === '/api/admission/notice-template') {
|
||
const body = await readJson(request);
|
||
const exam = db.exams.find(item => item.id === cleanText(body.examId, 64)) || db.exams.find(item => !item.archivedAt) || db.exams[0];
|
||
if (!exam) return sendError(response, 409, '系统中还没有可关联的考试,暂时无法保存模板');
|
||
const template = {
|
||
eyebrow: cleanText(body.eyebrow || 'ADMISSION NOTICE', 60),
|
||
title: cleanText(body.title || '录 取 通 知 书', 80),
|
||
body: cleanText(body.body, 1600), footer: cleanText(body.footer, 300),
|
||
primaryColor: /^#[0-9a-f]{6}$/i.test(body.primaryColor) ? body.primaryColor : '#8d2028',
|
||
accentColor: /^#[0-9a-f]{6}$/i.test(body.accentColor) ? body.accentColor : '#c9a45b'
|
||
};
|
||
if (!template.body) return sendError(response, 400, '请填写录取通知书正文');
|
||
const now = nowIso();
|
||
const record = admissionRecords(db, 'notification').find(item => item.schoolId === school.id && item.status === 'template')
|
||
|| { id: uid('notice_template'), kind: 'notification', examId: exam.id, userId: null, schoolId: school.id, status: 'template', createdAt: now };
|
||
Object.assign(record, { examId: exam.id, updatedAt: now, payload: { template, updatedBy: user.displayName } });
|
||
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 = [];
|
||
const changes = [];
|
||
let unchangedCount = 0;
|
||
const importedAt = nowIso();
|
||
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);
|
||
const status = reportingStatusByCode[code];
|
||
const note = cleanText(item.reportingNote, 300);
|
||
if (target.status === status && target.note === note) { unchangedCount += 1; continue; }
|
||
updates.push({ placementId: target.placementId, status, note, updatedAt: importedAt, source: 'excel' });
|
||
changes.push({ placementId: target.placementId, name: target.name, candidateNumber: target.candidateNumber, noticeNumber: target.noticeNumber, from: target.status, to: status, fromCode: target.statusCode, toCode: code, noteChanged: target.note !== note });
|
||
}
|
||
const merged = new Map((record.payload?.rows || []).map(item => [item.placementId, item]));
|
||
updates.forEach(item => merged.set(item.placementId, item));
|
||
if (updates.length) {
|
||
record.status = 'draft'; record.updatedAt = importedAt; 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} 人`));
|
||
}
|
||
const nextDb = updates.length ? { ...db, admissionRecords: db.admissionRecords.map(item => item.id === record.id ? record : item) } : db;
|
||
return sendJson(response, 200, { ok: true, count: imported.length, changedCount: updates.length, unchangedCount, changes, batch: reportingBatch(nextDb, 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-preview') {
|
||
const body = await readJson(request);
|
||
const target = reportingScanTarget(db, school, body.code);
|
||
if (target.error) return sendError(response, ...target.error);
|
||
const { code, placement, plan, record } = target;
|
||
if (body.examId && cleanText(body.examId, 64) !== placement.examId) return sendError(response, 400, '二维码不属于当前考试报到批次');
|
||
const row = reportingRows(db, plan, record).find(item => item.placementId === placement.id);
|
||
return sendJson(response, 200, { ok: true, code, examId: placement.examId, row });
|
||
}
|
||
if (request.method === 'POST' && pathname === '/api/admission/reporting/scan') {
|
||
const body = await readJson(request);
|
||
const target = reportingScanTarget(db, school, body.code);
|
||
if (target.error) return sendError(response, ...target.error);
|
||
const { placement, plan, record } = target;
|
||
if (body.examId && cleanText(body.examId, 64) !== placement.examId) return sendError(response, 400, '二维码不属于当前考试报到批次');
|
||
const status = cleanText(body.status, 30);
|
||
if (!['reported', 'not_reported', 'pending'].includes(status)) return sendError(response, 400, '请选择有效的报到确认状态');
|
||
const merged = new Map((record.payload?.rows || []).map(item => [item.placementId, item]));
|
||
const fallbackNote = status === 'reported' ? '扫描录取通知书二维码确认报到' : status === 'not_reported' ? '扫描录取通知书二维码确认未报到' : '扫描录取通知书二维码后暂待确认';
|
||
merged.set(placement.id, { placementId: placement.id, status, note: cleanText(body.note, 300) || fallbackNote, 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} · ${reportingCodeByStatus[status]}`));
|
||
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]));
|
||
const examById = new Map(db.exams.map(item => [item.id, item]));
|
||
const registrationByExamUser = new Map(db.registrations.map(item => [`${item.examId}\u0000${item.userId}`, item]));
|
||
const publishedResultsByRegistration = new Map();
|
||
for (const result of db.results) {
|
||
if (!result.published) continue;
|
||
const rows = publishedResultsByRegistration.get(result.registrationId) || [];
|
||
rows.push(result);
|
||
publishedResultsByRegistration.set(result.registrationId, rows);
|
||
}
|
||
const placements = admissionRecords(db, 'placement').filter(item => item.schoolId === school.id).map(item => {
|
||
const account = accountById.get(item.userId) || {};
|
||
const profile = profileByUserId.get(item.userId) || {};
|
||
const exam = examById.get(item.examId);
|
||
const registration = registrationByExamUser.get(`${item.examId}\u0000${item.userId}`);
|
||
const results = (publishedResultsByRegistration.get(registration?.id) || []).map(result => ({ subjectName: exam?.subjects.find(subject => subject.id === result.subjectId)?.name || result.subjectId, score: result.score }));
|
||
const qualification = resolveProfileSpecialty(profile);
|
||
return { ...item, examName: exam?.name || item.examId, candidate: { registrationNumber: account.candidateNumber, name: profile.name, gender: profile.gender, idNumberMasked: maskId(profile.idNumber), specialtyCategory: qualification.category, specialtyType: qualification.type, specialtyLabel: specialtyLabel(qualification.category, qualification.type), specialtyCertificate: profile.specialtyCertificate || '', policyEligibility: profile.policyEligibility || '' }, featureScore: Number(registration?.featureScore || 0), results };
|
||
});
|
||
const completedExams = db.exams.filter(exam => admissionRecords(db, 'setting', exam.id).some(setting => setting.status === 'completed') && placements.some(item => item.examId === exam.id && item.status === 'final'));
|
||
return sendJson(response, 200, { ok: true, school, placements, completedExams });
|
||
}
|
||
if (request.method === 'GET' && pathname === '/api/admission/placements/export') {
|
||
const examId = cleanText(new URL(request.url, 'http://localhost').searchParams.get('examId'), 64);
|
||
const exam = db.exams.find(item => item.id === examId);
|
||
const setting = admissionRecords(db, 'setting', examId)[0];
|
||
if (!exam || setting?.status !== 'completed') return sendError(response, 409, '录取工作结束后才能下载正式录取名单');
|
||
const rows = admissionRecords(db, 'placement', examId).filter(item => item.schoolId === school.id && item.status === 'final').map(item => {
|
||
const account = db.users.find(entry => entry.id === item.userId) || {};
|
||
const profile = db.candidateProfiles.find(entry => entry.userId === item.userId) || {};
|
||
const registration = db.registrations.find(entry => entry.examId === examId && entry.userId === item.userId) || {};
|
||
const sourceSchool = db.schools.find(entry => entry.id === profile.schoolId) || {};
|
||
const schoolClass = db.classes.find(entry => entry.id === profile.classId) || {};
|
||
const qualification = resolveProfileSpecialty(profile);
|
||
const scoreRows = db.results.filter(entry => entry.registrationId === registration.id && entry.published).map(result => ({ name: exam.subjects.find(subject => subject.id === result.subjectId)?.name || result.subjectId, score: result.score }));
|
||
return {
|
||
candidateNumber: account.candidateNumber || registration.registrationNumber || '', name: profile.name || account.displayName || '', gender: profile.gender || '',
|
||
idNumber: profile.idNumber || '', phone: profile.phone || '', email: profile.email || '', birthDate: profile.birthDate || '', ethnicity: profile.ethnicity || '', nativePlace: profile.nativePlace || '',
|
||
sourceSchool: sourceSchool.name || profile.school || '', sourceSchoolCode: sourceSchool.code || '', className: schoolClass.name || profile.grade || '',
|
||
address: [profile.provinceName, profile.cityName, profile.districtName, profile.address].filter(Boolean).join(' '), guardianName: profile.guardianName || profile.emergencyContact || '', guardianPhone: profile.guardianPhone || profile.emergencyPhone || '',
|
||
specialty: specialtyLabel(qualification.category, qualification.type) || '普通生', specialtyCertificate: profile.specialtyCertificate || '', policyEligibility: profile.policyEligibility || '',
|
||
featureScore: Number(registration.featureScore || 0), subjectScores: scoreRows.map(score => `${score.name} ${score.score}`).join(';'), totalScore: Number(item.payload?.totalScore || 0),
|
||
admittedSchool: school.name, categoryName: item.payload?.categoryName || '', preferenceOrder: Number(item.payload?.preferenceOrder || 0)
|
||
};
|
||
});
|
||
const buffer = Buffer.from(await buildWorkbook('admitted_candidates', rows, { subtitle: `${exam.name}|${school.name}` }));
|
||
return sendWorkbook(response, buffer, `${exam.name}-${school.name}-录取考生信息.xlsx`);
|
||
}
|
||
if (request.method === 'POST' && pathname === '/api/admission/placements/bulk') {
|
||
const body = await readJson(request);
|
||
const ids = [...new Set((Array.isArray(body.ids) ? body.ids : []).map(id => cleanText(id, 64)).filter(Boolean))];
|
||
const decision = cleanText(body.decision, 30);
|
||
const note = cleanText(body.note, 500);
|
||
if (!ids.length) return sendError(response, 400, '请至少选择一名待审核考生');
|
||
if (!['accept', 'withdraw'].includes(decision)) return sendError(response, 400, '请选择接收或申请退档');
|
||
if (decision === 'withdraw' && note.length < 8) return sendError(response, 400, '批量申请退档必须填写至少 8 个字的特殊理由');
|
||
const placements = admissionRecords(db, 'placement').filter(item => ids.includes(item.id) && item.schoolId === school.id && item.status === 'school_review');
|
||
if (placements.length !== ids.length) return sendError(response, 409, '所选记录中包含已处理或不属于本校的投档记录,请刷新后重试');
|
||
const now = nowIso();
|
||
for (const placement of placements) {
|
||
placement.status = decision === 'accept' ? 'admitted' : 'withdrawal_pending';
|
||
placement.payload.schoolDecisionNote = note;
|
||
if (decision === 'withdraw') placement.payload.withdrawalReason = note;
|
||
placement.updatedAt = now;
|
||
}
|
||
await database.saveAdmissionRecords(placements, logAction(db, user, decision === 'accept' ? '批量接收投档考生' : '批量申请退档', `${school.name} · ${placements.length} 人`));
|
||
return sendJson(response, 200, { ok: true, count: placements.length, decision });
|
||
}
|
||
const placementMatch = pathname.match(/^\/api\/admission\/placements\/([^/]+)$/);
|
||
if (request.method === 'PATCH' && placementMatch) {
|
||
const placement = admissionRecords(db, 'placement').find(item => item.id === placementMatch[1] && item.schoolId === school.id);
|
||
if (!placement || placement.status !== 'school_review') return sendError(response, 404, '待审核投档记录不存在');
|
||
const body = await readJson(request);
|
||
const decision = cleanText(body.decision, 30);
|
||
const note = cleanText(body.note, 500);
|
||
if (decision === 'accept') placement.status = 'admitted';
|
||
else if (decision === 'withdraw') {
|
||
if (note.length < 8) return sendError(response, 400, '申请退档必须填写至少 8 个字的特殊理由');
|
||
placement.status = 'withdrawal_pending';
|
||
placement.payload.withdrawalReason = note;
|
||
} else return sendError(response, 400, '请选择接收或申请退档');
|
||
placement.payload.schoolDecisionNote = note;
|
||
placement.updatedAt = nowIso();
|
||
await database.saveAdmissionRecord(placement, logAction(db, user, decision === 'accept' ? '接收投档考生' : '申请退档', `${school.name} · ${placement.id}`));
|
||
return sendJson(response, 200, { ok: true, placement });
|
||
}
|
||
return sendError(response, 404, '招生学校功能接口不存在');
|
||
}
|
||
|
||
return handleAdmission;
|
||
}
|