Files
Exam-Information-System/database.mjs
T

1588 lines
78 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { mkdir } from 'node:fs/promises';
import { dirname, join, resolve } from 'node:path';
import { mysqlSchema, sqliteSchema } from './src/database/schema.mjs';
import { createSqliteAdapter } from './src/database/sqlite-adapter.mjs';
import { createMysqlAdapter } from './src/database/mysql-adapter.mjs';
export const relationalTables = [
'schema_metadata',
'organization',
'schools',
'school_classes',
'school_student_partitions',
'users',
'candidate_profiles',
'notices',
'exams',
'exam_data_partitions',
'exam_subjects',
'registrations',
'registration_subjects',
'admission_number_rules',
'exam_arrangement_plans',
'admit_cards',
'admit_card_subjects',
'results',
'test_centers',
'test_rooms',
'center_change_requests',
'center_change_rooms',
'number_rules',
'number_rule_segments',
'candidate_account_batches',
'candidate_account_batch_items',
'workflow_definitions',
'workflow_steps',
'workflow_instances',
'workflow_actions',
'admission_records',
'audit_logs'
];
function validateState(state, source = '数据库') {
const collections = [
'schools', 'classes', 'users', 'candidateProfiles', 'notices', 'exams', 'registrations', 'results',
'testCenters', 'testRooms', 'centerChangeRequests', 'centerChangeRooms',
'admissionNumberRules', 'arrangementPlans',
'numberRules', 'candidateAccountBatches', 'candidateAccountBatchItems',
'workflows', 'workflowInstances', 'workflowActions', 'admissionRecords', 'auditLogs'
];
if (!state || typeof state !== 'object' || collections.some(name => !Array.isArray(state[name]))) {
throw new Error(`${source}中的应用数据格式无效`);
}
return state;
}
export function buildSeedOperations(state) {
validateState(state);
const operations = [];
const add = (sql, ...params) => operations.push({ sql, params });
const nullable = value => value == null || value === '' ? null : value;
add(
'UPDATE schema_metadata SET schema_version = 20, app_version = ?, self_registration_enabled = ?, created_at = ? WHERE id = 1',
Number(state.meta?.version || 1), state.settings?.selfRegistrationEnabled ? 1 : 0,
state.meta?.createdAt || new Date().toISOString()
);
add(
'INSERT INTO organization (id, name, code, phone, address) VALUES (1, ?, ?, ?, ?)',
state.organization?.name || '', state.organization?.code || '', state.organization?.phone || '', state.organization?.address || ''
);
for (const school of state.schools) {
add(
'INSERT INTO schools (id, name, code, address, is_source_school, is_admission_school, active) VALUES (?, ?, ?, ?, ?, ?, ?)',
school.id, school.name, school.code, nullable(school.address), school.isSourceSchool === false ? 0 : 1,
school.isAdmissionSchool === false ? 0 : 1, school.active === false ? 0 : 1
);
}
for (const schoolClass of state.classes) {
add(
'INSERT INTO school_classes (id, school_id, name, grade, active) VALUES (?, ?, ?, ?, ?)',
schoolClass.id, schoolClass.schoolId, schoolClass.name, schoolClass.grade, schoolClass.active === false ? 0 : 1
);
}
for (const user of state.users) {
add(
`INSERT INTO users (
id, username, candidate_number, password_hash, role, admin_level, school_id, class_id, active,
must_change_password, archived_at, archived_by, display_name, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
user.id, user.username, nullable(user.candidateNumber), user.passwordHash, user.role, nullable(user.adminLevel), nullable(user.schoolId),
nullable(user.classId), user.active === false ? 0 : 1, user.mustChangePassword ? 1 : 0, nullable(user.archivedAt),
nullable(user.archivedBy), user.displayName, user.createdAt
);
}
for (const profile of state.candidateProfiles) {
add(
`INSERT INTO candidate_profiles (
id, user_id, name, gender, id_number, phone, email, school, grade, school_id, class_id,
province_code, province_name, city_code, city_name, district_code, district_name, address,
emergency_contact, emergency_phone, native_place, birth_date, ethnicity, postal_code, guardian_name,
guardian_phone, specialty_category, specialty_type, specialty_types, specialty_certificate, policy_eligibility,
profile_completed, status, review_note, reviewed_at, reviewer_id, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
profile.id, profile.userId, profile.name, nullable(profile.gender), profile.idNumber, profile.phone,
nullable(profile.email), nullable(profile.school), nullable(profile.grade), nullable(profile.schoolId),
nullable(profile.classId), nullable(profile.provinceCode), nullable(profile.provinceName),
nullable(profile.cityCode), nullable(profile.cityName), nullable(profile.districtCode), nullable(profile.districtName),
nullable(profile.address),
nullable(profile.emergencyContact), nullable(profile.emergencyPhone), nullable(profile.nativePlace), nullable(profile.birthDate),
nullable(profile.ethnicity), nullable(profile.postalCode), nullable(profile.guardianName), nullable(profile.guardianPhone),
nullable(profile.specialtyCategory), nullable(profile.specialtyType), JSON.stringify(profile.specialtyTypes || []),
nullable(profile.specialtyCertificate), nullable(profile.policyEligibility),
profile.profileCompleted ? 1 : 0, profile.status, nullable(profile.reviewNote),
nullable(profile.reviewedAt), nullable(profile.reviewerId), profile.updatedAt
);
}
for (const notice of state.notices) {
add(
`INSERT INTO notices (
id, title, summary, content, category, pinned, status, publish_at, created_at, author
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
notice.id, notice.title, notice.summary, notice.content, notice.category, notice.pinned ? 1 : 0,
notice.status, nullable(notice.publishAt), nullable(notice.createdAt), notice.author
);
}
for (const exam of state.exams) {
add(
`INSERT INTO exams (
id, code, name, description, registration_start, registration_end, exam_start, exam_end,
admit_download_start, admit_download_end, location, pass_policy, pass_value, status, archived_at, archived_by, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
exam.id, exam.code, exam.name, exam.description || '', exam.registrationStart, exam.registrationEnd,
exam.examStart, exam.examEnd, exam.admitDownloadStart, exam.admitDownloadEnd, exam.location || '',
exam.passPolicy === 'score_ratio' ? 'rank_percent' : (exam.passPolicy || 'rank_percent'), Number(exam.passValue ?? 60), exam.status,
nullable(exam.archivedAt), nullable(exam.archivedBy), exam.createdAt
);
exam.subjects.forEach((subject, index) => add(
`INSERT INTO exam_subjects (
id, exam_id, name, subject_date, start_time, end_time, fee, full_score, pass_score, pass_rule, pass_value, position
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
subject.id, exam.id, subject.name, subject.date || String(exam.examStart).slice(0, 10),
subject.start || '', subject.end || '', Number(subject.fee || 0), Number(subject.fullScore || 150),
Number(subject.passScore ?? 0), subject.passRule === 'rank_percent' ? 'score_ratio' : (subject.passRule || 'fixed_score'),
Number(subject.passValue ?? subject.passScore ?? Number(subject.fullScore || 150) * .6), Number(subject.order || index + 1)
));
}
for (const registration of state.registrations) {
add(
`INSERT INTO registrations (
id, user_id, exam_id, status, payment_status, paid_at, paid_by, created_at, reviewed_at, review_note,
registration_number, number_rule_id, feature_score
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
registration.id, registration.userId, registration.examId, registration.status,
registration.paymentStatus, nullable(registration.paidAt), nullable(registration.paidBy), registration.createdAt,
nullable(registration.reviewedAt), nullable(registration.reviewNote),
nullable(registration.registrationNumber), nullable(registration.numberRuleId), Number(registration.featureScore || 0)
);
for (const subjectId of registration.subjectIds) {
add('INSERT INTO registration_subjects (registration_id, subject_id) VALUES (?, ?)', registration.id, subjectId);
}
}
for (const result of state.results) {
add(
`INSERT INTO results (
id, registration_id, subject_id, score, grade, published, updated_at, published_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
result.id, result.registrationId, result.subjectId, Number(result.score), result.grade,
result.published ? 1 : 0, nullable(result.updatedAt), nullable(result.publishedAt)
);
}
for (const center of state.testCenters) {
add(
`INSERT INTO test_centers (
id, school_id, code, name, province_code, province_name, city_code, city_name, district_code, district_name,
address, contact, manager_name, manager_phone, emergency_phone,
gate_open_time, transport, status, notes, rooms, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
center.id, center.schoolId, center.code, center.name,
center.provinceCode, center.provinceName, center.cityCode, center.cityName, center.districtCode, center.districtName,
center.address, nullable(center.contact),
nullable(center.managerName), nullable(center.managerPhone), nullable(center.emergencyPhone),
nullable(center.gateOpenTime), nullable(center.transport), center.status || 'active', nullable(center.notes),
center.rooms || '', center.updatedAt
);
}
for (const room of state.testRooms) {
add(
`INSERT INTO test_rooms (
id, center_id, code, name, building, floor, capacity, seat_plan, seat_start, seat_end, room_type, status, notes
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
room.id, room.centerId, room.code, room.name, room.building, nullable(room.floor), Number(room.capacity),
nullable(room.seatPlan), Number(room.seatStart || 1), Number(room.seatEnd || room.capacity), room.roomType, room.status || 'active', nullable(room.notes)
);
}
for (const rule of state.admissionNumberRules) {
add(
`INSERT INTO admission_number_rules (
id, code, name, description, \`separator\`, segments_json, example, active, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
rule.id, rule.code, rule.name, rule.description, rule.separator || '', JSON.stringify(rule.segments || []),
rule.example || '', rule.active === false ? 0 : 1, rule.createdAt
);
}
for (const plan of state.arrangementPlans) {
add(
`INSERT INTO exam_arrangement_plans (
id, exam_id, number_rule_id, mixing_scope, random_seed, candidate_count, center_count,
subject_assignment_count, subject_combination_count, same_school_center_rate, warnings_json, generated_by, generated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
plan.id, plan.examId, plan.numberRuleId, plan.mixingScope, plan.randomSeed,
Number(plan.candidateCount || 0), Number(plan.centerCount || 0), Number(plan.subjectAssignmentCount || 0),
Number(plan.subjectCombinationCount || 0), Number(plan.sameSchoolCenterRate || 0), JSON.stringify(plan.warnings || []),
nullable(plan.generatedBy), plan.generatedAt
);
}
for (const registration of state.registrations.filter(item => item.admitCard)) {
const card = registration.admitCard;
add(
`INSERT INTO admit_cards (
registration_id, plan_id, card_number, center_id, test_center, center_code, center_address, generated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
registration.id, card.planId, card.number, nullable(card.centerId), card.testCenter,
card.centerCode || '', card.centerAddress || '', card.generatedAt
);
for (const assignment of card.assignments || []) add(
`INSERT INTO admit_card_subjects (
registration_id, subject_id, room_id, room, room_code, exam_room_code, building, floor, seat, subject_signature
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
registration.id, assignment.subjectId, nullable(assignment.roomId), assignment.roomName || assignment.room,
assignment.roomCode, assignment.examRoomCode, assignment.building || '', assignment.floor || '', assignment.seat, assignment.subjectSignature || ''
);
}
for (const request of state.centerChangeRequests) {
add(
`INSERT INTO center_change_requests (
id, center_id, school_id, request_type, code, name,
province_code, province_name, city_code, city_name, district_code, district_name,
address, contact, manager_name, manager_phone,
emergency_phone, gate_open_time, transport, center_status, notes, status, review_note,
requested_by, created_at, reviewed_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
request.id, nullable(request.centerId), request.schoolId, request.requestType, request.code, request.name,
request.provinceCode, request.provinceName, request.cityCode, request.cityName, request.districtCode, request.districtName,
request.address, nullable(request.contact), nullable(request.managerName), nullable(request.managerPhone),
nullable(request.emergencyPhone), nullable(request.gateOpenTime), nullable(request.transport),
request.centerStatus || 'active', nullable(request.notes), request.status, nullable(request.reviewNote),
nullable(request.requestedBy), request.createdAt, nullable(request.reviewedAt)
);
}
for (const room of state.centerChangeRooms) {
add(
`INSERT INTO center_change_rooms (
id, request_id, room_id, code, name, building, floor, capacity, seat_plan, seat_start, seat_end, room_type, status, notes
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
room.id, room.requestId, nullable(room.roomId), room.code, room.name, room.building, nullable(room.floor),
Number(room.capacity), nullable(room.seatPlan), Number(room.seatStart || 1), Number(room.seatEnd || room.capacity), room.roomType,
room.status || 'active', nullable(room.notes)
);
}
for (const rule of state.numberRules) {
add(
'INSERT INTO number_rules (id, name, `separator`, active, created_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)',
rule.id, rule.name, rule.separator || '', rule.active ? 1 : 0, nullable(rule.createdBy), rule.updatedAt
);
rule.segments.forEach((segment, index) => add(
'INSERT INTO number_rule_segments (id, rule_id, position, type, value, width) VALUES (?, ?, ?, ?, ?, ?)',
segment.id, rule.id, Number(segment.position || index + 1), segment.type, nullable(segment.value), Number(segment.width || 0)
));
}
for (const batch of state.candidateAccountBatches) {
add(
`INSERT INTO candidate_account_batches (
id, school_id, requested_by, status, review_note, created_at, reviewed_at
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
batch.id, batch.schoolId, nullable(batch.requestedBy), batch.status, nullable(batch.reviewNote),
batch.createdAt, nullable(batch.reviewedAt)
);
}
for (const item of state.candidateAccountBatchItems) {
add(
`INSERT INTO candidate_account_batch_items (
id, batch_id, class_id, position, candidate_number, initial_password, user_id, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
item.id, item.batchId, item.classId, Number(item.position), nullable(item.candidateNumber),
nullable(item.initialPassword), nullable(item.userId), nullable(item.createdAt)
);
}
for (const workflow of state.workflows) {
add(
`INSERT INTO workflow_definitions (
id, business_type, name, active, updated_by, updated_at
) VALUES (?, ?, ?, ?, ?, ?)`,
workflow.id, workflow.businessType, workflow.name, workflow.active === false ? 0 : 1,
nullable(workflow.updatedBy), workflow.updatedAt
);
workflow.steps.forEach((step, index) => add(
`INSERT INTO workflow_steps (
id, workflow_id, position, name, admin_level
) VALUES (?, ?, ?, ?, ?)`,
step.id, workflow.id, Number(step.position || index + 1), step.name, step.adminLevel
));
}
for (const instance of state.workflowInstances) {
add(
`INSERT INTO workflow_instances (
id, workflow_id, business_type, business_id, status, current_step, assignee_id, created_at, completed_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
instance.id, instance.workflowId, instance.businessType, instance.businessId, instance.status,
Number(instance.currentStep || 1), nullable(instance.assigneeId), instance.createdAt, nullable(instance.completedAt)
);
}
for (const action of state.workflowActions) {
add(
`INSERT INTO workflow_actions (
id, instance_id, actor_id, action, note, from_assignee_id, to_assignee_id, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
action.id, action.instanceId, nullable(action.actorId), action.action, nullable(action.note),
nullable(action.fromAssigneeId), nullable(action.toAssigneeId), action.createdAt
);
}
for (const record of state.admissionRecords) {
add(
`INSERT INTO admission_records (
id, kind, exam_id, user_id, school_id, status, payload_json, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
record.id, record.kind, record.examId, nullable(record.userId), nullable(record.schoolId), record.status,
JSON.stringify(record.payload || {}), record.createdAt, record.updatedAt
);
}
for (const log of state.auditLogs) {
add(
'INSERT INTO audit_logs (id, actor_id, action, detail, created_at) VALUES (?, ?, ?, ?, ?)',
log.id, nullable(log.actorId), log.action, log.detail, log.createdAt
);
}
return operations;
}
function stateFromRows(rows) {
const parseJson = (value, fallback) => {
if (value != null && typeof value === 'object') return value;
try { return JSON.parse(value || JSON.stringify(fallback)); } catch { return fallback; }
};
const subjectsByExam = new Map();
for (const row of rows.subjects) {
const fullScore = Number(row.full_score ?? 150);
const passRule = row.pass_rule === 'score_ratio' ? 'rank_percent' : (row.pass_rule || 'fixed_score');
const passValue = Number(row.pass_value ?? row.pass_score ?? fullScore * .6);
const subject = {
id: row.id,
name: row.name,
date: row.subject_date,
start: row.start_time,
end: row.end_time,
fee: Number(row.fee),
fullScore,
passRule,
passValue,
passScore: passRule === 'fixed_score' ? Number(passValue.toFixed(2)) : null,
order: Number(row.position)
};
const subjects = subjectsByExam.get(row.exam_id) || [];
subjects.push(subject);
subjectsByExam.set(row.exam_id, subjects);
}
const registrationSubjects = new Map();
for (const row of rows.registrationSubjects) {
const subjectIds = registrationSubjects.get(row.registration_id) || [];
subjectIds.push(row.subject_id);
registrationSubjects.set(row.registration_id, subjectIds);
}
const subjectPositions = new Map(rows.subjects.map(row => [row.id, Number(row.position)]));
const admitAssignments = new Map();
for (const row of rows.admitCardSubjects) {
const assignments = admitAssignments.get(row.registration_id) || [];
assignments.push({
subjectId: row.subject_id,
roomId: row.room_id,
roomName: row.room,
room: row.room,
roomCode: row.room_code,
examRoomCode: row.exam_room_code,
building: row.building || '',
floor: row.floor || '',
seat: row.seat,
subjectSignature: row.subject_signature || ''
});
admitAssignments.set(row.registration_id, assignments);
}
for (const assignments of admitAssignments.values()) assignments.sort((left, right) =>
(subjectPositions.get(left.subjectId) || 0) - (subjectPositions.get(right.subjectId) || 0)
);
const admitCards = new Map(rows.admitCards.map(row => {
const assignments = admitAssignments.get(row.registration_id) || [];
const primary = assignments[0] || {};
return [row.registration_id, {
planId: row.plan_id,
number: row.card_number,
centerId: row.center_id,
testCenter: row.test_center,
centerCode: row.center_code || '',
centerAddress: row.center_address || '',
room: primary.roomName || '',
seat: primary.seat || '',
assignments,
generatedAt: row.generated_at
}];
}));
const ruleSegments = new Map();
for (const row of rows.numberRuleSegments) {
const segments = ruleSegments.get(row.rule_id) || [];
segments.push({
id: row.id,
type: row.type,
value: row.value || '',
width: Number(row.width || 0),
position: Number(row.position)
});
ruleSegments.set(row.rule_id, segments);
}
const workflowSteps = new Map();
for (const row of rows.workflowSteps) {
const steps = workflowSteps.get(row.workflow_id) || [];
steps.push({
id: row.id,
name: row.name,
adminLevel: row.admin_level,
position: Number(row.position)
});
workflowSteps.set(row.workflow_id, steps);
}
const organization = rows.organization;
const state = {
meta: { version: Number(rows.system.app_version), createdAt: rows.system.created_at },
settings: { selfRegistrationEnabled: Boolean(rows.system.self_registration_enabled) },
organization: {
name: organization.name,
code: organization.code,
phone: organization.phone,
address: organization.address
},
schools: rows.schools.map(row => ({
id: row.id,
name: row.name,
code: row.code,
address: row.address || '',
isSourceSchool: row.is_source_school == null ? true : Boolean(row.is_source_school),
isAdmissionSchool: row.is_admission_school == null ? true : Boolean(row.is_admission_school),
active: Boolean(row.active)
})),
classes: rows.classes.map(row => ({
id: row.id,
schoolId: row.school_id,
name: row.name,
grade: row.grade,
active: Boolean(row.active)
})),
users: rows.users.map(row => ({
id: row.id,
username: row.username,
candidateNumber: row.candidate_number || '',
passwordHash: row.password_hash,
role: row.role,
adminLevel: row.admin_level || (row.role === 'admin' ? 'super' : null),
schoolId: row.school_id || null,
classId: row.class_id || null,
active: row.active == null ? true : Boolean(row.active),
mustChangePassword: Boolean(row.must_change_password),
totpEnabled: Boolean(row.totp_enabled),
totpSecretEncrypted: row.totp_secret_encrypted || null,
totpRecoveryCodes: (() => { try { return JSON.parse(row.totp_recovery_codes || '[]'); } catch { return []; } })(),
totpLastUsedStep: row.totp_last_used_step == null ? null : Number(row.totp_last_used_step),
archivedAt: row.archived_at || null,
archivedBy: row.archived_by || null,
displayName: row.display_name,
createdAt: row.created_at
})),
candidateProfiles: rows.profiles.map(row => ({
id: row.id,
userId: row.user_id,
name: row.name,
gender: row.gender || '',
idNumber: row.id_number,
phone: row.phone,
email: row.email || '',
school: row.school || '',
grade: row.grade || '',
schoolId: row.school_id || null,
classId: row.class_id || null,
provinceCode: row.province_code || '',
provinceName: row.province_name || '',
cityCode: row.city_code || '',
cityName: row.city_name || '',
districtCode: row.district_code || '',
districtName: row.district_name || '',
address: row.address || '',
emergencyContact: row.emergency_contact || '',
emergencyPhone: row.emergency_phone || '',
nativePlace: row.native_place || '',
birthDate: row.birth_date || '',
ethnicity: row.ethnicity || '',
postalCode: row.postal_code || '',
guardianName: row.guardian_name || '',
guardianPhone: row.guardian_phone || '',
specialtyCategory: row.specialty_category || '',
specialtyType: row.specialty_type || '',
specialtyTypes: parseJson(row.specialty_types, []),
specialtyCertificate: row.specialty_certificate || '',
policyEligibility: row.policy_eligibility || '',
profileCompleted: Boolean(row.profile_completed),
status: row.status,
reviewNote: row.review_note || '',
reviewedAt: row.reviewed_at,
reviewerId: row.reviewer_id,
updatedAt: row.updated_at
})),
notices: rows.notices.map(row => ({
id: row.id,
title: row.title,
summary: row.summary,
content: row.content,
category: row.category,
pinned: Boolean(row.pinned),
status: row.status,
publishAt: row.publish_at,
createdAt: row.created_at,
author: row.author
})),
exams: rows.exams.map(row => ({
id: row.id,
code: row.code,
name: row.name,
description: row.description,
registrationStart: row.registration_start,
registrationEnd: row.registration_end,
examStart: row.exam_start,
examEnd: row.exam_end,
admitDownloadStart: row.admit_download_start,
admitDownloadEnd: row.admit_download_end,
location: row.location,
passPolicy: row.pass_policy === 'score_ratio' ? 'rank_percent' : (row.pass_policy || 'rank_percent'),
passValue: Number(row.pass_value ?? 60),
status: row.status,
archivedAt: row.archived_at || null,
archivedBy: row.archived_by || null,
createdAt: row.created_at,
subjects: subjectsByExam.get(row.id) || []
})),
registrations: rows.registrations.map(row => ({
id: row.id,
userId: row.user_id,
examId: row.exam_id,
subjectIds: registrationSubjects.get(row.id) || [],
status: row.status,
paymentStatus: row.payment_status,
paidAt: row.paid_at || null,
paidBy: row.paid_by || null,
createdAt: row.created_at,
reviewedAt: row.reviewed_at,
reviewNote: row.review_note || '',
registrationNumber: row.registration_number || '',
numberRuleId: row.number_rule_id || null,
featureScore: Number(row.feature_score || 0),
admitCard: admitCards.get(row.id) || null
})),
results: rows.results.map(row => ({
id: row.id,
registrationId: row.registration_id,
subjectId: row.subject_id,
score: Number(row.score),
grade: row.grade,
published: Boolean(row.published),
updatedAt: row.updated_at,
publishedAt: row.published_at
})),
testCenters: rows.testCenters.map(row => ({
id: row.id,
schoolId: row.school_id,
code: row.code || '',
name: row.name,
provinceCode: row.province_code || '',
provinceName: row.province_name || '',
cityCode: row.city_code || '',
cityName: row.city_name || '',
districtCode: row.district_code || '',
districtName: row.district_name || '',
address: row.address,
contact: row.contact || '',
managerName: row.manager_name || '',
managerPhone: row.manager_phone || '',
emergencyPhone: row.emergency_phone || '',
gateOpenTime: row.gate_open_time || '',
transport: row.transport || '',
status: row.status || 'active',
notes: row.notes || '',
rooms: row.rooms || '',
updatedAt: row.updated_at
})),
testRooms: rows.testRooms.map(row => ({
id: row.id,
centerId: row.center_id,
code: row.code,
name: row.name,
building: row.building,
floor: row.floor || '',
capacity: Number(row.capacity),
seatPlan: row.seat_plan || '',
seatStart: Number(row.seat_start),
seatEnd: Number(row.seat_end),
roomType: row.room_type,
status: row.status,
notes: row.notes || ''
})),
centerChangeRequests: rows.centerChangeRequests.map(row => ({
id: row.id,
centerId: row.center_id,
schoolId: row.school_id,
requestType: row.request_type,
code: row.code,
name: row.name,
provinceCode: row.province_code || '',
provinceName: row.province_name || '',
cityCode: row.city_code || '',
cityName: row.city_name || '',
districtCode: row.district_code || '',
districtName: row.district_name || '',
address: row.address,
contact: row.contact || '',
managerName: row.manager_name || '',
managerPhone: row.manager_phone || '',
emergencyPhone: row.emergency_phone || '',
gateOpenTime: row.gate_open_time || '',
transport: row.transport || '',
centerStatus: row.center_status,
notes: row.notes || '',
status: row.status,
reviewNote: row.review_note || '',
requestedBy: row.requested_by,
createdAt: row.created_at,
reviewedAt: row.reviewed_at
})),
centerChangeRooms: rows.centerChangeRooms.map(row => ({
id: row.id,
requestId: row.request_id,
roomId: row.room_id,
code: row.code,
name: row.name,
building: row.building,
floor: row.floor || '',
capacity: Number(row.capacity),
seatPlan: row.seat_plan || '',
seatStart: Number(row.seat_start),
seatEnd: Number(row.seat_end),
roomType: row.room_type,
status: row.status,
notes: row.notes || ''
})),
admissionNumberRules: rows.admissionNumberRules.map(row => ({
id: row.id,
code: row.code,
name: row.name,
description: row.description,
separator: row.separator || '',
segments: Array.isArray(row.segments_json) ? row.segments_json : JSON.parse(row.segments_json || '[]'),
example: row.example || '',
active: Boolean(row.active),
createdAt: row.created_at
})),
arrangementPlans: rows.arrangementPlans.map(row => ({
id: row.id,
examId: row.exam_id,
numberRuleId: row.number_rule_id,
mixingScope: row.mixing_scope,
randomSeed: row.random_seed,
candidateCount: Number(row.candidate_count),
centerCount: Number(row.center_count),
subjectAssignmentCount: Number(row.subject_assignment_count),
subjectCombinationCount: Number(row.subject_combination_count),
sameSchoolCenterRate: Number(row.same_school_center_rate),
warnings: Array.isArray(row.warnings_json) ? row.warnings_json : JSON.parse(row.warnings_json || '[]'),
generatedBy: row.generated_by,
generatedAt: row.generated_at
})),
numberRules: rows.numberRules.map(row => ({
id: row.id,
name: row.name,
separator: row.separator,
active: Boolean(row.active),
createdBy: row.created_by,
updatedAt: row.updated_at,
segments: ruleSegments.get(row.id) || []
})),
candidateAccountBatches: rows.candidateAccountBatches.map(row => ({
id: row.id,
schoolId: row.school_id,
requestedBy: row.requested_by,
status: row.status,
reviewNote: row.review_note || '',
createdAt: row.created_at,
reviewedAt: row.reviewed_at
})),
candidateAccountBatchItems: rows.candidateAccountBatchItems.map(row => ({
id: row.id,
batchId: row.batch_id,
classId: row.class_id,
position: Number(row.position),
candidateNumber: row.candidate_number || '',
initialPassword: row.initial_password || '',
userId: row.user_id,
createdAt: row.created_at
})),
workflows: rows.workflows.map(row => ({
id: row.id,
businessType: row.business_type,
name: row.name,
active: Boolean(row.active),
updatedBy: row.updated_by,
updatedAt: row.updated_at,
steps: workflowSteps.get(row.id) || []
})),
workflowInstances: rows.workflowInstances.map(row => ({
id: row.id,
workflowId: row.workflow_id,
businessType: row.business_type,
businessId: row.business_id,
status: row.status,
currentStep: Number(row.current_step),
assigneeId: row.assignee_id,
createdAt: row.created_at,
completedAt: row.completed_at
})),
workflowActions: rows.workflowActions.map(row => ({
id: row.id,
instanceId: row.instance_id,
actorId: row.actor_id,
action: row.action,
note: row.note || '',
fromAssigneeId: row.from_assignee_id,
toAssigneeId: row.to_assignee_id,
createdAt: row.created_at
})),
admissionRecords: rows.admissionRecords.map(row => ({
id: row.id,
kind: row.kind,
examId: row.exam_id,
userId: row.user_id || null,
schoolId: row.school_id || null,
status: row.status,
payload: parseJson(row.payload_json, {}),
createdAt: row.created_at,
updatedAt: row.updated_at
})),
auditLogs: rows.auditLogs.map(row => ({
id: row.id,
actorId: row.actor_id,
action: row.action,
detail: row.detail,
createdAt: row.created_at
}))
};
return state;
}
function readSqliteRows(connection) {
return {
system: connection.prepare('SELECT * FROM schema_metadata WHERE id = 1').get(),
organization: connection.prepare('SELECT * FROM organization WHERE id = 1').get(),
schools: connection.prepare('SELECT * FROM schools ORDER BY name, id').all(),
classes: connection.prepare('SELECT * FROM school_classes ORDER BY school_id, grade, name, id').all(),
users: connection.prepare('SELECT * FROM users ORDER BY created_at, id').all(),
profiles: connection.prepare('SELECT * FROM candidate_profiles ORDER BY updated_at, id').all(),
notices: connection.prepare('SELECT * FROM notices ORDER BY publish_at, created_at, id').all(),
exams: connection.prepare('SELECT * FROM exams ORDER BY created_at, id').all(),
subjects: connection.prepare('SELECT * FROM exam_subjects ORDER BY exam_id, position, id').all(),
registrations: connection.prepare('SELECT * FROM registrations ORDER BY created_at, id').all(),
registrationSubjects: connection.prepare('SELECT * FROM registration_subjects ORDER BY registration_id, subject_id').all(),
admissionNumberRules: connection.prepare('SELECT * FROM admission_number_rules ORDER BY created_at, id').all(),
arrangementPlans: connection.prepare('SELECT * FROM exam_arrangement_plans ORDER BY generated_at DESC, id').all(),
admitCards: connection.prepare('SELECT * FROM admit_cards ORDER BY registration_id').all(),
admitCardSubjects: connection.prepare('SELECT * FROM admit_card_subjects ORDER BY registration_id, subject_id').all(),
results: connection.prepare('SELECT * FROM results ORDER BY id').all(),
testCenters: connection.prepare('SELECT * FROM test_centers ORDER BY school_id, name, id').all(),
testRooms: connection.prepare('SELECT * FROM test_rooms ORDER BY center_id, code, id').all(),
centerChangeRequests: connection.prepare('SELECT * FROM center_change_requests ORDER BY created_at DESC, id').all(),
centerChangeRooms: connection.prepare('SELECT * FROM center_change_rooms ORDER BY request_id, code, id').all(),
numberRules: connection.prepare('SELECT * FROM number_rules ORDER BY updated_at DESC, id').all(),
numberRuleSegments: connection.prepare('SELECT * FROM number_rule_segments ORDER BY rule_id, position, id').all(),
candidateAccountBatches: connection.prepare('SELECT * FROM candidate_account_batches ORDER BY created_at DESC, id').all(),
candidateAccountBatchItems: connection.prepare('SELECT * FROM candidate_account_batch_items ORDER BY batch_id, position, id').all(),
workflows: connection.prepare('SELECT * FROM workflow_definitions ORDER BY business_type, id').all(),
workflowSteps: connection.prepare('SELECT * FROM workflow_steps ORDER BY workflow_id, position, id').all(),
workflowInstances: connection.prepare('SELECT * FROM workflow_instances ORDER BY created_at DESC, id').all(),
workflowActions: connection.prepare('SELECT * FROM workflow_actions ORDER BY created_at, id').all(),
admissionRecords: connection.prepare('SELECT * FROM admission_records ORDER BY created_at, id').all(),
auditLogs: connection.prepare('SELECT * FROM audit_logs ORDER BY created_at DESC, id DESC').all()
};
}
async function readMysqlRows(connection) {
const query = async sql => (await connection.execute(sql))[0];
const one = async sql => (await query(sql))[0];
return {
system: await one('SELECT * FROM schema_metadata WHERE id = 1'),
organization: await one('SELECT * FROM organization WHERE id = 1'),
schools: await query('SELECT * FROM schools ORDER BY name, id'),
classes: await query('SELECT * FROM school_classes ORDER BY school_id, grade, name, id'),
users: await query('SELECT * FROM users ORDER BY created_at, id'),
profiles: await query('SELECT * FROM candidate_profiles ORDER BY updated_at, id'),
notices: await query('SELECT * FROM notices ORDER BY publish_at, created_at, id'),
exams: await query('SELECT * FROM exams ORDER BY created_at, id'),
subjects: await query('SELECT * FROM exam_subjects ORDER BY exam_id, position, id'),
registrations: await query('SELECT * FROM registrations ORDER BY created_at, id'),
registrationSubjects: await query('SELECT * FROM registration_subjects ORDER BY registration_id, subject_id'),
admissionNumberRules: await query('SELECT * FROM admission_number_rules ORDER BY created_at, id'),
arrangementPlans: await query('SELECT * FROM exam_arrangement_plans ORDER BY generated_at DESC, id'),
admitCards: await query('SELECT * FROM admit_cards ORDER BY registration_id'),
admitCardSubjects: await query('SELECT * FROM admit_card_subjects ORDER BY registration_id, subject_id'),
results: await query('SELECT * FROM results ORDER BY id'),
testCenters: await query('SELECT * FROM test_centers ORDER BY school_id, name, id'),
testRooms: await query('SELECT * FROM test_rooms ORDER BY center_id, code, id'),
centerChangeRequests: await query('SELECT * FROM center_change_requests ORDER BY created_at DESC, id'),
centerChangeRooms: await query('SELECT * FROM center_change_rooms ORDER BY request_id, code, id'),
numberRules: await query('SELECT * FROM number_rules ORDER BY updated_at DESC, id'),
numberRuleSegments: await query('SELECT * FROM number_rule_segments ORDER BY rule_id, position, id'),
candidateAccountBatches: await query('SELECT * FROM candidate_account_batches ORDER BY created_at DESC, id'),
candidateAccountBatchItems: await query('SELECT * FROM candidate_account_batch_items ORDER BY batch_id, position, id'),
workflows: await query('SELECT * FROM workflow_definitions ORDER BY business_type, id'),
workflowSteps: await query('SELECT * FROM workflow_steps ORDER BY workflow_id, position, id'),
workflowInstances: await query('SELECT * FROM workflow_instances ORDER BY created_at DESC, id'),
workflowActions: await query('SELECT * FROM workflow_actions ORDER BY created_at, id'),
admissionRecords: await query('SELECT * FROM admission_records ORDER BY created_at, id'),
auditLogs: await query('SELECT * FROM audit_logs ORDER BY created_at DESC, id DESC')
};
}
function operation(sql, ...params) {
return { sql, params };
}
function optional(value) {
return value == null || value === '' ? null : value;
}
function auditOperation(log) {
return operation(
'INSERT INTO audit_logs (id, actor_id, action, detail, created_at) VALUES (?, ?, ?, ?, ?)',
log.id, optional(log.actorId), log.action, log.detail, log.createdAt
);
}
function workflowInstanceOperation(instance) {
return operation(
`INSERT INTO workflow_instances (
id, workflow_id, business_type, business_id, status, current_step, assignee_id, created_at, completed_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
instance.id, instance.workflowId, instance.businessType, instance.businessId, instance.status,
Number(instance.currentStep || 1), optional(instance.assigneeId), instance.createdAt, optional(instance.completedAt)
);
}
function workflowActionOperation(action) {
return operation(
`INSERT INTO workflow_actions (
id, instance_id, actor_id, action, note, from_assignee_id, to_assignee_id, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
action.id, action.instanceId, optional(action.actorId), action.action, optional(action.note),
optional(action.fromAssigneeId), optional(action.toAssigneeId), action.createdAt
);
}
function workflowCreateOperations(instance, action) {
return [workflowInstanceOperation(instance), workflowActionOperation(action)];
}
function createRepository({ client, location, read, transaction, close }) {
return {
client,
location,
read,
close,
async createCandidate(user, profile, instance, action, log = null) {
const operations = [
operation(
`INSERT INTO users (
id, username, candidate_number, password_hash, role, admin_level, school_id, class_id, active,
must_change_password, archived_at, archived_by, display_name, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
user.id, user.username, optional(user.candidateNumber), user.passwordHash, user.role, optional(user.adminLevel), optional(user.schoolId),
optional(user.classId), user.active === false ? 0 : 1, user.mustChangePassword ? 1 : 0, optional(user.archivedAt),
optional(user.archivedBy), user.displayName, user.createdAt
),
operation(
`INSERT INTO candidate_profiles (
id, user_id, name, gender, id_number, phone, email, school, grade, school_id, class_id,
province_code, province_name, city_code, city_name, district_code, district_name, address,
emergency_contact, emergency_phone, native_place, birth_date, ethnicity, postal_code, guardian_name,
guardian_phone, profile_completed, status, review_note, reviewed_at, reviewer_id, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
profile.id, profile.userId, profile.name, optional(profile.gender), profile.idNumber, profile.phone,
optional(profile.email), optional(profile.school), optional(profile.grade), optional(profile.schoolId),
optional(profile.classId), optional(profile.provinceCode), optional(profile.provinceName),
optional(profile.cityCode), optional(profile.cityName), optional(profile.districtCode), optional(profile.districtName),
optional(profile.address),
optional(profile.emergencyContact), optional(profile.emergencyPhone), optional(profile.nativePlace), optional(profile.birthDate),
optional(profile.ethnicity), optional(profile.postalCode), optional(profile.guardianName), optional(profile.guardianPhone),
profile.profileCompleted ? 1 : 0, profile.status, optional(profile.reviewNote),
optional(profile.reviewedAt), optional(profile.reviewerId), profile.updatedAt
)
];
if (instance && action) operations.push(...workflowCreateOperations(instance, action));
if (log) operations.push(auditOperation(log));
await transaction(operations);
},
async createCandidateAccountBatch(batch, items, instance, action, log) {
const operations = [
operation(
`INSERT INTO candidate_account_batches (
id, school_id, requested_by, status, review_note, created_at, reviewed_at
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
batch.id, batch.schoolId, optional(batch.requestedBy), batch.status, optional(batch.reviewNote),
batch.createdAt, optional(batch.reviewedAt)
)
];
for (const item of items) operations.push(operation(
`INSERT INTO candidate_account_batch_items (
id, batch_id, class_id, position, candidate_number, initial_password, user_id, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
item.id, item.batchId, item.classId, Number(item.position), optional(item.candidateNumber),
optional(item.initialPassword), optional(item.userId), optional(item.createdAt)
));
operations.push(...workflowCreateOperations(instance, action), auditOperation(log));
await transaction(operations);
},
async updateCandidateProfile(profile, displayName, instance, action) {
const operations = [
operation(
`UPDATE candidate_profiles SET
name = ?, gender = ?, id_number = ?, phone = ?, email = ?, school = ?, grade = ?,
province_code = ?, province_name = ?, city_code = ?, city_name = ?, district_code = ?, district_name = ?, address = ?,
school_id = ?, class_id = ?, emergency_contact = ?, emergency_phone = ?, status = ?, review_note = ?,
native_place = ?, birth_date = ?, ethnicity = ?, postal_code = ?, guardian_name = ?, guardian_phone = ?,
specialty_category = ?, specialty_type = ?, specialty_types = ?, specialty_certificate = ?, policy_eligibility = ?,
profile_completed = ?, reviewed_at = ?, reviewer_id = ?, updated_at = ?
WHERE id = ?`,
profile.name, optional(profile.gender), profile.idNumber, profile.phone, optional(profile.email),
optional(profile.school), optional(profile.grade), optional(profile.provinceCode), optional(profile.provinceName),
optional(profile.cityCode), optional(profile.cityName), optional(profile.districtCode), optional(profile.districtName),
optional(profile.address), optional(profile.schoolId),
optional(profile.classId), optional(profile.emergencyContact), optional(profile.emergencyPhone), profile.status,
optional(profile.reviewNote), optional(profile.nativePlace), optional(profile.birthDate), optional(profile.ethnicity),
optional(profile.postalCode), optional(profile.guardianName), optional(profile.guardianPhone), optional(profile.specialtyCategory),
optional(profile.specialtyType), JSON.stringify(profile.specialtyTypes || []),
optional(profile.specialtyCertificate), optional(profile.policyEligibility), profile.profileCompleted ? 1 : 0, optional(profile.reviewedAt),
optional(profile.reviewerId), profile.updatedAt, profile.id
),
operation('UPDATE users SET display_name = ? WHERE id = ?', displayName, profile.userId)
];
if (instance && action) operations.push(...workflowCreateOperations(instance, action));
await transaction(operations);
},
async changePassword(user, log) {
const operations = [operation(
'UPDATE users SET password_hash = ?, must_change_password = ? WHERE id = ?',
user.passwordHash, user.mustChangePassword ? 1 : 0, user.id
)];
if (log) operations.push(auditOperation(log));
await transaction(operations);
},
async updateTotpSecurity(user, log = null) {
const operations = [operation(
`UPDATE users SET
totp_enabled = ?, totp_secret_encrypted = ?, totp_recovery_codes = ?, totp_last_used_step = ?
WHERE id = ?`,
user.totpEnabled ? 1 : 0, optional(user.totpSecretEncrypted), JSON.stringify(user.totpRecoveryCodes || []),
optional(user.totpLastUsedStep), user.id
)];
if (log) operations.push(auditOperation(log));
await transaction(operations);
},
async updateCandidateArchives(users, log) {
const operations = users.map(user => operation(
'UPDATE users SET archived_at = ?, archived_by = ? WHERE id = ?',
optional(user.archivedAt), optional(user.archivedBy), user.id
));
operations.push(auditOperation(log));
await transaction(operations);
},
async updateRegistrationSetting(enabled, log) {
await transaction([
operation('UPDATE schema_metadata SET self_registration_enabled = ? WHERE id = 1', enabled ? 1 : 0),
auditOperation(log)
]);
},
async createRegistration(registration, instance, action) {
const operations = [operation(
`INSERT INTO registrations (
id, user_id, exam_id, status, payment_status, paid_at, paid_by, created_at, reviewed_at, review_note,
registration_number, number_rule_id, feature_score
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
registration.id, registration.userId, registration.examId, registration.status,
registration.paymentStatus, optional(registration.paidAt), optional(registration.paidBy), registration.createdAt,
optional(registration.reviewedAt), optional(registration.reviewNote),
optional(registration.registrationNumber), optional(registration.numberRuleId), Number(registration.featureScore || 0)
)];
for (const subjectId of registration.subjectIds) {
operations.push(operation(
'INSERT INTO registration_subjects (registration_id, subject_id) VALUES (?, ?)',
registration.id, subjectId
));
}
if (instance && action) operations.push(...workflowCreateOperations(instance, action));
await transaction(operations);
},
async reviewCandidate(profile, log) {
await transaction([
operation(
`UPDATE candidate_profiles SET status = ?, review_note = ?, reviewed_at = ?, reviewer_id = ? WHERE id = ?`,
profile.status, optional(profile.reviewNote), profile.reviewedAt, profile.reviewerId, profile.id
),
auditOperation(log)
]);
},
async reviewRegistration(registration, log) {
await transaction([
operation(
`UPDATE registrations SET status = ?, payment_status = ?, reviewed_at = ?, review_note = ? WHERE id = ?`,
registration.status, registration.paymentStatus, registration.reviewedAt,
optional(registration.reviewNote), registration.id
),
auditOperation(log)
]);
},
async updateRegistrationPayment(registration, log) {
await transaction([
operation(
'UPDATE registrations SET payment_status = ?, paid_at = ?, paid_by = ? WHERE id = ?',
registration.paymentStatus, optional(registration.paidAt), optional(registration.paidBy), registration.id
),
auditOperation(log)
]);
},
async processWorkflow(instance, action, business, log) {
const operations = [
operation(
`UPDATE workflow_instances SET
status = ?, current_step = ?, assignee_id = ?, completed_at = ? WHERE id = ?`,
instance.status, Number(instance.currentStep), optional(instance.assigneeId),
optional(instance.completedAt), instance.id
),
workflowActionOperation(action)
];
if (instance.businessType === 'profile_change') {
operations.push(operation(
`UPDATE candidate_profiles SET
status = ?, review_note = ?, reviewed_at = ?, reviewer_id = ? WHERE id = ?`,
business.status, optional(business.reviewNote), optional(business.reviewedAt),
optional(business.reviewerId), business.id
));
} else if (instance.businessType === 'registration_review') {
operations.push(operation(
`UPDATE registrations SET
status = ?, payment_status = ?, paid_at = ?, paid_by = ?, reviewed_at = ?, review_note = ?,
registration_number = ?, number_rule_id = ? WHERE id = ?`,
business.status, business.paymentStatus, optional(business.paidAt), optional(business.paidBy),
optional(business.reviewedAt), optional(business.reviewNote),
optional(business.registrationNumber), optional(business.numberRuleId), business.id
));
} else if (instance.businessType === 'center_change') {
operations.push(operation(
`UPDATE center_change_requests SET
status = ?, review_note = ?, reviewed_at = ? WHERE id = ?`,
business.status, optional(business.reviewNote), optional(business.reviewedAt), business.id
));
} else if (instance.businessType === 'candidate_account_batch') {
operations.push(operation(
`UPDATE candidate_account_batches SET
status = ?, review_note = ?, reviewed_at = ? WHERE id = ?`,
business.status, optional(business.reviewNote), optional(business.reviewedAt), business.id
));
} else if (instance.businessType === 'score_appeal' && business) {
operations.push(operation(
`UPDATE results SET score = ?, grade = ?, published = ?, updated_at = ?, published_at = ? WHERE id = ?`,
Number(business.score), business.grade, business.published ? 1 : 0,
optional(business.updatedAt), optional(business.publishedAt), business.id
));
}
if (log) operations.push(auditOperation(log));
await transaction(operations);
},
async completeCandidateAccountBatch(batch, items, users, profiles, instance, action, log) {
const operations = [
operation(
`UPDATE workflow_instances SET
status = ?, current_step = ?, assignee_id = ?, completed_at = ? WHERE id = ?`,
instance.status, Number(instance.currentStep), optional(instance.assigneeId), optional(instance.completedAt), instance.id
),
workflowActionOperation(action),
operation(
`UPDATE candidate_account_batches SET status = ?, review_note = ?, reviewed_at = ? WHERE id = ?`,
batch.status, optional(batch.reviewNote), optional(batch.reviewedAt), batch.id
)
];
for (let index = 0; index < users.length; index += 1) {
const user = users[index];
const profile = profiles[index];
const item = items[index];
operations.push(
operation(
`INSERT INTO users (
id, username, candidate_number, password_hash, role, admin_level, school_id, class_id, active,
must_change_password, archived_at, archived_by, display_name, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
user.id, user.username, user.candidateNumber, user.passwordHash, user.role, optional(user.adminLevel),
user.schoolId, user.classId, 1, 1, null, null, user.displayName, user.createdAt
),
operation(
`INSERT INTO candidate_profiles (
id, user_id, name, gender, id_number, phone, email, school, grade, school_id, class_id,
province_code, province_name, city_code, city_name, district_code, district_name, address,
emergency_contact, emergency_phone, native_place, birth_date, ethnicity, postal_code, guardian_name,
guardian_phone, profile_completed, status, review_note, reviewed_at, reviewer_id, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
profile.id, profile.userId, profile.name, optional(profile.gender), profile.idNumber, profile.phone,
optional(profile.email), profile.school, profile.grade, profile.schoolId, profile.classId,
optional(profile.provinceCode), optional(profile.provinceName), optional(profile.cityCode), optional(profile.cityName),
optional(profile.districtCode), optional(profile.districtName), optional(profile.address),
optional(profile.emergencyContact), optional(profile.emergencyPhone),
optional(profile.nativePlace), optional(profile.birthDate), optional(profile.ethnicity), optional(profile.postalCode),
optional(profile.guardianName), optional(profile.guardianPhone), 0, profile.status, optional(profile.reviewNote),
optional(profile.reviewedAt), optional(profile.reviewerId), profile.updatedAt
),
operation(
`UPDATE candidate_account_batch_items SET
candidate_number = ?, initial_password = ?, user_id = ?, created_at = ? WHERE id = ?`,
item.candidateNumber, item.initialPassword, item.userId, item.createdAt, item.id
)
);
}
operations.push(auditOperation(log));
await transaction(operations);
},
async transferWorkflow(instance, action, log) {
const operations = [
operation('UPDATE workflow_instances SET assignee_id = ? WHERE id = ?', optional(instance.assigneeId), instance.id),
workflowActionOperation(action)
];
if (log) operations.push(auditOperation(log));
await transaction(operations);
},
async createWorkflow(instance, action, log = null) {
const operations = workflowCreateOperations(instance, action);
if (log) operations.push(auditOperation(log));
await transaction(operations);
},
async saveWorkflow(workflow, log) {
const operations = [
operation(
`UPDATE workflow_definitions SET name = ?, active = ?, updated_by = ?, updated_at = ? WHERE id = ?`,
workflow.name, workflow.active ? 1 : 0, optional(workflow.updatedBy), workflow.updatedAt, workflow.id
),
operation('DELETE FROM workflow_steps WHERE workflow_id = ?', workflow.id)
];
workflow.steps.forEach((step, index) => operations.push(operation(
`INSERT INTO workflow_steps (id, workflow_id, position, name, admin_level) VALUES (?, ?, ?, ?, ?)`,
step.id, workflow.id, Number(step.position || index + 1), step.name, step.adminLevel
)));
operations.push(auditOperation(log));
await transaction(operations);
},
async saveNumberRule(rule, isNew, log) {
const operations = [operation('UPDATE number_rules SET active = 0 WHERE active = 1')];
if (isNew) {
operations.push(operation(
'INSERT INTO number_rules (id, name, `separator`, active, created_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)',
rule.id, rule.name, rule.separator, rule.active ? 1 : 0, optional(rule.createdBy), rule.updatedAt
));
} else {
operations.push(
operation(
'UPDATE number_rules SET name = ?, `separator` = ?, active = ?, created_by = ?, updated_at = ? WHERE id = ?',
rule.name, rule.separator, rule.active ? 1 : 0, optional(rule.createdBy), rule.updatedAt, rule.id
),
operation('DELETE FROM number_rule_segments WHERE rule_id = ?', rule.id)
);
}
rule.segments.forEach((segment, index) => operations.push(operation(
`INSERT INTO number_rule_segments (id, rule_id, position, type, value, width) VALUES (?, ?, ?, ?, ?, ?)`,
segment.id, rule.id, Number(segment.position || index + 1), segment.type,
optional(segment.value), Number(segment.width || 0)
)));
operations.push(auditOperation(log));
await transaction(operations);
},
async createAdmin(user, log) {
await transaction([
operation(
`INSERT INTO users (
id, username, password_hash, role, admin_level, school_id, class_id, active, display_name, created_at
) VALUES (?, ?, ?, 'admin', ?, ?, ?, ?, ?, ?)`,
user.id, user.username, user.passwordHash, user.adminLevel, optional(user.schoolId), optional(user.classId),
user.active === false ? 0 : 1, user.displayName, user.createdAt
),
auditOperation(log)
]);
},
async createAdmissionSchoolAccount(user, log) {
await transaction([
operation(
`INSERT INTO users (
id, username, password_hash, role, admin_level, school_id, class_id, active, display_name, created_at
) VALUES (?, ?, ?, 'admission_school', NULL, ?, NULL, ?, ?, ?)`,
user.id, user.username, user.passwordHash, user.schoolId, user.active === false ? 0 : 1, user.displayName, user.createdAt
),
auditOperation(log)
]);
},
async updateFeatureScore(registration, log) {
await transaction([
operation('UPDATE registrations SET feature_score = ? WHERE id = ?', Number(registration.featureScore || 0), registration.id),
auditOperation(log)
]);
},
async saveAdmissionRecord(record, log = null) {
const operations = [operation('DELETE FROM admission_records WHERE id = ?', record.id), operation(
`INSERT INTO admission_records (
id, kind, exam_id, user_id, school_id, status, payload_json, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
record.id, record.kind, record.examId, optional(record.userId), optional(record.schoolId), record.status,
JSON.stringify(record.payload || {}), record.createdAt, record.updatedAt
)];
if (log) operations.push(auditOperation(log));
await transaction(operations);
},
async saveAdmissionRecords(records, log = null) {
const operations = [];
for (const record of records) {
operations.push(operation('DELETE FROM admission_records WHERE id = ?', record.id));
operations.push(operation(
`INSERT INTO admission_records (
id, kind, exam_id, user_id, school_id, status, payload_json, created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
record.id, record.kind, record.examId, optional(record.userId), optional(record.schoolId), record.status,
JSON.stringify(record.payload || {}), record.createdAt, record.updatedAt
));
}
if (log) operations.push(auditOperation(log));
await transaction(operations);
},
async saveSchool(school, isNew, log) {
const change = isNew
? operation(
'INSERT INTO schools (id, name, code, address, is_source_school, is_admission_school, active) VALUES (?, ?, ?, ?, ?, ?, ?)',
school.id, school.name, school.code, optional(school.address), school.isSourceSchool ? 1 : 0,
school.isAdmissionSchool ? 1 : 0, school.active ? 1 : 0
)
: operation(
'UPDATE schools SET name = ?, code = ?, address = ?, is_source_school = ?, is_admission_school = ?, active = ? WHERE id = ?',
school.name, school.code, optional(school.address), school.isSourceSchool ? 1 : 0,
school.isAdmissionSchool ? 1 : 0, school.active ? 1 : 0, school.id
);
await transaction([change, auditOperation(log)]);
},
async saveSchoolClass(schoolClass, isNew, log) {
const change = isNew
? operation(
'INSERT INTO school_classes (id, school_id, name, grade, active) VALUES (?, ?, ?, ?, ?)',
schoolClass.id, schoolClass.schoolId, schoolClass.name, schoolClass.grade, schoolClass.active ? 1 : 0
)
: operation(
'UPDATE school_classes SET name = ?, grade = ?, active = ? WHERE id = ?',
schoolClass.name, schoolClass.grade, schoolClass.active ? 1 : 0, schoolClass.id
);
await transaction([change, auditOperation(log)]);
},
async updateAdmin(user, passwordChanged, log) {
const sql = passwordChanged
? 'UPDATE users SET display_name = ?, class_id = ?, active = ?, password_hash = ? WHERE id = ?'
: 'UPDATE users SET display_name = ?, class_id = ?, active = ? WHERE id = ?';
const params = passwordChanged
? [user.displayName, optional(user.classId), user.active ? 1 : 0, user.passwordHash, user.id]
: [user.displayName, optional(user.classId), user.active ? 1 : 0, user.id];
await transaction([operation(sql, ...params), auditOperation(log)]);
},
async saveTestCenter(center, isNew, log) {
const centerOperation = isNew
? operation(
`INSERT INTO test_centers (
id, school_id, code, name, province_code, province_name, city_code, city_name, district_code, district_name,
address, contact, rooms, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
center.id, center.schoolId, center.code, center.name,
center.provinceCode, center.provinceName, center.cityCode, center.cityName, center.districtCode, center.districtName,
center.address, optional(center.contact), center.rooms, center.updatedAt
)
: operation(
`UPDATE test_centers SET
code = ?, name = ?, province_code = ?, province_name = ?, city_code = ?, city_name = ?,
district_code = ?, district_name = ?, address = ?, contact = ?, rooms = ?, updated_at = ? WHERE id = ?`,
center.code, center.name, center.provinceCode, center.provinceName, center.cityCode, center.cityName,
center.districtCode, center.districtName, center.address, optional(center.contact), center.rooms, center.updatedAt, center.id
);
await transaction([centerOperation, auditOperation(log)]);
},
async createCenterChangeRequest(request, rooms, instance, action, log) {
const operations = [
operation(
`INSERT INTO center_change_requests (
id, center_id, school_id, request_type, code, name,
province_code, province_name, city_code, city_name, district_code, district_name,
address, contact, manager_name, manager_phone,
emergency_phone, gate_open_time, transport, center_status, notes, status, review_note,
requested_by, created_at, reviewed_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
request.id, optional(request.centerId), request.schoolId, request.requestType, request.code, request.name,
request.provinceCode, request.provinceName, request.cityCode, request.cityName, request.districtCode, request.districtName,
request.address, optional(request.contact), optional(request.managerName), optional(request.managerPhone),
optional(request.emergencyPhone), optional(request.gateOpenTime), optional(request.transport),
request.centerStatus, optional(request.notes), request.status, optional(request.reviewNote),
optional(request.requestedBy), request.createdAt, optional(request.reviewedAt)
),
...workflowCreateOperations(instance, action)
];
for (const room of rooms) operations.push(operation(
`INSERT INTO center_change_rooms (
id, request_id, room_id, code, name, building, floor, capacity, seat_plan, seat_start, seat_end, room_type, status, notes
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
room.id, request.id, optional(room.roomId), room.code, room.name, room.building, optional(room.floor),
Number(room.capacity), optional(room.seatPlan), 1, Number(room.capacity), room.roomType, room.status,
optional(room.notes)
));
operations.push(auditOperation(log));
await transaction(operations);
},
async applyCenterChange(request, instance, action, center, rooms, log) {
const operations = [
operation(
`UPDATE workflow_instances SET
status = ?, current_step = ?, assignee_id = ?, completed_at = ? WHERE id = ?`,
instance.status, Number(instance.currentStep), optional(instance.assigneeId), optional(instance.completedAt), instance.id
),
workflowActionOperation(action),
operation(
`UPDATE center_change_requests SET status = ?, review_note = ?, reviewed_at = ? WHERE id = ?`,
request.status, optional(request.reviewNote), optional(request.reviewedAt), request.id
)
];
if (request.status === 'approved' && center) {
if (request.requestType === 'create') {
operations.push(operation(
`INSERT INTO test_centers (
id, school_id, code, name, province_code, province_name, city_code, city_name, district_code, district_name,
address, contact, manager_name, manager_phone, emergency_phone,
gate_open_time, transport, status, notes, rooms, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
center.id, center.schoolId, center.code, center.name,
center.provinceCode, center.provinceName, center.cityCode, center.cityName, center.districtCode, center.districtName,
center.address, optional(center.contact),
optional(center.managerName), optional(center.managerPhone), optional(center.emergencyPhone),
optional(center.gateOpenTime), optional(center.transport), center.status, optional(center.notes),
center.rooms || '', center.updatedAt
));
} else {
operations.push(operation(
`UPDATE test_centers SET
code = ?, name = ?, province_code = ?, province_name = ?, city_code = ?, city_name = ?,
district_code = ?, district_name = ?, address = ?, contact = ?, manager_name = ?, manager_phone = ?,
emergency_phone = ?, gate_open_time = ?, transport = ?, status = ?, notes = ?, rooms = ?, updated_at = ?
WHERE id = ?`,
center.code, center.name, center.provinceCode, center.provinceName, center.cityCode, center.cityName,
center.districtCode, center.districtName, center.address, optional(center.contact), optional(center.managerName),
optional(center.managerPhone), optional(center.emergencyPhone), optional(center.gateOpenTime),
optional(center.transport), center.status, optional(center.notes), center.rooms || '', center.updatedAt, center.id
));
}
operations.push(operation('DELETE FROM test_rooms WHERE center_id = ?', center.id));
for (const room of rooms) operations.push(operation(
`INSERT INTO test_rooms (
id, center_id, code, name, building, floor, capacity, seat_plan, seat_start, seat_end, room_type, status, notes
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
room.id, center.id, room.code, room.name, room.building, optional(room.floor), Number(room.capacity),
optional(room.seatPlan), 1, Number(room.capacity), room.roomType, room.status, optional(room.notes)
));
}
operations.push(auditOperation(log));
await transaction(operations);
},
async saveAdmissionArrangement(plan, cards, log) {
const operations = [
operation('DELETE FROM exam_arrangement_plans WHERE exam_id = ?', plan.examId),
operation(
`INSERT INTO exam_arrangement_plans (
id, exam_id, number_rule_id, mixing_scope, random_seed, candidate_count, center_count,
subject_assignment_count, subject_combination_count, same_school_center_rate,
warnings_json, generated_by, generated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
plan.id, plan.examId, plan.numberRuleId, plan.mixingScope, plan.randomSeed,
Number(plan.candidateCount), Number(plan.centerCount), Number(plan.subjectAssignmentCount),
Number(plan.subjectCombinationCount), Number(plan.sameSchoolCenterRate), JSON.stringify(plan.warnings || []),
optional(plan.generatedBy), plan.generatedAt
)
];
for (const card of cards) {
operations.push(operation(
`INSERT INTO admit_cards (
registration_id, plan_id, card_number, center_id, test_center, center_code, center_address, generated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
card.registrationId, plan.id, card.number, optional(card.centerId), card.testCenter,
card.centerCode || '', card.centerAddress || '', card.generatedAt
));
for (const assignment of card.assignments) operations.push(operation(
`INSERT INTO admit_card_subjects (
registration_id, subject_id, room_id, room, room_code, exam_room_code, building, floor, seat, subject_signature
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
card.registrationId, assignment.subjectId, optional(assignment.roomId), assignment.roomName,
assignment.roomCode, assignment.examRoomCode, assignment.building || '', assignment.floor || '', assignment.seat, assignment.subjectSignature
));
}
operations.push(auditOperation(log));
await transaction(operations);
},
async createExam(exam, log) {
const operations = [operation(
`INSERT INTO exams (
id, code, name, description, registration_start, registration_end, exam_start, exam_end,
admit_download_start, admit_download_end, location, pass_policy, pass_value, status, archived_at, archived_by, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
exam.id, exam.code, exam.name, exam.description || '', exam.registrationStart, exam.registrationEnd,
exam.examStart, exam.examEnd, exam.admitDownloadStart, exam.admitDownloadEnd,
exam.location || '', exam.passPolicy, exam.passValue, exam.status, optional(exam.archivedAt), optional(exam.archivedBy), exam.createdAt
)];
exam.subjects.forEach((subject, index) => operations.push(operation(
`INSERT INTO exam_subjects (
id, exam_id, name, subject_date, start_time, end_time, fee, full_score, pass_score, pass_rule, pass_value, position
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
subject.id, exam.id, subject.name, subject.date || String(exam.examStart).slice(0, 10),
subject.start || '', subject.end || '', Number(subject.fee || 0), Number(subject.fullScore),
Number(subject.passScore ?? 0), subject.passRule === 'rank_percent' ? 'score_ratio' : (subject.passRule || 'fixed_score'), Number(subject.passValue ?? subject.passScore ?? 0), Number(subject.order || index + 1)
)));
operations.push(auditOperation(log));
await transaction(operations);
},
async updateExam(exam, log, replaceSubjects = false) {
const operations = [
operation(
`UPDATE exams SET
code = ?, name = ?, description = ?, registration_start = ?, registration_end = ?,
exam_start = ?, exam_end = ?, admit_download_start = ?, admit_download_end = ?,
location = ?, pass_policy = ?, pass_value = ?, status = ? WHERE id = ?`,
exam.code, exam.name, exam.description || '', exam.registrationStart, exam.registrationEnd,
exam.examStart, exam.examEnd, exam.admitDownloadStart, exam.admitDownloadEnd,
exam.location || '', exam.passPolicy, exam.passValue, exam.status, exam.id
)
];
if (replaceSubjects) {
operations.push(operation('DELETE FROM exam_subjects WHERE exam_id = ?', exam.id));
exam.subjects.forEach((subject, index) => operations.push(operation(
`INSERT INTO exam_subjects (
id, exam_id, name, subject_date, start_time, end_time, fee, full_score, pass_score, pass_rule, pass_value, position
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
subject.id, exam.id, subject.name, subject.date || String(exam.examStart).slice(0, 10),
subject.start || '', subject.end || '', Number(subject.fee || 0), Number(subject.fullScore),
Number(subject.passScore ?? 0), subject.passRule === 'rank_percent' ? 'score_ratio' : (subject.passRule || 'fixed_score'), Number(subject.passValue ?? subject.passScore ?? 0), Number(subject.order || index + 1)
)));
}
operations.push(auditOperation(log));
await transaction(operations);
},
async archiveExam(exam, log) {
await transaction([
operation('UPDATE exams SET status = ?, archived_at = ?, archived_by = ? WHERE id = ? AND archived_at IS NULL',
exam.status, exam.archivedAt, exam.archivedBy, exam.id),
auditOperation(log)
]);
},
async createNotice(notice, log) {
await transaction([
operation(
`INSERT INTO notices (
id, title, summary, content, category, pinned, status, publish_at, created_at, author
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
notice.id, notice.title, notice.summary, notice.content, notice.category, notice.pinned ? 1 : 0,
notice.status, optional(notice.publishAt), optional(notice.createdAt), notice.author
),
auditOperation(log)
]);
},
async updateNotice(notice, log) {
await transaction([
operation(
`UPDATE notices SET title = ?, summary = ?, content = ?, category = ?, pinned = ?, status = ?, publish_at = ? WHERE id = ?`,
notice.title, notice.summary, notice.content, notice.category, notice.pinned ? 1 : 0,
notice.status, optional(notice.publishAt), notice.id
),
auditOperation(log)
]);
},
async saveResult(result, isNew, log) {
const resultOperation = isNew
? operation(
`INSERT INTO results (
id, registration_id, subject_id, score, grade, published, updated_at, published_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
result.id, result.registrationId, result.subjectId, result.score, result.grade,
result.published ? 1 : 0, optional(result.updatedAt), optional(result.publishedAt)
)
: operation(
`UPDATE results SET score = ?, grade = ?, published = ?, updated_at = ?, published_at = ? WHERE id = ?`,
result.score, result.grade, result.published ? 1 : 0,
optional(result.updatedAt), optional(result.publishedAt), result.id
);
await transaction([resultOperation, auditOperation(log)]);
},
async saveResults(entries) {
const operations = [];
for (const { result, isNew, log } of entries) {
operations.push(isNew
? operation(
`INSERT INTO results (
id, registration_id, subject_id, score, grade, published, updated_at, published_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
result.id, result.registrationId, result.subjectId, result.score, result.grade,
result.published ? 1 : 0, optional(result.updatedAt), optional(result.publishedAt)
)
: operation(
`UPDATE results SET score = ?, grade = ?, published = ?, updated_at = ?, published_at = ? WHERE id = ?`,
result.score, result.grade, result.published ? 1 : 0,
optional(result.updatedAt), optional(result.publishedAt), result.id
));
operations.push(auditOperation(log));
}
await transaction(operations);
}
};
}
const adapterContext = {
mkdir,
dirname,
sqliteSchema,
mysqlSchema,
optional,
buildSeedOperations,
stateFromRows,
readSqliteRows,
readMysqlRows,
createRepository
};
const createSqliteStore = createSqliteAdapter(adapterContext);
const createMysqlStore = createMysqlAdapter(adapterContext);
export async function createDatabase({ root, seed }) {
const client = String(process.env.DATABASE_CLIENT || (process.env.NODE_ENV === 'production' ? 'mysql' : 'sqlite')).toLowerCase();
if (!['sqlite', 'mysql'].includes(client)) {
throw new Error(`不支持的 DATABASE_CLIENT${client}(可选 sqlite 或 mysql`);
}
if (client === 'mysql') return createMysqlStore({ seed });
const path = resolve(process.env.SQLITE_PATH || join(root, 'data', 'exam.sqlite'));
return createSqliteStore({ path, seed });
}