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; }