招生管理完善

This commit is contained in:
2026-07-22 11:32:28 +08:00 Unverified
parent 411cf6892c
commit 7430c7bb89
7 changed files with 84 additions and 17 deletions
+2 -2
View File
@@ -48,7 +48,7 @@ export function createPublicViews(context) {
const ordinary = (state.publicData.notices || []).filter(item => !String(item.id).startsWith('system-')).map(item => ({ ...item, documentId: item.id, documentType: 'notice', subtype: item.category || '通知公告', publishedAt: item.publishAt })); const ordinary = (state.publicData.notices || []).filter(item => !String(item.id).startsWith('system-')).map(item => ({ ...item, documentId: item.id, documentType: 'notice', subtype: item.category || '通知公告', publishedAt: item.publishAt }));
const plans = (data.plans || []).map(item => ({ ...item, documentId: `plan-${item.id}`, documentType: 'plan', category: '招生公示', subtype: '招生计划', title: `${item.examName} · ${item.schoolName}招生计划公示`, summary: `${item.rows.reduce((sum, row) => sum + Number(row.quota || 0), 0)} 个招生名额,计划审核通过后由系统自动公示。` })); const plans = (data.plans || []).map(item => ({ ...item, documentId: `plan-${item.id}`, documentType: 'plan', category: '招生公示', subtype: '招生计划', title: `${item.examName} · ${item.schoolName}招生计划公示`, summary: `${item.rows.reduce((sum, row) => sum + Number(row.quota || 0), 0)} 个招生名额,计划审核通过后由系统自动公示。` }));
const qualifications = (data.qualifications || []).map(item => ({ ...item, documentId: `qualification-${item.id}`, documentType: 'qualification', category: '录取公示', subtype: '指标资格', title: `${item.examName} · ${item.schoolName}指标分配资格公示`, summary: `本次公开 ${item.rows.length} 名考生的指标分配资格及特长类型。` })); const qualifications = (data.qualifications || []).map(item => ({ ...item, documentId: `qualification-${item.id}`, documentType: 'qualification', category: '录取公示', subtype: '指标资格', title: `${item.examName} · ${item.schoolName}指标分配资格公示`, summary: `本次公开 ${item.rows.length} 名考生的指标分配资格及特长类型。` }));
const admissions = (data.admissions || []).map(item => ({ ...item, documentId: `admission-${item.id}`, documentType: 'admission', category: '录取公示', subtype: '录取名单', title: `${item.examName}最终录取名单`, summary: `${item.rows.length} 名考生正式录取,公开报名号、姓名、总成绩和录取学校。` })); const admissions = (data.admissions || []).map(item => ({ ...item, documentId: `admission-${item.id}`, documentType: 'admission', category: '录取公示', subtype: item.round ? `${item.round} 轮录取名单` : '最终录取名单', title: item.title || `${item.examName}最终录取名单`, summary: `${item.rows.length} 名考生正式录取,公开报名号、姓名、总成绩和录取学校。` }));
const cutoffs = (data.cutoffs || []).map(item => ({ ...item, documentId: `cutoff-${item.id}`, documentType: 'cutoff', category: '录取公示', subtype: '录取分数线', title: `${item.examName}录取分数线`, summary: `按招生学校和招生类别公布 ${item.rows.length} 条最低录取分数线。` })); const cutoffs = (data.cutoffs || []).map(item => ({ ...item, documentId: `cutoff-${item.id}`, documentType: 'cutoff', category: '录取公示', subtype: '录取分数线', title: `${item.examName}录取分数线`, summary: `按招生学校和招生类别公布 ${item.rows.length} 条最低录取分数线。` }));
const reports = (data.reports || []).map(item => ({ ...item, documentId: `reporting-${item.id}`, documentType: 'reporting', category: '录取公示', subtype: item.supplementDecision === 'supplement' ? '报到与补录' : '报到情况', title: item.title, summary: item.summary })); const reports = (data.reports || []).map(item => ({ ...item, documentId: `reporting-${item.id}`, documentType: 'reporting', category: '录取公示', subtype: item.supplementDecision === 'supplement' ? '报到与补录' : '报到情况', title: item.title, summary: item.summary }));
return [...ordinary, ...plans, ...qualifications, ...admissions, ...cutoffs, ...reports].sort((left, right) => new Date(right.publishedAt) - new Date(left.publishedAt)); return [...ordinary, ...plans, ...qualifications, ...admissions, ...cutoffs, ...reports].sort((left, right) => new Date(right.publishedAt) - new Date(left.publishedAt));
@@ -83,7 +83,7 @@ export function createPublicViews(context) {
const page = publicPaged(document.rows, key); const page = publicPaged(document.rows, key);
const schools = [...new Set(document.rows.map(row => row.admittedSchool).filter(Boolean))]; const schools = [...new Set(document.rows.map(row => row.admittedSchool).filter(Boolean))];
const categories = [...new Set(document.rows.map(row => row.categoryName).filter(Boolean))]; const categories = [...new Set(document.rows.map(row => row.categoryName).filter(Boolean))];
return `<p class="document-lead">报名号、姓名、考生总成绩与录取学校公开透明;证件号和联系方式不在本页展示。</p><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="${h(key)}" placeholder="跨页搜索报名号、姓名、学校或类别"></label><div class="table-filter-selects"><select data-table-filter="school" data-target="${h(key)}"><option value="">全部录取学校</option>${schools.map(school => `<option value="${h(school)}">${h(school)}</option>`).join('')}</select><select data-table-filter="category" data-target="${h(key)}"><option value="">全部录取类别</option>${categories.map(category => `<option value="${h(category)}">${h(category)}</option>`).join('')}</select><button class="row-action" data-action="clear-table-filters" data-target="${h(key)}">清除筛选</button></div></div><div class="table-scroll"><table id="${h(key)}"><thead><tr><th>报名号</th><th>姓名</th><th>总成绩</th><th>录取学校</th><th>录取类别</th></tr></thead><tbody>${page.items.map(row => `<tr><td class="mono">${h(row.registrationNumber)}</td><td><strong>${h(row.name)}</strong></td><td>${h(row.totalScore)}</td><td>${h(row.admittedSchool)}</td><td>${h(row.categoryName)}</td></tr>`).join('') || '<tr><td colspan="5" class="empty-state">没有符合条件的录取记录</td></tr>'}</tbody></table></div>${publicPagination(page)}`; return `<p class="document-lead">${document.round ? `本公示为第 ${h(document.round)} 轮录取通知书签发时生成的名单快照。` : '本公示为全部录取与报到流程结束后的最终名单。'}报名号、姓名、考生总成绩与录取学校公开透明;证件号和联系方式不在本页展示。</p><div class="data-toolbar"><label class="search-box">${icons.search}<input data-action="table-search" data-target="${h(key)}" placeholder="跨页搜索报名号、姓名、学校或类别"></label><div class="table-filter-selects"><select data-table-filter="school" data-target="${h(key)}"><option value="">全部录取学校</option>${schools.map(school => `<option value="${h(school)}">${h(school)}</option>`).join('')}</select><select data-table-filter="category" data-target="${h(key)}"><option value="">全部录取类别</option>${categories.map(category => `<option value="${h(category)}">${h(category)}</option>`).join('')}</select><button class="row-action" data-action="clear-table-filters" data-target="${h(key)}">清除筛选</button></div></div><div class="table-scroll"><table id="${h(key)}"><thead><tr><th>报名号</th><th>姓名</th><th>总成绩</th><th>录取学校</th><th>录取类别</th></tr></thead><tbody>${page.items.map(row => `<tr><td class="mono">${h(row.registrationNumber)}</td><td><strong>${h(row.name)}</strong></td><td>${h(row.totalScore)}</td><td>${h(row.admittedSchool)}</td><td>${h(row.categoryName)}</td></tr>`).join('') || '<tr><td colspan="5" class="empty-state">没有符合条件的录取记录</td></tr>'}</tbody></table></div>${publicPagination(page)}`;
} }
function renderDocumentBody(document) { function renderDocumentBody(document) {
+23 -7
View File
@@ -1,6 +1,6 @@
import { admissionMixingScopes, buildAdmissionArrangement } from '../services/admission-arrangement.mjs'; import { admissionMixingScopes, buildAdmissionArrangement } from '../services/admission-arrangement.mjs';
import { noticeForClient, noticePlainText, sanitizeNoticeContent } from '../security/notice-content.mjs'; import { noticeForClient, noticePlainText, sanitizeNoticeContent } from '../security/notice-content.mjs';
import { admissionCutoffRows, admissionPhases, admissionPlanProgress, admissionRecords, admissionReportingRecord, admissionSetting, assignAdmissionNoticeNumbers, approvedPlans, buildVolunteerPlacements, candidateTotalScore, publicAdmissionRows, remainingPlanQuota, sourceSchoolQualificationStatus } from '../services/volunteer-admission.mjs'; import { admissionCutoffRows, admissionPhases, admissionPlanProgress, admissionRecords, admissionReportingRecord, admissionRoundPublications, admissionSetting, assignAdmissionNoticeNumbers, approvedPlans, buildVolunteerPlacements, candidateTotalScore, publicAdmissionRows, remainingPlanQuota, sourceSchoolQualificationStatus } from '../services/volunteer-admission.mjs';
import { isValidSpecialty, resolveProfileSpecialty, specialtyLabel } from '../data/specialty-types.mjs'; import { isValidSpecialty, resolveProfileSpecialty, specialtyLabel } from '../data/specialty-types.mjs';
import { systemNotificationItems } from '../services/system-notifications.mjs'; import { systemNotificationItems } from '../services/system-notifications.mjs';
@@ -151,7 +151,16 @@ export function createAdminRoutes(context) {
item.payload?.publishedAt || item.updatedAt, item.payload?.publishedAt || item.updatedAt,
'资格确认完成后由系统生成,内容随资格确认结果更新。' '资格确认完成后由系统生成,内容随资格确认结果更新。'
)); ));
const admissions = admissionRecords(db, 'setting').filter(item => item.status === 'completed' && item.payload?.autoPublish !== false).map(item => view( const roundAdmissions = admissionRoundPublications(db).filter(item => admissionSetting(db, item.examId)?.payload?.autoPublish !== false).map(item => view(
{ ...item, id: item.sourceRecordId || item.id },
'admission',
'录取名单',
`${examName(item.examId)}${item.round} 轮录取名单公示`,
item.publishedAt,
`${item.round} 轮录取通知书签发后由系统自动生成,共 ${item.rows.length} 人。`
));
const virtualSourceIds = new Set(admissionRoundPublications(db).filter(item => item.virtual).map(item => item.sourceRecordId));
const admissions = admissionRecords(db, 'setting').filter(item => item.status === 'completed' && item.payload?.autoPublish !== false && !virtualSourceIds.has(item.id)).map(item => view(
item, item,
'admission', 'admission',
'录取名单', '录取名单',
@@ -171,7 +180,7 @@ export function createAdminRoutes(context) {
id: item.id, sourceType: 'reporting', category: item.category, title: item.title, summary: item.summary, id: item.id, sourceType: 'reporting', category: item.category, title: item.title, summary: item.summary,
author: item.author, publishedAt: item.publishAt, visible: item.visible, status: item.status author: item.author, publishedAt: item.publishAt, visible: item.visible, status: item.status
})); }));
return [...plans, ...qualifications, ...admissions, ...cutoffs, ...reports] return [...plans, ...qualifications, ...roundAdmissions, ...admissions, ...cutoffs, ...reports]
.sort((left, right) => new Date(right.publishedAt) - new Date(left.publishedAt)); .sort((left, right) => new Date(right.publishedAt) - new Date(left.publishedAt));
} }
@@ -417,9 +426,14 @@ export function createAdminRoutes(context) {
const record = existing || { id: uid('admission_reporting'), kind: 'notification', examId: setting.examId, userId: null, schoolId: plan.schoolId, createdAt: now }; const record = existing || { id: uid('admission_reporting'), kind: 'notification', examId: setting.examId, userId: null, schoolId: plan.schoolId, createdAt: now };
return { ...record, status: 'draft', updatedAt: now, payload: { type: 'admission_reporting', round, rows, openedAt: now, openedBy: user.displayName } }; return { ...record, status: 'draft', updatedAt: now, payload: { type: 'admission_reporting', round, rows, openedAt: now, openedBy: user.displayName } };
}); });
setting.status = 'reporting'; setting.updatedAt = now; setting.payload.progress = `${round} 轮录取结束,${admitted.length} 名考生已签发通知书,招生学校正在登记报到`; const publicationDb = { ...db, admissionRecords: db.admissionRecords.map(item => admitted.find(entry => entry.id === item.id) || item) };
await database.saveAdmissionRecords([setting, ...admitted, ...notifications, ...reportingRecords], logAction(db, user, '签发录取通知书并开启报到', `${setting.examId} · ${admitted.length}`)); const existingPublication = admissionRecords(db, 'notification', setting.examId).find(item => item.userId == null && item.payload?.type === 'admission_round_publication' && Number(item.payload?.round || 1) === round);
return sendJson(response, 200, { ok: true, admittedCount: admitted.length, reportingSchoolCount: reportingRecords.length }); const admissionPublication = existingPublication || { id: uid('admission_round_publication'), kind: 'notification', examId: setting.examId, userId: null, schoolId: null, createdAt: now };
Object.assign(admissionPublication, { status: 'published', updatedAt: now, payload: { ...admissionPublication.payload, type: 'admission_round_publication', round, publishedAt: now, publishedBy: user.displayName, rows: publicAdmissionRows(publicationDb, setting.examId, { round }) } });
setting.status = 'reporting'; setting.updatedAt = now; setting.payload = { ...setting.payload, roundPublishedAt: now, progress: `${round} 轮录取结束,${admitted.length} 名考生已签发通知书,录取名单已公示,招生学校正在登记报到` };
await database.saveAdmissionRecords([setting, ...admitted, ...notifications, ...reportingRecords, admissionPublication], logAction(db, user, '签发录取通知书并公示本轮录取名单', `${setting.examId} · 第 ${round} 轮 · ${admitted.length}`));
await cache.invalidate('public');
return sendJson(response, 200, { ok: true, admittedCount: admitted.length, reportingSchoolCount: reportingRecords.length, publicationId: admissionPublication.id });
} }
if (action === 'supplementary') return sendError(response, 409, '补录必须由招生学校提交报到情况和补录决定,再经超级管理员审批开启'); if (action === 'supplementary') return sendError(response, 409, '补录必须由招生学校提交报到情况和补录决定,再经超级管理员审批开启');
const body = await readJson(request); const body = await readJson(request);
@@ -1482,7 +1496,9 @@ export function createAdminRoutes(context) {
if (!requirePermission(user, response, '*')) return true; if (!requirePermission(user, response, '*')) return true;
const kindByType = { plan: 'plan', qualification: 'qualification_publication', admission: 'setting', cutoff: 'cutoff_publication', reporting: 'notification' }; const kindByType = { plan: 'plan', qualification: 'qualification_publication', admission: 'setting', cutoff: 'cutoff_publication', reporting: 'notification' };
const sourceType = publicationMatch[1]; const sourceType = publicationMatch[1];
const record = admissionRecords(db, kindByType[sourceType]).find(item => item.id === publicationMatch[2] && (sourceType !== 'reporting' || item.payload?.type === 'admission_reporting')); const record = sourceType === 'admission'
? (db.admissionRecords || []).find(item => item.id === publicationMatch[2] && (item.kind === 'setting' || (item.kind === 'notification' && item.payload?.type === 'admission_round_publication')))
: admissionRecords(db, kindByType[sourceType]).find(item => item.id === publicationMatch[2] && (sourceType !== 'reporting' || item.payload?.type === 'admission_reporting'));
if (!record) return sendError(response, 404, '系统公示不存在'); if (!record) return sendError(response, 404, '系统公示不存在');
const body = await readJson(request); const body = await readJson(request);
if (typeof body.visible !== 'boolean') return sendError(response, 400, '请明确设置是否显示'); if (typeof body.visible !== 'boolean') return sendError(response, 400, '请明确设置是否显示');
+3 -2
View File
@@ -1,5 +1,5 @@
import { noticeForClient } from '../security/notice-content.mjs'; import { noticeForClient } from '../security/notice-content.mjs';
import { admissionRecords, admissionSetting, publicAdmissionRows, sourceSchoolQualificationStatus } from '../services/volunteer-admission.mjs'; import { admissionRecords, admissionRoundPublications, admissionSetting, publicAdmissionRows, sourceSchoolQualificationStatus } from '../services/volunteer-admission.mjs';
import { specialtyLabel } from '../data/specialty-types.mjs'; import { specialtyLabel } from '../data/specialty-types.mjs';
import { systemNotificationItems } from '../services/system-notifications.mjs'; import { systemNotificationItems } from '../services/system-notifications.mjs';
@@ -121,7 +121,8 @@ export function createPublicRoutes(context) {
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, 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 || '普通生' })) 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)); })).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 && item.payload?.publicVisible !== 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 roundAdmissions = admissionRoundPublications(db).filter(item => admissionSetting(db, item.examId)?.payload?.autoPublish !== false && item.payload?.publicVisible !== false).map(item => ({ id: item.id, examId: item.examId, examName: db.exams.find(exam => exam.id === item.examId)?.name || '', round: item.round, title: `${db.exams.find(exam => exam.id === item.examId)?.name || ''}${item.round} 轮录取名单公示`, publishedAt: item.publishedAt, rows: item.rows }));
const admissions = [...roundAdmissions, ...admissionRecords(db, 'setting').filter(item => item.status === 'completed' && item.payload?.autoPublish !== false && item.payload?.publicVisible !== false).map(setting => ({ id: setting.id, examId: setting.examId, examName: db.exams.find(item => item.id === setting.examId)?.name || '', title: `${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' && item.payload?.publicVisible !== false && 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)); const cutoffs = admissionRecords(db, 'cutoff_publication').filter(item => item.status === 'published' && item.payload?.publicVisible !== false && 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));
const reports = systemNotificationItems(db).filter(item => item.sourceType === 'reporting' && item.visible).map(item => ({ id: item.id, examId: item.examId, schoolId: item.schoolId, examName: db.exams.find(exam => exam.id === item.examId)?.name || '', schoolName: db.schools.find(school => school.id === item.schoolId)?.name || '', title: item.title, summary: item.summary, publishedAt: item.publishAt, statistics: admissionRecords(db, 'notification').find(record => record.id === item.id)?.payload?.statistics || {}, supplementDecision: admissionRecords(db, 'notification').find(record => record.id === item.id)?.payload?.supplementDecision || '', decisionNote: admissionRecords(db, 'notification').find(record => record.id === item.id)?.payload?.decisionNote || '' })); const reports = systemNotificationItems(db).filter(item => item.sourceType === 'reporting' && item.visible).map(item => ({ id: item.id, examId: item.examId, schoolId: item.schoolId, examName: db.exams.find(exam => exam.id === item.examId)?.name || '', schoolName: db.schools.find(school => school.id === item.schoolId)?.name || '', title: item.title, summary: item.summary, publishedAt: item.publishAt, statistics: admissionRecords(db, 'notification').find(record => record.id === item.id)?.payload?.statistics || {}, supplementDecision: admissionRecords(db, 'notification').find(record => record.id === item.id)?.payload?.supplementDecision || '', decisionNote: admissionRecords(db, 'notification').find(record => record.id === item.id)?.payload?.decisionNote || '' }));
return { ok: true, plans, qualifications, admissions, cutoffs, reports }; return { ok: true, plans, qualifications, admissions, cutoffs, reports };
+7 -3
View File
@@ -1,4 +1,4 @@
import { admissionRecords, admissionSetting } from './volunteer-admission.mjs'; import { admissionRecords, admissionRoundPublications, admissionSetting } from './volunteer-admission.mjs';
const h = value => String(value ?? '').replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[char])); const h = value => String(value ?? '').replace(/[&<>"']/g, char => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[char]));
@@ -32,6 +32,11 @@ export function systemNotificationItems(db) {
'生源学校资格确认完成,系统已生成指标分配资格公示。', record.payload?.publishedAt || record.updatedAt, '生源学校资格确认完成,系统已生成指标分配资格公示。', record.payload?.publishedAt || record.updatedAt,
`<p>${h(schoolName(record.schoolId))}指标分配资格确认已经完成,共 ${Number(record.payload?.rows?.length || 0)} 条记录。</p>` `<p>${h(schoolName(record.schoolId))}指标分配资格确认已经完成,共 ${Number(record.payload?.rows?.length || 0)} 条记录。</p>`
)); ));
const roundAdmissions = admissionRoundPublications(db).filter(record => admissionSetting(db, record.examId)?.payload?.autoPublish !== false).map(record => item(
record, 'admission', '录取名单', `${examName(record.examId)}${record.round} 轮录取名单公示`,
`${record.round} 轮录取通知书已签发,共 ${record.rows.length} 名考生进入本轮录取公示。`, record.publishedAt,
`<p>${h(examName(record.examId))}${record.round} 轮录取工作已经完成,共 ${record.rows.length} 名考生正式录取。</p>`
));
const admissions = admissionRecords(db, 'setting').filter(record => record.status === 'completed' && record.payload?.autoPublish !== false).map(record => item( const admissions = admissionRecords(db, 'setting').filter(record => record.status === 'completed' && record.payload?.autoPublish !== false).map(record => item(
record, 'admission', '录取名单', `${examName(record.examId)}最终录取名单`, record, 'admission', '录取名单', `${examName(record.examId)}最终录取名单`,
'录取与报到决策已经办结,最终录取结果已自动公开。', record.payload?.completedAt || record.updatedAt, '录取与报到决策已经办结,最终录取结果已自动公开。', record.payload?.completedAt || record.updatedAt,
@@ -53,7 +58,6 @@ export function systemNotificationItems(db) {
return item(record, 'reporting', '考生报到', title, summary, record.payload?.approvedAt || record.updatedAt, return item(record, 'reporting', '考生报到', title, summary, record.payload?.approvedAt || record.updatedAt,
`<p>${h(summary)}</p><p>${h(decision)}</p><ul><li>正式录取:${Number(stats.finalCount || 0)} 人</li><li>已报到:${Number(stats.reportedCount || 0)} 人</li><li>未报到:${Number(stats.notReportedCount || 0)} 人</li><li>计划缺额:${Number(stats.reportingGap || 0)} 人</li></ul><p>${h(record.payload?.approvalNote || '')}</p>`); `<p>${h(summary)}</p><p>${h(decision)}</p><ul><li>正式录取:${Number(stats.finalCount || 0)} 人</li><li>已报到:${Number(stats.reportedCount || 0)} 人</li><li>未报到:${Number(stats.notReportedCount || 0)} 人</li><li>计划缺额:${Number(stats.reportingGap || 0)} 人</li></ul><p>${h(record.payload?.approvalNote || '')}</p>`);
}); });
return [...plans, ...qualifications, ...admissions, ...cutoffs, ...reports] return [...plans, ...qualifications, ...roundAdmissions, ...admissions, ...cutoffs, ...reports]
.sort((left, right) => new Date(right.publishAt) - new Date(left.publishAt)); .sort((left, right) => new Date(right.publishAt) - new Date(left.publishAt));
} }
+36 -2
View File
@@ -148,8 +148,11 @@ export function assignAdmissionNoticeNumbers(db, placements) {
return output; return output;
} }
export function publicAdmissionRows(db, examId) { export function publicAdmissionRows(db, examId, options = {}) {
return admissionRecords(db, 'placement', examId).filter(item => item.status === 'final').map(item => { const round = Math.max(0, Number(options.round || 0));
return admissionRecords(db, 'placement', examId).filter(item => round
? Number(item.payload?.finalizedRound || 1) === round && ['final', 'forfeited'].includes(item.status)
: item.status === 'final').map(item => {
const user = db.users.find(entry => entry.id === item.userId) || {}; const user = db.users.find(entry => entry.id === item.userId) || {};
const profile = db.candidateProfiles.find(entry => entry.userId === item.userId) || {}; const profile = db.candidateProfiles.find(entry => entry.userId === item.userId) || {};
const school = db.schools.find(entry => entry.id === item.schoolId) || {}; const school = db.schools.find(entry => entry.id === item.schoolId) || {};
@@ -165,6 +168,37 @@ export function publicAdmissionRows(db, examId) {
}).sort((a, b) => b.totalScore - a.totalScore || a.registrationNumber.localeCompare(b.registrationNumber)); }).sort((a, b) => b.totalScore - a.totalScore || a.registrationNumber.localeCompare(b.registrationNumber));
} }
export function admissionRoundPublications(db) {
const stored = admissionRecords(db, 'notification')
.filter(item => item.userId == null && item.status === 'published' && item.payload?.type === 'admission_round_publication')
.map(item => ({ ...item, round: Number(item.payload?.round || 1), publishedAt: item.payload?.publishedAt || item.updatedAt, rows: item.payload?.rows || [], virtual: false }));
const keys = new Set(stored.map(item => `${item.examId}:${item.round}`));
const fallback = admissionRecords(db, 'setting').filter(item => ['reporting', 'supplementary', 'completed'].includes(item.status)).flatMap(setting => {
const rounds = admissionRecords(db, 'placement', setting.examId)
.filter(item => ['final', 'forfeited'].includes(item.status))
.map(item => Number(item.payload?.finalizedRound || 1));
const round = rounds.length ? Math.max(...rounds) : 0;
if (!round || keys.has(`${setting.examId}:${round}`)) return [];
return [{
id: `${setting.id}-round-${round}`,
sourceRecordId: setting.id,
kind: 'notification',
examId: setting.examId,
schoolId: null,
userId: null,
status: 'published',
createdAt: setting.updatedAt,
updatedAt: setting.updatedAt,
round,
publishedAt: setting.payload?.roundPublishedAt || setting.updatedAt,
rows: publicAdmissionRows(db, setting.examId, { round }),
virtual: true,
payload: { type: 'admission_round_publication', round, publicVisible: setting.payload?.publicVisible, publishedAt: setting.payload?.roundPublishedAt || setting.updatedAt }
}];
});
return [...stored, ...fallback].sort((left, right) => new Date(right.publishedAt) - new Date(left.publishedAt));
}
export function admissionCutoffRows(db, examId) { export function admissionCutoffRows(db, examId) {
const groups = new Map(); const groups = new Map();
for (const placement of admissionRecords(db, 'placement', examId).filter(item => item.status === 'final')) { for (const placement of admissionRecords(db, 'placement', examId).filter(item => item.status === 'final')) {
+6 -1
View File
@@ -1,5 +1,5 @@
import assert from 'node:assert/strict'; import assert from 'node:assert/strict';
import { admissionCutoffRows, admissionPlanProgress, assignAdmissionNoticeNumbers, buildVolunteerPlacements, candidateAdmissionScore, candidateTotalScore, publicAdmissionRows, remainingPlanQuota, sourceSchoolQualificationStatus } from '../src/services/volunteer-admission.mjs'; import { admissionCutoffRows, admissionPlanProgress, admissionRoundPublications, assignAdmissionNoticeNumbers, buildVolunteerPlacements, candidateAdmissionScore, candidateTotalScore, publicAdmissionRows, remainingPlanQuota, sourceSchoolQualificationStatus } from '../src/services/volunteer-admission.mjs';
import { candidateEligibleForCategory, specialtyLabel } from '../src/data/specialty-types.mjs'; import { candidateEligibleForCategory, specialtyLabel } from '../src/data/specialty-types.mjs';
import { systemNotificationItems } from '../src/services/system-notifications.mjs'; import { systemNotificationItems } from '../src/services/system-notifications.mjs';
@@ -97,6 +97,11 @@ const numbered = assignAdmissionNoticeNumbers(documentDb, targetPlacements);
assert.deepEqual(numbered.map(item => item.payload.noticeNumber), ['AD02-EX-2026-ZK-000001', 'AD02-EX-2026-ZK-000002'], '通知书编号应按学校与考试独立生成连续流水号'); assert.deepEqual(numbered.map(item => item.payload.noticeNumber), ['AD02-EX-2026-ZK-000001', 'AD02-EX-2026-ZK-000002'], '通知书编号应按学校与考试独立生成连续流水号');
const reportingPlan = documentDb.admissionRecords.find(item => item.id === 'plan-b'); const reportingPlan = documentDb.admissionRecords.find(item => item.id === 'plan-b');
documentDb.admissionRecords = documentDb.admissionRecords.map(item => numbered.find(numberedItem => numberedItem.id === item.id) || item); documentDb.admissionRecords = documentDb.admissionRecords.map(item => numbered.find(numberedItem => numberedItem.id === item.id) || item);
const legacyRoundDb = structuredClone(documentDb);
legacyRoundDb.admissionRecords.push({ id: 'setting-exam', kind: 'setting', examId: 'exam', status: 'reporting', updatedAt: now, payload: { enabled: true, autoPublish: true, round: 1, roundPublishedAt: now } });
const legacyRoundPublication = admissionRoundPublications(legacyRoundDb).find(item => item.examId === 'exam' && item.round === 1);
assert.ok(legacyRoundPublication?.virtual, '历史报到中数据缺少轮次公示记录时应自动兼容回显');
assert.equal(legacyRoundPublication.rows.length, publicAdmissionRows(legacyRoundDb, 'exam', { round: 1 }).length, '历史轮次公示应恢复该轮全部正式录取名单');
documentDb.admissionRecords.push({ id: 'reporting-b', kind: 'notification', examId: 'exam', schoolId: 'target-b', userId: null, status: 'draft', payload: { type: 'admission_reporting', round: 1, rows: [{ placementId: numbered[0].id, status: 'reported' }, { placementId: numbered[1].id, status: 'not_reported' }] } }); documentDb.admissionRecords.push({ id: 'reporting-b', kind: 'notification', examId: 'exam', schoolId: 'target-b', userId: null, status: 'draft', payload: { type: 'admission_reporting', round: 1, rows: [{ placementId: numbered[0].id, status: 'reported' }, { placementId: numbered[1].id, status: 'not_reported' }] } });
const progress = admissionPlanProgress(documentDb, reportingPlan); const progress = admissionPlanProgress(documentDb, reportingPlan);
assert.equal(progress.totalQuota, 2, '计划完成率分母应来自学校审核通过的计划人数'); assert.equal(progress.totalQuota, 2, '计划完成率分母应来自学校审核通过的计划人数');
+7
View File
@@ -682,6 +682,13 @@ try {
const finalizedAdmission = await admin.request(`/api/admin/admissions/${exam.id}/finalize`, { method: 'POST' }); const finalizedAdmission = await admin.request(`/api/admin/admissions/${exam.id}/finalize`, { method: 'POST' });
assert.equal(finalizedAdmission.response.status, 200, '超级管理员应能签发带编号的录取通知书并开启报到'); assert.equal(finalizedAdmission.response.status, 200, '超级管理员应能签发带编号的录取通知书并开启报到');
assert.equal(finalizedAdmission.data.admittedCount, 2, '正式签发人数应与学校接收人数一致'); assert.equal(finalizedAdmission.data.admittedCount, 2, '正式签发人数应与学校接收人数一致');
const roundAdmissionAnnouncements = await anonymous.request('/api/public/announcements');
const roundAdmissionPublication = roundAdmissionAnnouncements.data.admissions.find(item => item.examId === exam.id && item.round === 1);
assert.ok(roundAdmissionPublication, '每轮录取通知书签发后应立即自动生成本轮录取名单公示');
assert.equal(roundAdmissionPublication.rows.length, 2, '本轮录取公示应固定保存本轮全部正式录取考生');
assert.ok(roundAdmissionPublication.title.includes('第 1 轮录取名单公示'), '本轮公示标题应明确标注录取轮次');
const homeAfterRoundAdmission = await anonymous.request('/api/public/home');
assert.ok(homeAfterRoundAdmission.data.notices.some(item => item.title.includes('第 1 轮录取名单公示')), '本轮录取公示应同步进入首页通知公告');
const reportingLedger = await admissionSchoolClient.request('/api/admission/reporting'); const reportingLedger = await admissionSchoolClient.request('/api/admission/reporting');
const reportingBatch = reportingLedger.data.batches.find(item => item.exam.id === exam.id); const reportingBatch = reportingLedger.data.batches.find(item => item.exam.id === exam.id);
assert.equal(reportingBatch.rows.length, 2, '招生学校报到台账应包含本轮全部正式录取考生'); assert.equal(reportingBatch.rows.length, 2, '招生学校报到台账应包含本轮全部正式录取考生');