diff --git a/src/client/public-views.mjs b/src/client/public-views.mjs index 16a4caa..bf0c1f0 100644 --- a/src/client/public-views.mjs +++ b/src/client/public-views.mjs @@ -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 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 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 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)); @@ -83,7 +83,7 @@ export function createPublicViews(context) { const page = publicPaged(document.rows, key); const schools = [...new Set(document.rows.map(row => row.admittedSchool).filter(Boolean))]; const categories = [...new Set(document.rows.map(row => row.categoryName).filter(Boolean))]; - return `

报名号、姓名、考生总成绩与录取学校公开透明;证件号和联系方式不在本页展示。

${page.items.map(row => ``).join('') || ''}
报名号姓名总成绩录取学校录取类别
${h(row.registrationNumber)}${h(row.name)}${h(row.totalScore)}${h(row.admittedSchool)}${h(row.categoryName)}
没有符合条件的录取记录
${publicPagination(page)}`; + return `

${document.round ? `本公示为第 ${h(document.round)} 轮录取通知书签发时生成的名单快照。` : '本公示为全部录取与报到流程结束后的最终名单。'}报名号、姓名、考生总成绩与录取学校公开透明;证件号和联系方式不在本页展示。

${page.items.map(row => ``).join('') || ''}
报名号姓名总成绩录取学校录取类别
${h(row.registrationNumber)}${h(row.name)}${h(row.totalScore)}${h(row.admittedSchool)}${h(row.categoryName)}
没有符合条件的录取记录
${publicPagination(page)}`; } function renderDocumentBody(document) { diff --git a/src/routes/admin.routes.mjs b/src/routes/admin.routes.mjs index 7e9e8b2..26195fa 100644 --- a/src/routes/admin.routes.mjs +++ b/src/routes/admin.routes.mjs @@ -1,6 +1,6 @@ import { admissionMixingScopes, buildAdmissionArrangement } from '../services/admission-arrangement.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 { systemNotificationItems } from '../services/system-notifications.mjs'; @@ -151,7 +151,16 @@ export function createAdminRoutes(context) { 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, 'admission', '录取名单', @@ -171,7 +180,7 @@ export function createAdminRoutes(context) { 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 })); - return [...plans, ...qualifications, ...admissions, ...cutoffs, ...reports] + return [...plans, ...qualifications, ...roundAdmissions, ...admissions, ...cutoffs, ...reports] .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 }; 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} 名考生已签发通知书,招生学校正在登记报到`; - await database.saveAdmissionRecords([setting, ...admitted, ...notifications, ...reportingRecords], logAction(db, user, '签发录取通知书并开启报到', `${setting.examId} · ${admitted.length} 人`)); - return sendJson(response, 200, { ok: true, admittedCount: admitted.length, reportingSchoolCount: reportingRecords.length }); + const publicationDb = { ...db, admissionRecords: db.admissionRecords.map(item => admitted.find(entry => entry.id === item.id) || item) }; + const existingPublication = admissionRecords(db, 'notification', setting.examId).find(item => item.userId == null && item.payload?.type === 'admission_round_publication' && Number(item.payload?.round || 1) === round); + 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, '补录必须由招生学校提交报到情况和补录决定,再经超级管理员审批开启'); const body = await readJson(request); @@ -1482,7 +1496,9 @@ export function createAdminRoutes(context) { if (!requirePermission(user, response, '*')) return true; const kindByType = { plan: 'plan', qualification: 'qualification_publication', admission: 'setting', cutoff: 'cutoff_publication', reporting: 'notification' }; 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, '系统公示不存在'); const body = await readJson(request); if (typeof body.visible !== 'boolean') return sendError(response, 400, '请明确设置是否显示'); diff --git a/src/routes/public.routes.mjs b/src/routes/public.routes.mjs index b559360..37500ff 100644 --- a/src/routes/public.routes.mjs +++ b/src/routes/public.routes.mjs @@ -1,5 +1,5 @@ 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 { 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, 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 && 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 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 }; diff --git a/src/services/system-notifications.mjs b/src/services/system-notifications.mjs index 22e6444..be6a46b 100644 --- a/src/services/system-notifications.mjs +++ b/src/services/system-notifications.mjs @@ -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 => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[char])); @@ -32,6 +32,11 @@ export function systemNotificationItems(db) { '生源学校资格确认完成,系统已生成指标分配资格公示。', record.payload?.publishedAt || record.updatedAt, `

${h(schoolName(record.schoolId))}指标分配资格确认已经完成,共 ${Number(record.payload?.rows?.length || 0)} 条记录。

` )); + 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, + `

${h(examName(record.examId))}第 ${record.round} 轮录取工作已经完成,共 ${record.rows.length} 名考生正式录取。

` + )); const admissions = admissionRecords(db, 'setting').filter(record => record.status === 'completed' && record.payload?.autoPublish !== false).map(record => item( record, 'admission', '录取名单', `${examName(record.examId)}最终录取名单`, '录取与报到决策已经办结,最终录取结果已自动公开。', record.payload?.completedAt || record.updatedAt, @@ -53,7 +58,6 @@ export function systemNotificationItems(db) { return item(record, 'reporting', '考生报到', title, summary, record.payload?.approvedAt || record.updatedAt, `

${h(summary)}

${h(decision)}

${h(record.payload?.approvalNote || '')}

`); }); - return [...plans, ...qualifications, ...admissions, ...cutoffs, ...reports] + return [...plans, ...qualifications, ...roundAdmissions, ...admissions, ...cutoffs, ...reports] .sort((left, right) => new Date(right.publishAt) - new Date(left.publishAt)); } - diff --git a/src/services/volunteer-admission.mjs b/src/services/volunteer-admission.mjs index 7d86b16..2f7b50a 100644 --- a/src/services/volunteer-admission.mjs +++ b/src/services/volunteer-admission.mjs @@ -148,8 +148,11 @@ export function assignAdmissionNoticeNumbers(db, placements) { return output; } -export function publicAdmissionRows(db, examId) { - return admissionRecords(db, 'placement', examId).filter(item => item.status === 'final').map(item => { +export function publicAdmissionRows(db, examId, options = {}) { + 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 profile = db.candidateProfiles.find(entry => entry.userId === item.userId) || {}; 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)); } +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) { const groups = new Map(); for (const placement of admissionRecords(db, 'placement', examId).filter(item => item.status === 'final')) { diff --git a/tests/admission.test.mjs b/tests/admission.test.mjs index 97179af..1d94576 100644 --- a/tests/admission.test.mjs +++ b/tests/admission.test.mjs @@ -1,5 +1,5 @@ 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 { 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'], '通知书编号应按学校与考试独立生成连续流水号'); const reportingPlan = documentDb.admissionRecords.find(item => item.id === 'plan-b'); 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' }] } }); const progress = admissionPlanProgress(documentDb, reportingPlan); assert.equal(progress.totalQuota, 2, '计划完成率分母应来自学校审核通过的计划人数'); diff --git a/tests/system.test.mjs b/tests/system.test.mjs index fedf03c..786606a 100644 --- a/tests/system.test.mjs +++ b/tests/system.test.mjs @@ -682,6 +682,13 @@ try { const finalizedAdmission = await admin.request(`/api/admin/admissions/${exam.id}/finalize`, { method: 'POST' }); assert.equal(finalizedAdmission.response.status, 200, '超级管理员应能签发带编号的录取通知书并开启报到'); 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 reportingBatch = reportingLedger.data.batches.find(item => item.exam.id === exam.id); assert.equal(reportingBatch.rows.length, 2, '招生学校报到台账应包含本轮全部正式录取考生');