129 lines
7.2 KiB
JavaScript
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';
|