Files
Exam-Information-System/src/services/volunteer-admission.mjs
T

129 lines
7.2 KiB
JavaScript

export const admissionPhases = new Set(['draft', 'filling', 'closed', 'matching', 'school_review', '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 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 approvedPlans(db, examId) {
return admissionRecords(db, 'plan', examId).filter(item => item.status === 'approved');
}
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 publicAdmissionRows(db, examId) {
return admissionRecords(db, 'placement', examId).filter(item => 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));
}
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 plans = approvedPlans(db, examId);
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 existing = admissionRecords(db, 'placement', examId).filter(item => item.status !== 'withdrawn');
const occupied = new Map();
const occupiedIndicators = new Map();
const occupiedGeneral = new Map();
for (const placement of existing) {
const key = categoryKey(placement.schoolId, placement.payload?.categoryCode);
occupied.set(key, (occupied.get(key) || 0) + 1);
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) || {};
return { preference, profile, account, score: candidateTotalScore(db, examId, preference.userId) };
}).filter(item => item.score != null && !existing.some(entry => entry.userId === item.preference.userId && ['school_review', 'admitted', 'final', 'withdrawal_pending'].includes(entry.status)))
.sort((left, right) => right.score - left.score || String(left.account.candidateNumber || '').localeCompare(String(right.account.candidateNumber || '')));
const created = [];
for (const candidate of candidates) {
for (const [index, choice] of (candidate.preference.payload?.choices || []).entries()) {
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);
if ((occupied.get(key) || 0) >= Number(category.quota || 0)) continue;
const allocation = (category.indicatorAllocations || []).find(item => item.sourceSchoolId === candidate.profile.schoolId);
let quotaBucket = null;
if (allocation) {
const indicatorKey = `${key}|${candidate.profile.schoolId}`;
if ((occupiedIndicators.get(indicatorKey) || 0) < Number(allocation.quota || 0)) {
quotaBucket = `indicator:${candidate.profile.schoolId}`;
occupiedIndicators.set(indicatorKey, (occupiedIndicators.get(indicatorKey) || 0) + 1);
}
}
if (!quotaBucket) {
const generalQuota = Math.max(0, Number(category.quota || 0) - (category.indicatorAllocations || []).reduce((sum, item) => sum + Number(item.quota || 0), 0));
if ((occupiedGeneral.get(key) || 0) >= generalQuota) continue;
quotaBucket = 'general';
occupiedGeneral.set(key, (occupiedGeneral.get(key) || 0) + 1);
}
occupied.set(key, (occupied.get(key) || 0) + 1);
created.push({
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,
totalScore: candidate.score, featureScore: Number(db.registrations.find(item => item.examId === examId && item.userId === candidate.preference.userId)?.featureScore || 0),
specialtyQualification: resolveProfileSpecialty(candidate.profile), quotaBucket, schoolDecisionNote: '', withdrawalReason: '', withdrawalReviewNote: ''
}
});
break;
}
}
return created;
}
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 && item.status !== 'withdrawn').length;
return { ...category, used, remaining: Math.max(0, Number(category.quota || 0) - used) };
});
}
import { candidateEligibleForCategory, resolveProfileSpecialty } from '../data/specialty-types.mjs';