Files
EIS-dotnet/src/services/volunteer-admission.mjs
T
2026-07-22 18:22:52 +08:00

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';