export const admissionPhases = new Set(['draft', 'filling', 'closed', 'matching', 'school_review', 'reporting', 'supplementary', 'completed']); export function admissionRecords(db, kind, examId = null) { return (db.admissionRecords || []).filter(item => item.kind === kind && (!examId || item.examId === examId)); } export function admissionSetting(db, examId) { return admissionRecords(db, 'setting', examId)[0] || null; } export function candidateTotalScore(db, examId, userId) { const registration = db.registrations.find(item => item.examId === examId && item.userId === userId && item.status === 'approved'); if (!registration) return null; const results = db.results.filter(item => item.registrationId === registration.id && item.published); if (!registration.subjectIds.length || registration.subjectIds.some(id => !results.some(result => result.subjectId === id))) return null; return Number(results.reduce((sum, item) => sum + Number(item.score || 0), 0).toFixed(2)); } export function candidateAdmissionScore(db, examId, userId, category = {}) { const culturalScore = candidateTotalScore(db, examId, userId); if (culturalScore == null) return null; const registration = db.registrations.find(item => item.examId === examId && item.userId === userId && item.status === 'approved'); const featureScore = Number(registration?.featureScore || 0); const usesFeatureScore = Boolean(category.specialtyCategory || category.specialtyType); return Number((culturalScore + (usesFeatureScore ? featureScore : 0)).toFixed(2)); } export function activePreference(db, examId, userId, round) { return admissionRecords(db, 'preference', examId).find(item => item.userId === userId && Number(item.payload?.round || 1) === Number(round || 1)) || null; } export function indicatorQualification(db, examId, userId) { return admissionRecords(db, 'indicator_qualification', examId).find(item => item.userId === userId) || null; } export function sourceSchoolQualificationStatus(db, examId, schoolId) { const profiles = db.candidateProfiles.filter(profile => profile.schoolId === schoolId && profile.profileCompleted && db.users.some(user => user.id === profile.userId && user.role === 'candidate' && user.active)); const qualifications = admissionRecords(db, 'indicator_qualification', examId).filter(item => item.schoolId === schoolId && item.status === 'confirmed'); const byUser = new Map(qualifications.map(item => [item.userId, item])); const rows = profiles.map(profile => { const account = db.users.find(item => item.id === profile.userId) || {}; const specialty = resolveProfileSpecialty(profile); const qualification = byUser.get(profile.userId) || null; return { userId: profile.userId, registrationNumber: account.candidateNumber || '', name: profile.name || account.displayName || '', eligible: qualification?.payload?.eligible === true, confirmed: Boolean(qualification), confirmedAt: qualification?.payload?.confirmedAt || qualification?.updatedAt || '', specialtyCategory: specialty.category, specialtyType: specialty.type, specialtyLabel: specialtyLabel(specialty.category, specialty.type) || '普通生' }; }).sort((left, right) => left.registrationNumber.localeCompare(right.registrationNumber)); return { total: rows.length, confirmed: rows.filter(item => item.confirmed).length, complete: rows.length > 0 && rows.every(item => item.confirmed), rows }; } export function approvedPlans(db, examId) { return admissionRecords(db, 'plan', examId).filter(item => item.status === 'approved'); } export function supplementarySchoolIds(db, setting) { if (setting?.status !== 'supplementary') return null; const sourceRound = Math.max(1, Number(setting.payload?.round || 1) - 1); const schoolIds = admissionRecords(db, 'notification', setting.examId) .filter(item => item.userId == null && item.status === 'approved' && item.payload?.type === 'admission_reporting' && Number(item.payload?.round || 1) === sourceRound && item.payload?.supplementDecision === 'supplement') .map(item => item.schoolId) .filter(Boolean); // Older data could enter the supplementary phase without reporting decisions. // Preserve that legacy behavior, while new rounds are restricted to approved schools. return schoolIds.length ? new Set(schoolIds) : null; } export function planSummary(plan) { const categories = Array.isArray(plan.payload?.categories) ? plan.payload.categories : []; return { ...plan, totalQuota: categories.reduce((sum, item) => sum + Number(item.quota || 0), 0) }; } export function admissionReportingRecords(db, examId, schoolId) { return admissionRecords(db, 'notification', examId) .filter(item => item.schoolId === schoolId && item.userId == null && item.payload?.type === 'admission_reporting') .sort((left, right) => Number(right.payload?.round || 1) - Number(left.payload?.round || 1) || new Date(right.updatedAt) - new Date(left.updatedAt)); } export function admissionReportingRecord(db, examId, schoolId, round = null) { return admissionReportingRecords(db, examId, schoolId).find(item => round == null || Number(item.payload?.round || 1) === Number(round)) || null; } export function admissionPlanProgress(db, plan) { const totalQuota = (plan.payload?.categories || []).reduce((sum, item) => sum + Number(item.quota || 0), 0); const placements = admissionRecords(db, 'placement', plan.examId).filter(item => item.schoolId === plan.schoolId && !['withdrawn', 'forfeited'].includes(item.status)); const finalPlacements = placements.filter(item => item.status === 'final'); const reportingRecords = admissionReportingRecords(db, plan.examId, plan.schoolId); const reporting = reportingRecords[0] || null; const reportingRows = new Map(); for (const record of [...reportingRecords].reverse()) for (const row of record.payload?.rows || []) reportingRows.set(row.placementId, row); const reportedCount = finalPlacements.filter(item => reportingRows.get(item.id)?.status === 'reported').length; const notReportedCount = finalPlacements.filter(item => reportingRows.get(item.id)?.status === 'not_reported').length; const pendingReportingCount = Math.max(0, finalPlacements.length - reportedCount - notReportedCount); const percent = value => totalQuota ? Number((value / totalQuota * 100).toFixed(1)) : 0; return { examId: plan.examId, schoolId: plan.schoolId, totalQuota, placedCount: placements.length, finalCount: finalPlacements.length, reportedCount, notReportedCount, pendingReportingCount, admissionRate: percent(finalPlacements.length), reportingRate: percent(reportedCount), remainingQuota: Math.max(0, totalQuota - finalPlacements.length), reportingGap: Math.max(0, totalQuota - reportedCount), reportingStatus: reporting?.status || 'not_started', supplementDecision: reporting?.payload?.supplementDecision || '', reportingUpdatedAt: reporting?.updatedAt || null }; } function documentCodePart(value, fallback) { const normalized = String(value || '').trim().toUpperCase().replace(/[^A-Z0-9-]+/g, ''); return normalized || fallback; } export function assignAdmissionNoticeNumbers(db, placements) { const counters = new Map(); for (const item of admissionRecords(db, 'placement')) { const serial = Number(item.payload?.noticeSerial || String(item.payload?.noticeNumber || '').match(/(\d{6})$/)?.[1] || 0); if (!serial) continue; const key = `${item.schoolId}\u0000${item.examId}`; counters.set(key, Math.max(counters.get(key) || 0, serial)); } const accountNumber = userId => db.users.find(item => item.id === userId)?.candidateNumber || userId; const output = []; const grouped = new Map(); for (const placement of placements) { const key = `${placement.schoolId}\u0000${placement.examId}`; const rows = grouped.get(key) || []; rows.push(placement); grouped.set(key, rows); } for (const [key, rows] of grouped) { let serial = counters.get(key) || 0; rows.sort((left, right) => String(accountNumber(left.userId)).localeCompare(String(accountNumber(right.userId)))); for (const placement of rows) { if (placement.payload?.noticeNumber) { output.push(placement); continue; } serial += 1; const school = db.schools.find(item => item.id === placement.schoolId) || {}; const exam = db.exams.find(item => item.id === placement.examId) || {}; const noticeSerial = serial; const noticeNumber = `${documentCodePart(school.code, 'SCHOOL')}-${documentCodePart(exam.code, 'EXAM')}-${String(noticeSerial).padStart(6, '0')}`; output.push({ ...placement, payload: { ...placement.payload, noticeSerial, noticeNumber } }); } counters.set(key, serial); } return output; } 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) || {}; return { registrationNumber: user.candidateNumber || '', name: profile.name || user.displayName || '', totalScore: Number(item.payload?.totalScore || 0), admittedSchool: school.name || '', categoryName: item.payload?.categoryName || '', idNumberMasked: profile.idNumber ? `${profile.idNumber.slice(0, 3)}***********${profile.idNumber.slice(-2)}` : '', phoneMasked: profile.phone ? `${profile.phone.slice(0, 3)}****${profile.phone.slice(-4)}` : '' }; }).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')) { const key = categoryKey(placement.schoolId, placement.payload?.categoryCode); const row = groups.get(key) || { schoolId: placement.schoolId, schoolName: db.schools.find(item => item.id === placement.schoolId)?.name || '', categoryCode: placement.payload?.categoryCode || '', categoryName: placement.payload?.categoryName || '', admittedCount: 0, planQuota: 0, highestScore: null, cutoffScore: null }; const score = Number(placement.payload?.totalScore || 0); row.admittedCount += 1; row.highestScore = row.highestScore == null ? score : Math.max(row.highestScore, score); row.cutoffScore = row.cutoffScore == null ? score : Math.min(row.cutoffScore, score); groups.set(key, row); } for (const plan of approvedPlans(db, examId)) for (const category of plan.payload?.categories || []) { const row = groups.get(categoryKey(plan.schoolId, category.code)); if (row) row.planQuota = Number(category.quota || 0); } return [...groups.values()].sort((left, right) => left.schoolName.localeCompare(right.schoolName) || left.categoryName.localeCompare(right.categoryName)); } function categoryKey(schoolId, code) { return `${schoolId}|${code}`; } export function buildVolunteerPlacements(db, setting, { uid, nowIso }) { const examId = setting.examId; const round = Number(setting.payload?.round || 1); const supplementarySchools = supplementarySchoolIds(db, setting); const plans = approvedPlans(db, examId).filter(plan => !supplementarySchools || supplementarySchools.has(plan.schoolId)); const categories = new Map(); for (const plan of plans) for (const category of plan.payload?.categories || []) { categories.set(categoryKey(plan.schoolId, category.code), { plan, category }); } const allExisting = admissionRecords(db, 'placement', examId); const existing = allExisting.filter(item => !['withdrawn', 'forfeited'].includes(item.status)); const occupiedIndicators = new Map(); const occupiedGeneral = new Map(); for (const placement of existing) { const key = categoryKey(placement.schoolId, placement.payload?.categoryCode); if (placement.payload?.quotaBucket?.startsWith('indicator:')) { const indicatorKey = `${key}|${placement.payload.quotaBucket.slice(10)}`; occupiedIndicators.set(indicatorKey, (occupiedIndicators.get(indicatorKey) || 0) + 1); } else occupiedGeneral.set(key, (occupiedGeneral.get(key) || 0) + 1); } const preferences = admissionRecords(db, 'preference', examId).filter(item => Number(item.payload?.round || 1) === round && item.status === 'submitted'); const candidates = preferences.map(preference => { const profile = db.candidateProfiles.find(item => item.userId === preference.userId) || {}; const account = db.users.find(item => item.id === preference.userId) || {}; const registration = db.registrations.find(item => item.examId === examId && item.userId === preference.userId && item.status === 'approved'); return { preference, profile, account, registration, culturalScore: candidateTotalScore(db, examId, preference.userId), featureScore: Number(registration?.featureScore || 0), nextChoiceIndex: 0 }; }).filter(item => item.culturalScore != null && !allExisting.some(entry => entry.userId === item.preference.userId && ['school_review', 'admitted', 'final', 'withdrawal_pending', 'forfeited'].includes(entry.status))); const compareProposals = (left, right) => right.totalScore - left.totalScore || String(left.candidate.account.candidateNumber || '').localeCompare(String(right.candidate.account.candidateNumber || '')); const acceptedByBucket = new Map(); const queue = [...candidates].sort((left, right) => right.culturalScore - left.culturalScore || String(left.account.candidateNumber || '').localeCompare(String(right.account.candidateNumber || ''))); while (queue.length) { const candidate = queue.shift(); const choices = candidate.preference.payload?.choices || []; while (candidate.nextChoiceIndex < choices.length) { const index = candidate.nextChoiceIndex; const choice = choices[candidate.nextChoiceIndex++]; const target = categories.get(categoryKey(choice.schoolId, choice.categoryCode)); if (!target) continue; const { category } = target; if (!candidateEligibleForCategory(candidate.profile, category)) continue; const key = categoryKey(choice.schoolId, choice.categoryCode); let quotaBucket = null; let bucketKey = ''; let capacity = 0; let occupiedCount = 0; if (choice.preferenceType === 'indicator') { const qualification = indicatorQualification(db, examId, candidate.preference.userId); const allocation = (category.indicatorAllocations || []).find(item => item.sourceSchoolId === candidate.profile.schoolId); if (!qualification?.payload?.eligible || !allocation) continue; const indicatorKey = `${key}|${candidate.profile.schoolId}`; quotaBucket = `indicator:${candidate.profile.schoolId}`; bucketKey = quotaBucket + '|' + key; capacity = Number(allocation.quota || 0); occupiedCount = occupiedIndicators.get(indicatorKey) || 0; } else { const generalQuota = Math.max(0, Number(category.quota || 0) - (category.indicatorAllocations || []).reduce((sum, item) => sum + Number(item.quota || 0), 0)); quotaBucket = 'general'; bucketKey = `general|${key}`; capacity = generalQuota; occupiedCount = occupiedGeneral.get(key) || 0; } const available = Math.max(0, capacity - occupiedCount); if (!available) continue; const usesFeatureScore = Boolean(category.specialtyCategory || category.specialtyType); const proposal = { candidate, choice, category, index, quotaBucket, culturalScore: candidate.culturalScore, featureScore: candidate.featureScore, totalScore: Number((candidate.culturalScore + (usesFeatureScore ? candidate.featureScore : 0)).toFixed(2)) }; const accepted = acceptedByBucket.get(bucketKey) || []; accepted.push(proposal); accepted.sort(compareProposals); const rejected = accepted.length > available ? accepted.pop() : null; acceptedByBucket.set(bucketKey, accepted); if (rejected && rejected !== proposal) queue.push(rejected.candidate); if (rejected === proposal) continue; break; } } const accepted = [...acceptedByBucket.values()].flat().sort(compareProposals); return accepted.map(({ candidate, choice, category, index, quotaBucket, culturalScore, featureScore, totalScore }) => ({ id: uid('placement'), kind: 'placement', examId, userId: candidate.preference.userId, schoolId: choice.schoolId, status: 'school_review', createdAt: nowIso(), updatedAt: nowIso(), payload: { round, categoryCode: category.code, categoryName: category.name, preferenceOrder: index + 1, culturalScore, totalScore, featureScore, specialtyQualification: resolveProfileSpecialty(candidate.profile), quotaBucket, schoolDecisionNote: '', withdrawalReason: '', withdrawalReviewNote: '' } })); } export function remainingPlanQuota(db, plan) { return (plan.payload?.categories || []).map(category => { const used = admissionRecords(db, 'placement', plan.examId).filter(item => item.schoolId === plan.schoolId && item.payload?.categoryCode === category.code && !['withdrawn', 'forfeited'].includes(item.status)).length; return { ...category, used, remaining: Math.max(0, Number(category.quota || 0) - used) }; }); } import { candidateEligibleForCategory, resolveProfileSpecialty, specialtyLabel } from '../data/specialty-types.mjs';