Add indicator qualification and admissions announcements

This commit is contained in:
2026-07-21 14:37:06 +08:00 Unverified
parent 5e0bee60e2
commit 6be1ff227a
17 changed files with 332 additions and 70 deletions
+48 -5
View File
@@ -1,6 +1,6 @@
import { admissionMixingScopes, buildAdmissionArrangement } from '../services/admission-arrangement.mjs';
import { noticeForClient, noticePlainText, sanitizeNoticeContent } from '../security/notice-content.mjs';
import { admissionPhases, admissionRecords, admissionSetting, buildVolunteerPlacements, candidateTotalScore, publicAdmissionRows, remainingPlanQuota } from '../services/volunteer-admission.mjs';
import { admissionCutoffRows, admissionPhases, admissionRecords, admissionSetting, buildVolunteerPlacements, candidateTotalScore, publicAdmissionRows, remainingPlanQuota, sourceSchoolQualificationStatus } from '../services/volunteer-admission.mjs';
import { isValidSpecialty, resolveProfileSpecialty, specialtyLabel } from '../data/specialty-types.mjs';
export function createAdminRoutes(context) {
@@ -141,6 +141,44 @@ export function createAdminRoutes(context) {
});
}
if (pathname === '/api/admin/indicator-qualifications' && request.method === 'GET') {
if (user.adminLevel !== 'school' || !user.schoolId) return sendError(response, 403, '只有生源校学校管理员可以确认指标分配资格');
const school = db.schools.find(item => item.id === user.schoolId && item.active && item.isSourceSchool);
if (!school) return sendError(response, 403, '当前学校未设置为生源学校');
const exams = admissionRecords(db, 'setting').filter(item => item.payload?.enabled).map(setting => {
const exam = db.exams.find(item => item.id === setting.examId);
return { ...setting, exam: exam ? publicExam(exam) : null, qualificationStatus: sourceSchoolQualificationStatus(db, setting.examId, school.id) };
}).filter(item => item.exam);
return sendJson(response, 200, { ok: true, school, exams });
}
const qualificationMatch = pathname.match(/^\/api\/admin\/indicator-qualifications\/([^/]+)\/([^/]+)$/);
if (qualificationMatch && request.method === 'PUT') {
if (user.adminLevel !== 'school' || !user.schoolId) return sendError(response, 403, '只有生源校学校管理员可以确认指标分配资格');
const setting = admissionSetting(db, qualificationMatch[1]);
const profile = db.candidateProfiles.find(item => item.userId === qualificationMatch[2] && item.schoolId === user.schoolId && item.profileCompleted);
const account = db.users.find(item => item.id === profile?.userId && item.role === 'candidate' && item.active);
if (!setting?.payload?.enabled) return sendError(response, 404, '该考试未启用志愿填报');
if (!profile || !account) return sendError(response, 404, '本校有效考生不存在');
const body = await readJson(request);
if (typeof body.eligible !== 'boolean') return sendError(response, 400, '请选择有或无指标分配资格');
const now = nowIso();
const existing = admissionRecords(db, 'indicator_qualification', setting.examId).find(item => item.userId === profile.userId);
const qualification = existing || { id: uid('indicator_qualification'), kind: 'indicator_qualification', examId: setting.examId, userId: profile.userId, schoolId: user.schoolId, createdAt: now };
Object.assign(qualification, { status: 'confirmed', updatedAt: now, payload: { eligible: body.eligible, confirmedBy: user.displayName, confirmedAt: now } });
const nextRecords = [...db.admissionRecords.filter(item => item.id !== qualification.id), qualification];
const nextDb = { ...db, admissionRecords: nextRecords };
const status = sourceSchoolQualificationStatus(nextDb, setting.examId, user.schoolId);
const records = [qualification];
if (status.complete) {
const published = admissionRecords(nextDb, 'qualification_publication', setting.examId).find(item => item.schoolId === user.schoolId);
const publication = published || { id: uid('qualification_publication'), kind: 'qualification_publication', examId: setting.examId, userId: user.id, schoolId: user.schoolId, createdAt: now };
Object.assign(publication, { status: 'published', updatedAt: now, payload: { publishedAt: now, rows: status.rows } });
records.push(publication);
}
await database.saveAdmissionRecords(records, logAction(db, user, '确认指标分配资格', `${account.candidateNumber} · ${body.eligible ? '有资格' : '无资格'}${status.complete ? ' · 全校已自动公示' : ''}`));
return sendJson(response, 200, { ok: true, qualification, qualificationStatus: status, published: status.complete, message: status.complete ? '资格已确认;本校全部考生确认完成,公示已自动发布' : '资格已确认' });
}
if (pathname === '/api/admin/admissions' && request.method === 'GET') {
if (user.adminLevel !== 'super') return sendError(response, 403, '只有超级管理员可以查看志愿与录取数据');
const settings = admissionRecords(db, 'setting').map(setting => ({ ...setting, exam: db.exams.find(item => item.id === setting.examId), publicRows: setting.status === 'completed' ? publicAdmissionRows(db, setting.examId) : [] }));
@@ -184,7 +222,7 @@ export function createAdminRoutes(context) {
const status = setting.status && !manualPhases.includes(setting.status) ? setting.status : manualPhases.includes(requestedStatus) ? requestedStatus : (setting.status || 'draft');
setting.status = status;
setting.updatedAt = now;
setting.payload = { ...setting.payload, enabled: body.enabled === true, preferenceStart: cleanText(body.preferenceStart, 35), preferenceEnd: cleanText(body.preferenceEnd, 35), maxChoices: Math.min(20, Math.max(1, Math.trunc(Number(body.maxChoices || 5)))), round: Math.max(1, Math.trunc(Number(body.round || setting.payload?.round || 1))), autoPublish: body.autoPublish !== false, progress: cleanText(body.progress, 200) || '等待志愿填报开始' };
setting.payload = { ...setting.payload, enabled: body.enabled === true, preferenceStart: cleanText(body.preferenceStart, 35), preferenceEnd: cleanText(body.preferenceEnd, 35), maxChoices: Math.min(20, Math.max(1, Math.trunc(Number(body.maxChoices || 5)))), maxSubmissions: Math.min(50, Math.max(1, Math.trunc(Number(body.maxSubmissions || 3)))), round: Math.max(1, Math.trunc(Number(body.round || setting.payload?.round || 1))), autoPublish: body.autoPublish !== false, progress: cleanText(body.progress, 200) || '等待志愿填报开始' };
if (setting.payload.preferenceStart && setting.payload.preferenceEnd && new Date(setting.payload.preferenceStart) >= new Date(setting.payload.preferenceEnd)) return sendError(response, 400, '志愿填报结束时间必须晚于开始时间');
await database.saveAdmissionRecord(setting, logAction(db, user, '设置志愿填报', `${exam.name} · ${status}`));
return sendJson(response, 200, { ok: true, setting });
@@ -241,9 +279,14 @@ export function createAdminRoutes(context) {
const now = nowIso();
const admitted = placements.filter(item => item.status === 'admitted').map(item => ({ ...item, status: 'final', updatedAt: now }));
const notifications = admitted.map(item => ({ id: uid('notification'), kind: 'notification', examId: setting.examId, userId: item.userId, schoolId: item.schoolId, status: 'unread', createdAt: now, updatedAt: now, payload: { title: '录取结果通知', message: `你已被${db.schools.find(school => school.id === item.schoolId)?.name || '招生学校'}录取`, placementId: item.id } }));
setting.status = 'completed'; setting.updatedAt = now; setting.payload.progress = '本次录取工作已结束,录取结果已经通知并自动公'; setting.payload.completedAt = now;
await database.saveAdmissionRecords([setting, ...admitted, ...notifications], logAction(db, user, '结束录取并发布结果', `${setting.examId} · ${admitted.length}`));
return sendJson(response, 200, { ok: true, admittedCount: admitted.length, publicRows: publicAdmissionRows({ ...db, admissionRecords: [...db.admissionRecords.filter(item => !admitted.some(entry => entry.id === item.id)), ...admitted] }, setting.examId) });
setting.status = 'completed'; setting.updatedAt = now; setting.payload.progress = '本次录取工作已结束,录取结果与分数线已经自动公'; setting.payload.completedAt = now;
const completedDb = { ...db, admissionRecords: [...db.admissionRecords.filter(item => !admitted.some(entry => entry.id === item.id)), ...admitted] };
const cutoffRows = admissionCutoffRows(completedDb, setting.examId);
const existingCutoff = admissionRecords(db, 'cutoff_publication', setting.examId)[0];
const cutoffPublication = existingCutoff || { id: uid('cutoff_publication'), kind: 'cutoff_publication', examId: setting.examId, userId: user.id, schoolId: null, createdAt: now };
Object.assign(cutoffPublication, { status: 'published', updatedAt: now, payload: { publishedAt: now, rows: cutoffRows } });
await database.saveAdmissionRecords([setting, ...admitted, ...notifications, cutoffPublication], logAction(db, user, '结束录取并发布结果与分数线', `${setting.examId} · ${admitted.length}`));
return sendJson(response, 200, { ok: true, admittedCount: admitted.length, publicRows: publicAdmissionRows(completedDb, setting.examId), cutoffRows });
}
const body = await readJson(request);
const now = nowIso();
+44 -11
View File
@@ -1,5 +1,5 @@
import { noticeForClient } from '../security/notice-content.mjs';
import { admissionRecords, admissionSetting, activePreference, approvedPlans, candidateTotalScore, remainingPlanQuota } from '../services/volunteer-admission.mjs';
import { admissionRecords, admissionSetting, activePreference, approvedPlans, candidateTotalScore, indicatorQualification, remainingPlanQuota } from '../services/volunteer-admission.mjs';
import { candidateEligibleForCategory, isValidSpecialty, resolveProfileSpecialty } from '../data/specialty-types.mjs';
export function createCandidateRoutes(context) {
@@ -162,16 +162,29 @@ export function createCandidateRoutes(context) {
const exam = db.exams.find(item => item.id === setting.examId);
const round = Number(setting.payload?.round || 1);
const preference = activePreference(db, setting.examId, user.id, round);
const qualification = indicatorQualification(db, setting.examId, user.id);
const placement = admissionRecords(db, 'placement', setting.examId).find(item => item.userId === user.id && item.status !== 'withdrawn');
const plans = approvedPlans(db, setting.examId).map(plan => {
const school = db.schools.find(item => item.id === plan.schoolId);
const placements = admissionRecords(db, 'placement', setting.examId).filter(item => item.schoolId === plan.schoolId && item.status !== 'withdrawn');
return {
id: plan.id, schoolId: plan.schoolId, schoolCode: school?.code || '', schoolName: school?.name || '',
categories: remainingPlanQuota(db, plan).filter(category => candidateEligibleForCategory(profile, category))
categories: remainingPlanQuota(db, plan).filter(category => candidateEligibleForCategory(profile, category)).map(category => {
const indicatorAllocation = (category.indicatorAllocations || []).find(item => item.sourceSchoolId === profile.schoolId);
const indicatorUsed = placements.filter(item => item.payload?.categoryCode === category.code && item.payload?.quotaBucket === `indicator:${profile.schoolId}`).length;
const generalQuota = Math.max(0, Number(category.quota || 0) - (category.indicatorAllocations || []).reduce((sum, item) => sum + Number(item.quota || 0), 0));
const generalUsed = placements.filter(item => item.payload?.categoryCode === category.code && item.payload?.quotaBucket === 'general').length;
const indicatorRemaining = Math.max(0, Number(indicatorAllocation?.quota || 0) - indicatorUsed);
const generalRemaining = Math.max(0, generalQuota - generalUsed);
const preferenceTypes = [generalRemaining > 0 ? 'general' : '', qualification?.payload?.eligible && indicatorRemaining > 0 ? 'indicator' : ''].filter(Boolean);
return { ...category, generalRemaining, indicatorRemaining, preferenceTypes };
}).filter(category => category.preferenceTypes.length)
};
}).filter(plan => plan.categories.length);
const registration = db.registrations.find(item => item.examId === setting.examId && item.userId === user.id);
return { ...setting, exam: exam ? publicExam(exam) : null, preference, placement, plans, totalScore: candidateTotalScore(db, setting.examId, user.id), featureScore: Number(registration?.featureScore || 0), specialtyQualification: resolveProfileSpecialty(profile) };
const submissionCount = Number(preference?.payload?.submissionCount || 0);
const maxSubmissions = Math.max(1, Number(setting.payload?.maxSubmissions || 3));
return { ...setting, exam: exam ? publicExam(exam) : null, preference, placement, plans, totalScore: candidateTotalScore(db, setting.examId, user.id), featureScore: Number(registration?.featureScore || 0), specialtyQualification: resolveProfileSpecialty(profile), indicatorQualification: qualification, submissionCount, maxSubmissions, remainingSubmissions: Math.max(0, maxSubmissions - submissionCount), preferenceLocked: submissionCount >= maxSubmissions };
}).filter(item => item.exam);
const notifications = admissionRecords(db, 'notification').filter(item => item.userId === user.id);
return sendJson(response, 200, { ok: true, admissions: settings, notifications });
@@ -187,17 +200,37 @@ export function createCandidateRoutes(context) {
if (candidateTotalScore(db, setting.examId, user.id) == null) return sendError(response, 403, '本场考试成绩全部发布后才能填报志愿');
const body = await readJson(request);
const maxChoices = Math.max(1, Number(setting.payload.maxChoices || 5));
const choices = (Array.isArray(body.choices) ? body.choices : []).slice(0, maxChoices).map(item => ({ schoolId: cleanText(item.schoolId, 64), categoryCode: cleanText(item.categoryCode, 40) }));
if (!choices.length) return sendError(response, 400, '请至少选择一个志愿');
if (new Set(choices.map(item => `${item.schoolId}|${item.categoryCode}`)).size !== choices.length) return sendError(response, 400, '同一学校和招生类别不能重复填报');
const plans = approvedPlans(db, setting.examId);
if (choices.some(choice => !plans.some(plan => plan.schoolId === choice.schoolId && plan.payload?.categories?.some(category => category.code === choice.categoryCode && candidateEligibleForCategory(profile, category))))) return sendError(response, 400, '志愿中包含未审核通过或与本人资格不符的招生类别');
const round = Number(setting.payload.round || 1);
const currentPreference = activePreference(db, setting.examId, user.id, round);
const maxSubmissions = Math.max(1, Number(setting.payload.maxSubmissions || 3));
const submissionCount = Number(currentPreference?.payload?.submissionCount || 0);
if (submissionCount >= maxSubmissions) return sendError(response, 409, `志愿已达到 ${maxSubmissions} 次提交上限,现已自动锁定`);
const choices = (Array.isArray(body.choices) ? body.choices : []).slice(0, maxChoices + 1).map(item => ({ schoolId: cleanText(item.schoolId, 64), categoryCode: cleanText(item.categoryCode, 40), preferenceType: item.preferenceType === 'indicator' ? 'indicator' : 'general' }));
if (!choices.length) return sendError(response, 400, '请至少选择一个志愿');
const indicatorChoices = choices.filter(item => item.preferenceType === 'indicator');
const generalChoices = choices.filter(item => item.preferenceType === 'general');
if (indicatorChoices.length > 1 || generalChoices.length > maxChoices) return sendError(response, 400, `本轮最多填报 1 个指标分配志愿和 ${maxChoices} 个普通志愿`);
if (indicatorChoices.length && choices[0].preferenceType !== 'indicator') return sendError(response, 400, '指标分配志愿必须位于专用第一栏');
if (new Set(choices.map(item => `${item.preferenceType}|${item.schoolId}|${item.categoryCode}`)).size !== choices.length) return sendError(response, 400, '同类志愿中同一学校和招生类别不能重复填报');
const plans = approvedPlans(db, setting.examId);
const indicator = indicatorQualification(db, setting.examId, user.id);
const invalidChoice = choices.some(choice => !plans.some(plan => plan.schoolId === choice.schoolId && plan.payload?.categories?.some(category => {
if (category.code !== choice.categoryCode || !candidateEligibleForCategory(profile, category)) return false;
const placements = admissionRecords(db, 'placement', setting.examId).filter(item => item.schoolId === plan.schoolId && item.payload?.categoryCode === category.code && item.status !== 'withdrawn');
if (choice.preferenceType === 'indicator') {
const allocation = (category.indicatorAllocations || []).find(item => item.sourceSchoolId === profile.schoolId);
const used = placements.filter(item => item.payload?.quotaBucket === `indicator:${profile.schoolId}`).length;
return indicator?.payload?.eligible === true && Number(allocation?.quota || 0) > used;
}
const quota = Number(category.quota || 0) - (category.indicatorAllocations || []).reduce((sum, item) => sum + Number(item.quota || 0), 0);
return quota > placements.filter(item => item.payload?.quotaBucket === 'general').length;
})));
if (invalidChoice) return sendError(response, 400, '志愿中包含未审核通过、无剩余对应计划或与本人资格不符的招生类别');
const nowValue = nowIso();
const preference = activePreference(db, setting.examId, user.id, round) || { id: uid('preference'), kind: 'preference', examId: setting.examId, userId: user.id, schoolId: null, createdAt: nowValue };
Object.assign(preference, { status: 'submitted', updatedAt: nowValue, payload: { round, choices, submittedAt: nowValue } });
const preference = currentPreference || { id: uid('preference'), kind: 'preference', examId: setting.examId, userId: user.id, schoolId: null, createdAt: nowValue };
Object.assign(preference, { status: 'submitted', updatedAt: nowValue, payload: { round, choices, submittedAt: nowValue, submissionCount: submissionCount + 1 } });
await database.saveAdmissionRecord(preference);
return sendJson(response, 200, { ok: true, preference, message: '志愿已由本人保存' });
return sendJson(response, 200, { ok: true, preference, remainingSubmissions: Math.max(0, maxSubmissions - submissionCount - 1), locked: submissionCount + 1 >= maxSubmissions, message: submissionCount + 1 >= maxSubmissions ? '志愿已保存并达到提交上限,现已自动锁定' : '志愿已由本人保存' });
}
const scoreAppealMatch = pathname.match(/^\/api\/candidate\/results\/([^/]+)\/appeals$/);
if (request.method === 'POST' && scoreAppealMatch) {
+15 -3
View File
@@ -1,5 +1,5 @@
import { noticeForClient } from '../security/notice-content.mjs';
import { admissionRecords, publicAdmissionRows } from '../services/volunteer-admission.mjs';
import { admissionRecords, admissionSetting, publicAdmissionRows, sourceSchoolQualificationStatus } from '../services/volunteer-admission.mjs';
export function createPublicRoutes(context) {
const {
@@ -58,8 +58,20 @@ export function createPublicRoutes(context) {
const db = await readDb();
const publishedNotices = db.notices.filter(item => item.status === 'published').sort((a, b) => Number(b.pinned) - Number(a.pinned) || new Date(b.publishAt) - new Date(a.publishAt)).map(noticeForClient);
const exams = db.exams.filter(item => item.status === 'published' && !item.archivedAt).map(exam => ({ ...publicExam(exam), registrationCount: db.registrations.filter(reg => reg.examId === exam.id).length }));
const admissionAnnouncements = admissionRecords(db, 'setting').filter(item => item.status === 'completed' && item.payload?.autoPublish !== false).map(setting => ({ examId: setting.examId, examName: db.exams.find(item => item.id === setting.examId)?.name || '', completedAt: setting.payload?.completedAt || setting.updatedAt, rows: publicAdmissionRows(db, setting.examId) }));
return { ok: true, organization: publicSiteConfig.organization, siteCopy: { heroEyebrow: publicSiteConfig.heroEyebrow, heroTitle: publicSiteConfig.heroTitle, heroHighlight: publicSiteConfig.heroHighlight, heroDescription: publicSiteConfig.heroDescription, footerNotice: publicSiteConfig.footerNotice }, schools: db.schools.filter(item => item.active && item.isSourceSchool), classes: db.classes.filter(item => item.active), selfRegistrationEnabled: db.settings.selfRegistrationEnabled, notices: publishedNotices, exams, admissionAnnouncements, stats: { candidates: db.candidateProfiles.length, exams: exams.length, registrations: db.registrations.length } };
return { ok: true, organization: publicSiteConfig.organization, siteCopy: { heroEyebrow: publicSiteConfig.heroEyebrow, heroTitle: publicSiteConfig.heroTitle, heroHighlight: publicSiteConfig.heroHighlight, heroDescription: publicSiteConfig.heroDescription, footerNotice: publicSiteConfig.footerNotice }, schools: db.schools.filter(item => item.active && item.isSourceSchool), classes: db.classes.filter(item => item.active), selfRegistrationEnabled: db.settings.selfRegistrationEnabled, notices: publishedNotices, exams, stats: { candidates: db.candidateProfiles.length, exams: exams.length, registrations: db.registrations.length } };
});
return sendJson(response, 200, payload);
}
if (pathname === '/api/public/announcements') {
const payload = await cache.remember('public', 'admission-announcements', async () => {
const db = await readDb();
const qualifications = admissionRecords(db, 'qualification_publication').filter(item => item.status === 'published' && sourceSchoolQualificationStatus(db, item.examId, item.schoolId).complete).map(item => ({
id: item.id, examId: item.examId, examName: db.exams.find(exam => exam.id === item.examId)?.name || '', schoolName: db.schools.find(school => school.id === item.schoolId)?.name || '', publishedAt: item.payload?.publishedAt || item.updatedAt,
rows: (item.payload?.rows || []).map(row => ({ registrationNumber: row.registrationNumber, name: row.name, eligible: row.eligible === true, specialtyLabel: row.specialtyLabel || '普通生' }))
})).sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt));
const admissions = admissionRecords(db, 'setting').filter(item => item.status === 'completed' && item.payload?.autoPublish !== false).map(setting => ({ id: setting.id, examId: setting.examId, examName: db.exams.find(item => item.id === setting.examId)?.name || '', publishedAt: setting.payload?.completedAt || setting.updatedAt, rows: publicAdmissionRows(db, setting.examId) })).sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt));
const cutoffs = admissionRecords(db, 'cutoff_publication').filter(item => item.status === 'published' && admissionSetting(db, item.examId)?.payload?.autoPublish !== false).map(item => ({ id: item.id, examId: item.examId, examName: db.exams.find(exam => exam.id === item.examId)?.name || '', publishedAt: item.payload?.publishedAt || item.updatedAt, rows: item.payload?.rows || [] })).sort((a, b) => new Date(b.publishedAt) - new Date(a.publishedAt));
return { ok: true, qualifications, admissions, cutoffs };
});
return sendJson(response, 200, payload);
}