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 `
报名号、姓名、考生总成绩与录取学校公开透明;证件号和联系方式不在本页展示。
| 报名号 | 姓名 | 总成绩 | 录取学校 | 录取类别 |
|---|---|---|---|---|
| ${h(row.registrationNumber)} | ${h(row.name)} | ${h(row.totalScore)} | ${h(row.admittedSchool)} | ${h(row.categoryName)} |
| 没有符合条件的录取记录 | ||||
${document.round ? `本公示为第 ${h(document.round)} 轮录取通知书签发时生成的名单快照。` : '本公示为全部录取与报到流程结束后的最终名单。'}报名号、姓名、考生总成绩与录取学校公开透明;证件号和联系方式不在本页展示。
| 报名号 | 姓名 | 总成绩 | 录取学校 | 录取类别 |
|---|---|---|---|---|
| ${h(row.registrationNumber)} | ${h(row.name)} | ${h(row.totalScore)} | ${h(row.admittedSchool)} | ${h(row.categoryName)} |
| 没有符合条件的录取记录 | ||||
${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, '招生学校报到台账应包含本轮全部正式录取考生');