351 lines
19 KiB
JavaScript
351 lines
19 KiB
JavaScript
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';
|