2100 lines
96 KiB
JavaScript
2100 lines
96 KiB
JavaScript
import { mkdir } from 'node:fs/promises';
|
||
import { dirname, join, resolve } from 'node:path';
|
||
|
||
export const relationalTables = [
|
||
'schema_metadata',
|
||
'organization',
|
||
'schools',
|
||
'school_classes',
|
||
'users',
|
||
'candidate_profiles',
|
||
'notices',
|
||
'exams',
|
||
'exam_subjects',
|
||
'registrations',
|
||
'registration_subjects',
|
||
'admit_cards',
|
||
'results',
|
||
'test_centers',
|
||
'test_rooms',
|
||
'center_change_requests',
|
||
'center_change_rooms',
|
||
'number_rules',
|
||
'number_rule_segments',
|
||
'workflow_definitions',
|
||
'workflow_steps',
|
||
'workflow_instances',
|
||
'workflow_actions',
|
||
'audit_logs'
|
||
];
|
||
|
||
const sqliteSchema = `
|
||
PRAGMA foreign_keys = ON;
|
||
|
||
CREATE TABLE IF NOT EXISTS schema_metadata (
|
||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||
schema_version INTEGER NOT NULL DEFAULT 1,
|
||
app_version INTEGER NOT NULL DEFAULT 1,
|
||
created_at TEXT NOT NULL
|
||
) STRICT;
|
||
|
||
CREATE TABLE IF NOT EXISTS organization (
|
||
id INTEGER PRIMARY KEY CHECK (id = 1),
|
||
name TEXT NOT NULL,
|
||
code TEXT NOT NULL,
|
||
phone TEXT NOT NULL,
|
||
address TEXT NOT NULL
|
||
) STRICT;
|
||
|
||
CREATE TABLE IF NOT EXISTS schools (
|
||
id TEXT PRIMARY KEY,
|
||
name TEXT NOT NULL UNIQUE,
|
||
code TEXT NOT NULL UNIQUE,
|
||
address TEXT,
|
||
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1))
|
||
) STRICT;
|
||
|
||
CREATE TABLE IF NOT EXISTS school_classes (
|
||
id TEXT PRIMARY KEY,
|
||
school_id TEXT NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
|
||
name TEXT NOT NULL,
|
||
grade TEXT NOT NULL,
|
||
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
|
||
UNIQUE (school_id, name)
|
||
) STRICT;
|
||
|
||
CREATE TABLE IF NOT EXISTS users (
|
||
id TEXT PRIMARY KEY,
|
||
username TEXT NOT NULL UNIQUE,
|
||
password_hash TEXT NOT NULL,
|
||
role TEXT NOT NULL CHECK (role IN ('admin', 'candidate')),
|
||
admin_level TEXT CHECK (admin_level IN ('super', 'school', 'class')),
|
||
school_id TEXT REFERENCES schools(id) ON DELETE SET NULL,
|
||
class_id TEXT REFERENCES school_classes(id) ON DELETE SET NULL,
|
||
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
|
||
display_name TEXT NOT NULL,
|
||
created_at TEXT NOT NULL
|
||
) STRICT;
|
||
|
||
CREATE TABLE IF NOT EXISTS candidate_profiles (
|
||
id TEXT PRIMARY KEY,
|
||
user_id TEXT NOT NULL UNIQUE REFERENCES users(id) ON DELETE CASCADE,
|
||
name TEXT NOT NULL,
|
||
gender TEXT,
|
||
id_number TEXT NOT NULL UNIQUE,
|
||
phone TEXT NOT NULL,
|
||
email TEXT,
|
||
school TEXT,
|
||
grade TEXT,
|
||
school_id TEXT REFERENCES schools(id) ON DELETE SET NULL,
|
||
class_id TEXT REFERENCES school_classes(id) ON DELETE SET NULL,
|
||
address TEXT,
|
||
emergency_contact TEXT,
|
||
emergency_phone TEXT,
|
||
status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')),
|
||
review_note TEXT,
|
||
reviewed_at TEXT,
|
||
reviewer_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||
updated_at TEXT NOT NULL
|
||
) STRICT;
|
||
|
||
CREATE TABLE IF NOT EXISTS notices (
|
||
id TEXT PRIMARY KEY,
|
||
title TEXT NOT NULL,
|
||
summary TEXT NOT NULL,
|
||
content TEXT NOT NULL,
|
||
category TEXT NOT NULL,
|
||
pinned INTEGER NOT NULL DEFAULT 0 CHECK (pinned IN (0, 1)),
|
||
status TEXT NOT NULL CHECK (status IN ('draft', 'published')),
|
||
publish_at TEXT,
|
||
created_at TEXT,
|
||
author TEXT NOT NULL
|
||
) STRICT;
|
||
|
||
CREATE TABLE IF NOT EXISTS exams (
|
||
id TEXT PRIMARY KEY,
|
||
code TEXT NOT NULL UNIQUE,
|
||
name TEXT NOT NULL,
|
||
description TEXT NOT NULL,
|
||
registration_start TEXT NOT NULL,
|
||
registration_end TEXT NOT NULL,
|
||
exam_start TEXT NOT NULL,
|
||
exam_end TEXT NOT NULL,
|
||
admit_download_start TEXT NOT NULL,
|
||
admit_download_end TEXT NOT NULL,
|
||
location TEXT NOT NULL,
|
||
status TEXT NOT NULL CHECK (status IN ('draft', 'published', 'closed')),
|
||
created_at TEXT NOT NULL
|
||
) STRICT;
|
||
|
||
CREATE TABLE IF NOT EXISTS exam_subjects (
|
||
id TEXT PRIMARY KEY,
|
||
exam_id TEXT NOT NULL REFERENCES exams(id) ON DELETE CASCADE,
|
||
name TEXT NOT NULL,
|
||
subject_date TEXT NOT NULL,
|
||
start_time TEXT NOT NULL,
|
||
end_time TEXT NOT NULL,
|
||
fee REAL NOT NULL DEFAULT 0,
|
||
position INTEGER NOT NULL,
|
||
UNIQUE (exam_id, position)
|
||
) STRICT;
|
||
|
||
CREATE TABLE IF NOT EXISTS registrations (
|
||
id TEXT PRIMARY KEY,
|
||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||
exam_id TEXT NOT NULL REFERENCES exams(id) ON DELETE CASCADE,
|
||
status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')),
|
||
payment_status TEXT NOT NULL CHECK (payment_status IN ('unpaid', 'paid', 'refunded')),
|
||
created_at TEXT NOT NULL,
|
||
reviewed_at TEXT,
|
||
review_note TEXT,
|
||
registration_number TEXT UNIQUE,
|
||
number_rule_id TEXT,
|
||
UNIQUE (user_id, exam_id)
|
||
) STRICT;
|
||
|
||
CREATE TABLE IF NOT EXISTS registration_subjects (
|
||
registration_id TEXT NOT NULL REFERENCES registrations(id) ON DELETE CASCADE,
|
||
subject_id TEXT NOT NULL REFERENCES exam_subjects(id) ON DELETE CASCADE,
|
||
PRIMARY KEY (registration_id, subject_id)
|
||
) STRICT;
|
||
|
||
CREATE TABLE IF NOT EXISTS admit_cards (
|
||
registration_id TEXT PRIMARY KEY REFERENCES registrations(id) ON DELETE CASCADE,
|
||
card_number TEXT NOT NULL UNIQUE,
|
||
test_center TEXT NOT NULL,
|
||
room TEXT NOT NULL,
|
||
seat TEXT NOT NULL,
|
||
generated_at TEXT NOT NULL
|
||
) STRICT;
|
||
|
||
CREATE TABLE IF NOT EXISTS results (
|
||
id TEXT PRIMARY KEY,
|
||
registration_id TEXT NOT NULL REFERENCES registrations(id) ON DELETE CASCADE,
|
||
subject_id TEXT NOT NULL REFERENCES exam_subjects(id) ON DELETE CASCADE,
|
||
score REAL NOT NULL CHECK (score >= 0 AND score <= 150),
|
||
grade TEXT NOT NULL,
|
||
published INTEGER NOT NULL DEFAULT 0 CHECK (published IN (0, 1)),
|
||
updated_at TEXT,
|
||
published_at TEXT,
|
||
UNIQUE (registration_id, subject_id)
|
||
) STRICT;
|
||
|
||
CREATE TABLE IF NOT EXISTS test_centers (
|
||
id TEXT PRIMARY KEY,
|
||
school_id TEXT NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
|
||
code TEXT NOT NULL UNIQUE,
|
||
name TEXT NOT NULL,
|
||
address TEXT NOT NULL,
|
||
contact TEXT,
|
||
manager_name TEXT,
|
||
manager_phone TEXT,
|
||
emergency_phone TEXT,
|
||
gate_open_time TEXT,
|
||
transport TEXT,
|
||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')),
|
||
notes TEXT,
|
||
rooms TEXT NOT NULL,
|
||
updated_at TEXT NOT NULL,
|
||
UNIQUE (school_id, name)
|
||
) STRICT;
|
||
|
||
CREATE TABLE IF NOT EXISTS test_rooms (
|
||
id TEXT PRIMARY KEY,
|
||
center_id TEXT NOT NULL REFERENCES test_centers(id) ON DELETE CASCADE,
|
||
code TEXT NOT NULL,
|
||
name TEXT NOT NULL,
|
||
building TEXT NOT NULL,
|
||
floor TEXT,
|
||
capacity INTEGER NOT NULL CHECK (capacity > 0),
|
||
seat_start INTEGER NOT NULL DEFAULT 1 CHECK (seat_start > 0),
|
||
seat_end INTEGER NOT NULL CHECK (seat_end >= seat_start),
|
||
room_type TEXT NOT NULL CHECK (room_type IN ('standard', 'computer', 'accessible', 'spare')),
|
||
status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')),
|
||
notes TEXT,
|
||
UNIQUE (center_id, code)
|
||
) STRICT;
|
||
|
||
CREATE TABLE IF NOT EXISTS center_change_requests (
|
||
id TEXT PRIMARY KEY,
|
||
center_id TEXT REFERENCES test_centers(id) ON DELETE SET NULL,
|
||
school_id TEXT NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
|
||
request_type TEXT NOT NULL CHECK (request_type IN ('create', 'update')),
|
||
code TEXT NOT NULL,
|
||
name TEXT NOT NULL,
|
||
address TEXT NOT NULL,
|
||
contact TEXT,
|
||
manager_name TEXT,
|
||
manager_phone TEXT,
|
||
emergency_phone TEXT,
|
||
gate_open_time TEXT,
|
||
transport TEXT,
|
||
center_status TEXT NOT NULL CHECK (center_status IN ('active', 'inactive')),
|
||
notes TEXT,
|
||
status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')),
|
||
review_note TEXT,
|
||
requested_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||
created_at TEXT NOT NULL,
|
||
reviewed_at TEXT
|
||
) STRICT;
|
||
|
||
CREATE TABLE IF NOT EXISTS center_change_rooms (
|
||
id TEXT PRIMARY KEY,
|
||
request_id TEXT NOT NULL REFERENCES center_change_requests(id) ON DELETE CASCADE,
|
||
room_id TEXT,
|
||
code TEXT NOT NULL,
|
||
name TEXT NOT NULL,
|
||
building TEXT NOT NULL,
|
||
floor TEXT,
|
||
capacity INTEGER NOT NULL CHECK (capacity > 0),
|
||
seat_start INTEGER NOT NULL DEFAULT 1,
|
||
seat_end INTEGER NOT NULL,
|
||
room_type TEXT NOT NULL CHECK (room_type IN ('standard', 'computer', 'accessible', 'spare')),
|
||
status TEXT NOT NULL CHECK (status IN ('active', 'inactive')),
|
||
notes TEXT,
|
||
UNIQUE (request_id, code)
|
||
) STRICT;
|
||
|
||
CREATE TABLE IF NOT EXISTS number_rules (
|
||
id TEXT PRIMARY KEY,
|
||
name TEXT NOT NULL,
|
||
separator TEXT NOT NULL DEFAULT '',
|
||
active INTEGER NOT NULL DEFAULT 0 CHECK (active IN (0, 1)),
|
||
created_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||
updated_at TEXT NOT NULL
|
||
) STRICT;
|
||
|
||
CREATE TABLE IF NOT EXISTS number_rule_segments (
|
||
id TEXT PRIMARY KEY,
|
||
rule_id TEXT NOT NULL REFERENCES number_rules(id) ON DELETE CASCADE,
|
||
position INTEGER NOT NULL,
|
||
type TEXT NOT NULL CHECK (type IN ('year', 'school_code', 'gender', 'sequence', 'literal')),
|
||
value TEXT,
|
||
width INTEGER NOT NULL DEFAULT 0,
|
||
UNIQUE (rule_id, position)
|
||
) STRICT;
|
||
|
||
CREATE TABLE IF NOT EXISTS workflow_definitions (
|
||
id TEXT PRIMARY KEY,
|
||
business_type TEXT NOT NULL CHECK (business_type IN ('profile_change', 'registration_review', 'center_change')),
|
||
name TEXT NOT NULL,
|
||
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
|
||
updated_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||
updated_at TEXT NOT NULL,
|
||
UNIQUE (business_type, active)
|
||
) STRICT;
|
||
|
||
CREATE TABLE IF NOT EXISTS workflow_steps (
|
||
id TEXT PRIMARY KEY,
|
||
workflow_id TEXT NOT NULL REFERENCES workflow_definitions(id) ON DELETE CASCADE,
|
||
position INTEGER NOT NULL,
|
||
name TEXT NOT NULL,
|
||
admin_level TEXT NOT NULL CHECK (admin_level IN ('school', 'super')),
|
||
UNIQUE (workflow_id, position)
|
||
) STRICT;
|
||
|
||
CREATE TABLE IF NOT EXISTS workflow_instances (
|
||
id TEXT PRIMARY KEY,
|
||
workflow_id TEXT NOT NULL REFERENCES workflow_definitions(id) ON DELETE RESTRICT,
|
||
business_type TEXT NOT NULL,
|
||
business_id TEXT NOT NULL,
|
||
status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')),
|
||
current_step INTEGER NOT NULL DEFAULT 1,
|
||
assignee_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||
created_at TEXT NOT NULL,
|
||
completed_at TEXT
|
||
) STRICT;
|
||
|
||
CREATE TABLE IF NOT EXISTS workflow_actions (
|
||
id TEXT PRIMARY KEY,
|
||
instance_id TEXT NOT NULL REFERENCES workflow_instances(id) ON DELETE CASCADE,
|
||
actor_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||
action TEXT NOT NULL CHECK (action IN ('submit', 'approve', 'reject', 'transfer', 'return', 'supervise')),
|
||
note TEXT,
|
||
from_assignee_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||
to_assignee_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||
created_at TEXT NOT NULL
|
||
) STRICT;
|
||
|
||
CREATE TABLE IF NOT EXISTS audit_logs (
|
||
id TEXT PRIMARY KEY,
|
||
actor_id TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||
action TEXT NOT NULL,
|
||
detail TEXT NOT NULL,
|
||
created_at TEXT NOT NULL
|
||
) STRICT;
|
||
|
||
CREATE INDEX IF NOT EXISTS idx_profiles_status ON candidate_profiles(status);
|
||
CREATE INDEX IF NOT EXISTS idx_profiles_scope ON candidate_profiles(school_id, class_id, status);
|
||
CREATE INDEX IF NOT EXISTS idx_notices_status_publish ON notices(status, publish_at);
|
||
CREATE INDEX IF NOT EXISTS idx_exams_status_registration ON exams(status, registration_start, registration_end);
|
||
CREATE INDEX IF NOT EXISTS idx_subjects_exam ON exam_subjects(exam_id, position);
|
||
CREATE INDEX IF NOT EXISTS idx_registrations_status ON registrations(status);
|
||
CREATE INDEX IF NOT EXISTS idx_registrations_exam ON registrations(exam_id);
|
||
CREATE INDEX IF NOT EXISTS idx_workflow_inbox ON workflow_instances(status, assignee_id, business_type);
|
||
CREATE UNIQUE INDEX IF NOT EXISTS uq_test_centers_code ON test_centers(code);
|
||
CREATE INDEX IF NOT EXISTS idx_rooms_center ON test_rooms(center_id, status, code);
|
||
CREATE INDEX IF NOT EXISTS idx_center_changes_school ON center_change_requests(school_id, status, created_at);
|
||
CREATE INDEX IF NOT EXISTS idx_results_registration ON results(registration_id, published);
|
||
CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_logs(created_at);
|
||
`;
|
||
|
||
const mysqlSchema = [
|
||
`CREATE TABLE IF NOT EXISTS schema_metadata (
|
||
id TINYINT UNSIGNED NOT NULL,
|
||
schema_version INT UNSIGNED NOT NULL DEFAULT 1,
|
||
app_version INT UNSIGNED NOT NULL DEFAULT 1,
|
||
created_at VARCHAR(35) NOT NULL,
|
||
PRIMARY KEY (id),
|
||
CONSTRAINT chk_schema_metadata_singleton CHECK (id = 1)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||
`CREATE TABLE IF NOT EXISTS organization (
|
||
id TINYINT UNSIGNED NOT NULL,
|
||
name VARCHAR(120) NOT NULL,
|
||
code VARCHAR(60) NOT NULL,
|
||
phone VARCHAR(60) NOT NULL,
|
||
address VARCHAR(255) NOT NULL,
|
||
PRIMARY KEY (id),
|
||
CONSTRAINT chk_organization_singleton CHECK (id = 1)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||
`CREATE TABLE IF NOT EXISTS schools (
|
||
id VARCHAR(64) NOT NULL,
|
||
name VARCHAR(160) NOT NULL,
|
||
code VARCHAR(40) NOT NULL,
|
||
address VARCHAR(255) NULL,
|
||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||
PRIMARY KEY (id),
|
||
UNIQUE KEY uq_schools_name (name),
|
||
UNIQUE KEY uq_schools_code (code)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||
`CREATE TABLE IF NOT EXISTS school_classes (
|
||
id VARCHAR(64) NOT NULL,
|
||
school_id VARCHAR(64) NOT NULL,
|
||
name VARCHAR(100) NOT NULL,
|
||
grade VARCHAR(60) NOT NULL,
|
||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||
PRIMARY KEY (id),
|
||
UNIQUE KEY uq_classes_school_name (school_id, name),
|
||
CONSTRAINT fk_classes_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||
`CREATE TABLE IF NOT EXISTS users (
|
||
id VARCHAR(64) NOT NULL,
|
||
username VARCHAR(100) NOT NULL,
|
||
password_hash VARCHAR(255) NOT NULL,
|
||
role ENUM('admin', 'candidate') NOT NULL,
|
||
admin_level ENUM('super', 'school', 'class') NULL,
|
||
school_id VARCHAR(64) NULL,
|
||
class_id VARCHAR(64) NULL,
|
||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||
display_name VARCHAR(100) NOT NULL,
|
||
created_at VARCHAR(35) NOT NULL,
|
||
PRIMARY KEY (id),
|
||
UNIQUE KEY uq_users_username (username),
|
||
KEY idx_users_admin_scope (role, admin_level, school_id, class_id),
|
||
CONSTRAINT fk_users_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE SET NULL,
|
||
CONSTRAINT fk_users_class FOREIGN KEY (class_id) REFERENCES school_classes(id) ON DELETE SET NULL
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||
`CREATE TABLE IF NOT EXISTS candidate_profiles (
|
||
id VARCHAR(64) NOT NULL,
|
||
user_id VARCHAR(64) NOT NULL,
|
||
name VARCHAR(100) NOT NULL,
|
||
gender VARCHAR(20) NULL,
|
||
id_number VARCHAR(60) NOT NULL,
|
||
phone VARCHAR(60) NOT NULL,
|
||
email VARCHAR(160) NULL,
|
||
school VARCHAR(160) NULL,
|
||
grade VARCHAR(100) NULL,
|
||
school_id VARCHAR(64) NULL,
|
||
class_id VARCHAR(64) NULL,
|
||
address VARCHAR(255) NULL,
|
||
emergency_contact VARCHAR(100) NULL,
|
||
emergency_phone VARCHAR(60) NULL,
|
||
status ENUM('pending', 'approved', 'rejected') NOT NULL,
|
||
review_note VARCHAR(500) NULL,
|
||
reviewed_at VARCHAR(35) NULL,
|
||
reviewer_id VARCHAR(64) NULL,
|
||
updated_at VARCHAR(35) NOT NULL,
|
||
PRIMARY KEY (id),
|
||
UNIQUE KEY uq_profiles_user (user_id),
|
||
UNIQUE KEY uq_profiles_id_number (id_number),
|
||
KEY idx_profiles_status (status),
|
||
CONSTRAINT fk_profiles_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||
CONSTRAINT fk_profiles_reviewer FOREIGN KEY (reviewer_id) REFERENCES users(id) ON DELETE SET NULL,
|
||
CONSTRAINT fk_profiles_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE SET NULL,
|
||
CONSTRAINT fk_profiles_class FOREIGN KEY (class_id) REFERENCES school_classes(id) ON DELETE SET NULL
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||
`CREATE TABLE IF NOT EXISTS notices (
|
||
id VARCHAR(64) NOT NULL,
|
||
title VARCHAR(240) NOT NULL,
|
||
summary VARCHAR(500) NOT NULL,
|
||
content TEXT NOT NULL,
|
||
category VARCHAR(60) NOT NULL,
|
||
pinned BOOLEAN NOT NULL DEFAULT FALSE,
|
||
status ENUM('draft', 'published') NOT NULL,
|
||
publish_at VARCHAR(35) NULL,
|
||
created_at VARCHAR(35) NULL,
|
||
author VARCHAR(100) NOT NULL,
|
||
PRIMARY KEY (id),
|
||
KEY idx_notices_status_publish (status, publish_at)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||
`CREATE TABLE IF NOT EXISTS exams (
|
||
id VARCHAR(64) NOT NULL,
|
||
code VARCHAR(60) NOT NULL,
|
||
name VARCHAR(200) NOT NULL,
|
||
description TEXT NOT NULL,
|
||
registration_start VARCHAR(35) NOT NULL,
|
||
registration_end VARCHAR(35) NOT NULL,
|
||
exam_start VARCHAR(35) NOT NULL,
|
||
exam_end VARCHAR(35) NOT NULL,
|
||
admit_download_start VARCHAR(35) NOT NULL,
|
||
admit_download_end VARCHAR(35) NOT NULL,
|
||
location VARCHAR(200) NOT NULL,
|
||
status ENUM('draft', 'published', 'closed') NOT NULL,
|
||
created_at VARCHAR(35) NOT NULL,
|
||
PRIMARY KEY (id),
|
||
UNIQUE KEY uq_exams_code (code),
|
||
KEY idx_exams_status_registration (status, registration_start, registration_end)
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||
`CREATE TABLE IF NOT EXISTS exam_subjects (
|
||
id VARCHAR(64) NOT NULL,
|
||
exam_id VARCHAR(64) NOT NULL,
|
||
name VARCHAR(100) NOT NULL,
|
||
subject_date VARCHAR(35) NOT NULL,
|
||
start_time VARCHAR(20) NOT NULL,
|
||
end_time VARCHAR(20) NOT NULL,
|
||
fee DOUBLE NOT NULL DEFAULT 0,
|
||
position INT UNSIGNED NOT NULL,
|
||
PRIMARY KEY (id),
|
||
UNIQUE KEY uq_subjects_exam_position (exam_id, position),
|
||
KEY idx_subjects_exam (exam_id, position),
|
||
CONSTRAINT fk_subjects_exam FOREIGN KEY (exam_id) REFERENCES exams(id) ON DELETE CASCADE
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||
`CREATE TABLE IF NOT EXISTS registrations (
|
||
id VARCHAR(64) NOT NULL,
|
||
user_id VARCHAR(64) NOT NULL,
|
||
exam_id VARCHAR(64) NOT NULL,
|
||
status ENUM('pending', 'approved', 'rejected') NOT NULL,
|
||
payment_status ENUM('unpaid', 'paid', 'refunded') NOT NULL,
|
||
created_at VARCHAR(35) NOT NULL,
|
||
reviewed_at VARCHAR(35) NULL,
|
||
review_note VARCHAR(500) NULL,
|
||
registration_number VARCHAR(120) NULL,
|
||
number_rule_id VARCHAR(64) NULL,
|
||
PRIMARY KEY (id),
|
||
UNIQUE KEY uq_registrations_user_exam (user_id, exam_id),
|
||
UNIQUE KEY uq_registrations_number (registration_number),
|
||
KEY idx_registrations_status (status),
|
||
KEY idx_registrations_exam (exam_id),
|
||
CONSTRAINT fk_registrations_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE,
|
||
CONSTRAINT fk_registrations_exam FOREIGN KEY (exam_id) REFERENCES exams(id) ON DELETE CASCADE
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||
`CREATE TABLE IF NOT EXISTS registration_subjects (
|
||
registration_id VARCHAR(64) NOT NULL,
|
||
subject_id VARCHAR(64) NOT NULL,
|
||
PRIMARY KEY (registration_id, subject_id),
|
||
CONSTRAINT fk_registration_subjects_registration FOREIGN KEY (registration_id) REFERENCES registrations(id) ON DELETE CASCADE,
|
||
CONSTRAINT fk_registration_subjects_subject FOREIGN KEY (subject_id) REFERENCES exam_subjects(id) ON DELETE CASCADE
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||
`CREATE TABLE IF NOT EXISTS admit_cards (
|
||
registration_id VARCHAR(64) NOT NULL,
|
||
card_number VARCHAR(100) NOT NULL,
|
||
test_center VARCHAR(200) NOT NULL,
|
||
room VARCHAR(100) NOT NULL,
|
||
seat VARCHAR(30) NOT NULL,
|
||
generated_at VARCHAR(35) NOT NULL,
|
||
PRIMARY KEY (registration_id),
|
||
UNIQUE KEY uq_admit_cards_number (card_number),
|
||
CONSTRAINT fk_admit_cards_registration FOREIGN KEY (registration_id) REFERENCES registrations(id) ON DELETE CASCADE
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||
`CREATE TABLE IF NOT EXISTS results (
|
||
id VARCHAR(64) NOT NULL,
|
||
registration_id VARCHAR(64) NOT NULL,
|
||
subject_id VARCHAR(64) NOT NULL,
|
||
score DOUBLE NOT NULL,
|
||
grade VARCHAR(20) NOT NULL,
|
||
published BOOLEAN NOT NULL DEFAULT FALSE,
|
||
updated_at VARCHAR(35) NULL,
|
||
published_at VARCHAR(35) NULL,
|
||
PRIMARY KEY (id),
|
||
UNIQUE KEY uq_results_registration_subject (registration_id, subject_id),
|
||
KEY idx_results_registration (registration_id, published),
|
||
CONSTRAINT chk_results_score CHECK (score >= 0 AND score <= 150),
|
||
CONSTRAINT fk_results_registration FOREIGN KEY (registration_id) REFERENCES registrations(id) ON DELETE CASCADE,
|
||
CONSTRAINT fk_results_subject FOREIGN KEY (subject_id) REFERENCES exam_subjects(id) ON DELETE CASCADE
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||
`CREATE TABLE IF NOT EXISTS test_centers (
|
||
id VARCHAR(64) NOT NULL,
|
||
school_id VARCHAR(64) NOT NULL,
|
||
code VARCHAR(40) NOT NULL,
|
||
name VARCHAR(200) NOT NULL,
|
||
address VARCHAR(255) NOT NULL,
|
||
contact VARCHAR(100) NULL,
|
||
manager_name VARCHAR(100) NULL,
|
||
manager_phone VARCHAR(60) NULL,
|
||
emergency_phone VARCHAR(60) NULL,
|
||
gate_open_time VARCHAR(40) NULL,
|
||
transport VARCHAR(500) NULL,
|
||
status ENUM('active', 'inactive') NOT NULL DEFAULT 'active',
|
||
notes VARCHAR(1000) NULL,
|
||
rooms TEXT NOT NULL,
|
||
updated_at VARCHAR(35) NOT NULL,
|
||
PRIMARY KEY (id),
|
||
UNIQUE KEY uq_centers_code (code),
|
||
UNIQUE KEY uq_centers_school_name (school_id, name),
|
||
CONSTRAINT fk_centers_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||
`CREATE TABLE IF NOT EXISTS test_rooms (
|
||
id VARCHAR(64) NOT NULL,
|
||
center_id VARCHAR(64) NOT NULL,
|
||
code VARCHAR(40) NOT NULL,
|
||
name VARCHAR(120) NOT NULL,
|
||
building VARCHAR(120) NOT NULL,
|
||
floor VARCHAR(40) NULL,
|
||
capacity INT UNSIGNED NOT NULL,
|
||
seat_start INT UNSIGNED NOT NULL DEFAULT 1,
|
||
seat_end INT UNSIGNED NOT NULL,
|
||
room_type ENUM('standard', 'computer', 'accessible', 'spare') NOT NULL,
|
||
status ENUM('active', 'inactive') NOT NULL DEFAULT 'active',
|
||
notes VARCHAR(500) NULL,
|
||
PRIMARY KEY (id),
|
||
UNIQUE KEY uq_rooms_center_code (center_id, code),
|
||
KEY idx_rooms_center (center_id, status, code),
|
||
CONSTRAINT fk_rooms_center FOREIGN KEY (center_id) REFERENCES test_centers(id) ON DELETE CASCADE
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||
`CREATE TABLE IF NOT EXISTS center_change_requests (
|
||
id VARCHAR(64) NOT NULL,
|
||
center_id VARCHAR(64) NULL,
|
||
school_id VARCHAR(64) NOT NULL,
|
||
request_type ENUM('create', 'update') NOT NULL,
|
||
code VARCHAR(40) NOT NULL,
|
||
name VARCHAR(200) NOT NULL,
|
||
address VARCHAR(255) NOT NULL,
|
||
contact VARCHAR(100) NULL,
|
||
manager_name VARCHAR(100) NULL,
|
||
manager_phone VARCHAR(60) NULL,
|
||
emergency_phone VARCHAR(60) NULL,
|
||
gate_open_time VARCHAR(40) NULL,
|
||
transport VARCHAR(500) NULL,
|
||
center_status ENUM('active', 'inactive') NOT NULL,
|
||
notes VARCHAR(1000) NULL,
|
||
status ENUM('pending', 'approved', 'rejected') NOT NULL,
|
||
review_note VARCHAR(500) NULL,
|
||
requested_by VARCHAR(64) NULL,
|
||
created_at VARCHAR(35) NOT NULL,
|
||
reviewed_at VARCHAR(35) NULL,
|
||
PRIMARY KEY (id),
|
||
KEY idx_center_changes_school (school_id, status, created_at),
|
||
CONSTRAINT fk_center_change_center FOREIGN KEY (center_id) REFERENCES test_centers(id) ON DELETE SET NULL,
|
||
CONSTRAINT fk_center_change_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE,
|
||
CONSTRAINT fk_center_change_requester FOREIGN KEY (requested_by) REFERENCES users(id) ON DELETE SET NULL
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||
`CREATE TABLE IF NOT EXISTS center_change_rooms (
|
||
id VARCHAR(64) NOT NULL,
|
||
request_id VARCHAR(64) NOT NULL,
|
||
room_id VARCHAR(64) NULL,
|
||
code VARCHAR(40) NOT NULL,
|
||
name VARCHAR(120) NOT NULL,
|
||
building VARCHAR(120) NOT NULL,
|
||
floor VARCHAR(40) NULL,
|
||
capacity INT UNSIGNED NOT NULL,
|
||
seat_start INT UNSIGNED NOT NULL DEFAULT 1,
|
||
seat_end INT UNSIGNED NOT NULL,
|
||
room_type ENUM('standard', 'computer', 'accessible', 'spare') NOT NULL,
|
||
status ENUM('active', 'inactive') NOT NULL,
|
||
notes VARCHAR(500) NULL,
|
||
PRIMARY KEY (id),
|
||
UNIQUE KEY uq_center_change_rooms_code (request_id, code),
|
||
CONSTRAINT fk_center_change_rooms_request FOREIGN KEY (request_id) REFERENCES center_change_requests(id) ON DELETE CASCADE
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||
`CREATE TABLE IF NOT EXISTS number_rules (
|
||
id VARCHAR(64) NOT NULL,
|
||
name VARCHAR(120) NOT NULL,
|
||
separator VARCHAR(10) NOT NULL DEFAULT '',
|
||
active BOOLEAN NOT NULL DEFAULT FALSE,
|
||
created_by VARCHAR(64) NULL,
|
||
updated_at VARCHAR(35) NOT NULL,
|
||
PRIMARY KEY (id),
|
||
CONSTRAINT fk_number_rules_creator FOREIGN KEY (created_by) REFERENCES users(id) ON DELETE SET NULL
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||
`CREATE TABLE IF NOT EXISTS number_rule_segments (
|
||
id VARCHAR(64) NOT NULL,
|
||
rule_id VARCHAR(64) NOT NULL,
|
||
position INT UNSIGNED NOT NULL,
|
||
type ENUM('year', 'school_code', 'gender', 'sequence', 'literal') NOT NULL,
|
||
value VARCHAR(60) NULL,
|
||
width INT UNSIGNED NOT NULL DEFAULT 0,
|
||
PRIMARY KEY (id),
|
||
UNIQUE KEY uq_rule_segments_position (rule_id, position),
|
||
CONSTRAINT fk_rule_segments_rule FOREIGN KEY (rule_id) REFERENCES number_rules(id) ON DELETE CASCADE
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||
`CREATE TABLE IF NOT EXISTS workflow_definitions (
|
||
id VARCHAR(64) NOT NULL,
|
||
business_type ENUM('profile_change', 'registration_review', 'center_change') NOT NULL,
|
||
name VARCHAR(120) NOT NULL,
|
||
active BOOLEAN NOT NULL DEFAULT TRUE,
|
||
updated_by VARCHAR(64) NULL,
|
||
updated_at VARCHAR(35) NOT NULL,
|
||
PRIMARY KEY (id),
|
||
UNIQUE KEY uq_workflow_type_active (business_type, active),
|
||
CONSTRAINT fk_workflow_updater FOREIGN KEY (updated_by) REFERENCES users(id) ON DELETE SET NULL
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||
`CREATE TABLE IF NOT EXISTS workflow_steps (
|
||
id VARCHAR(64) NOT NULL,
|
||
workflow_id VARCHAR(64) NOT NULL,
|
||
position INT UNSIGNED NOT NULL,
|
||
name VARCHAR(120) NOT NULL,
|
||
admin_level ENUM('school', 'super') NOT NULL,
|
||
PRIMARY KEY (id),
|
||
UNIQUE KEY uq_workflow_steps_position (workflow_id, position),
|
||
CONSTRAINT fk_workflow_steps_definition FOREIGN KEY (workflow_id) REFERENCES workflow_definitions(id) ON DELETE CASCADE
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||
`CREATE TABLE IF NOT EXISTS workflow_instances (
|
||
id VARCHAR(64) NOT NULL,
|
||
workflow_id VARCHAR(64) NOT NULL,
|
||
business_type VARCHAR(40) NOT NULL,
|
||
business_id VARCHAR(64) NOT NULL,
|
||
status ENUM('pending', 'approved', 'rejected') NOT NULL,
|
||
current_step INT UNSIGNED NOT NULL DEFAULT 1,
|
||
assignee_id VARCHAR(64) NULL,
|
||
created_at VARCHAR(35) NOT NULL,
|
||
completed_at VARCHAR(35) NULL,
|
||
PRIMARY KEY (id),
|
||
KEY idx_workflow_inbox (status, assignee_id, business_type),
|
||
CONSTRAINT fk_workflow_instance_definition FOREIGN KEY (workflow_id) REFERENCES workflow_definitions(id),
|
||
CONSTRAINT fk_workflow_instance_assignee FOREIGN KEY (assignee_id) REFERENCES users(id) ON DELETE SET NULL
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||
`CREATE TABLE IF NOT EXISTS workflow_actions (
|
||
id VARCHAR(64) NOT NULL,
|
||
instance_id VARCHAR(64) NOT NULL,
|
||
actor_id VARCHAR(64) NULL,
|
||
action ENUM('submit', 'approve', 'reject', 'transfer', 'return', 'supervise') NOT NULL,
|
||
note VARCHAR(500) NULL,
|
||
from_assignee_id VARCHAR(64) NULL,
|
||
to_assignee_id VARCHAR(64) NULL,
|
||
created_at VARCHAR(35) NOT NULL,
|
||
PRIMARY KEY (id),
|
||
KEY idx_workflow_actions_instance (instance_id, created_at),
|
||
CONSTRAINT fk_workflow_action_instance FOREIGN KEY (instance_id) REFERENCES workflow_instances(id) ON DELETE CASCADE,
|
||
CONSTRAINT fk_workflow_action_actor FOREIGN KEY (actor_id) REFERENCES users(id) ON DELETE SET NULL,
|
||
CONSTRAINT fk_workflow_action_from FOREIGN KEY (from_assignee_id) REFERENCES users(id) ON DELETE SET NULL,
|
||
CONSTRAINT fk_workflow_action_to FOREIGN KEY (to_assignee_id) REFERENCES users(id) ON DELETE SET NULL
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`,
|
||
`CREATE TABLE IF NOT EXISTS audit_logs (
|
||
id VARCHAR(64) NOT NULL,
|
||
actor_id VARCHAR(64) NULL,
|
||
action VARCHAR(100) NOT NULL,
|
||
detail VARCHAR(1000) NOT NULL,
|
||
created_at VARCHAR(35) NOT NULL,
|
||
PRIMARY KEY (id),
|
||
KEY idx_audit_created (created_at),
|
||
CONSTRAINT fk_audit_actor FOREIGN KEY (actor_id) REFERENCES users(id) ON DELETE SET NULL
|
||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci`
|
||
];
|
||
|
||
function validateState(state, source = '数据库') {
|
||
const collections = [
|
||
'schools', 'classes', 'users', 'candidateProfiles', 'notices', 'exams', 'registrations', 'results',
|
||
'testCenters', 'testRooms', 'centerChangeRequests', 'centerChangeRooms',
|
||
'numberRules', 'workflows', 'workflowInstances', 'workflowActions', 'auditLogs'
|
||
];
|
||
if (!state || typeof state !== 'object' || collections.some(name => !Array.isArray(state[name]))) {
|
||
throw new Error(`${source}中的应用数据格式无效`);
|
||
}
|
||
return state;
|
||
}
|
||
|
||
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 = 3, app_version = ?, created_at = ? WHERE id = 1',
|
||
Number(state.meta?.version || 1), 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, active) VALUES (?, ?, ?, ?, ?)',
|
||
school.id, school.name, school.code, nullable(school.address), 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, password_hash, role, admin_level, school_id, class_id, active, display_name, created_at
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||
user.id, user.username, user.passwordHash, user.role, nullable(user.adminLevel), nullable(user.schoolId),
|
||
nullable(user.classId), user.active === false ? 0 : 1, 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, address,
|
||
emergency_contact, emergency_phone, 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.address),
|
||
nullable(profile.emergencyContact), nullable(profile.emergencyPhone), 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, status, 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.status, exam.createdAt
|
||
);
|
||
exam.subjects.forEach((subject, index) => add(
|
||
`INSERT INTO exam_subjects (
|
||
id, exam_id, name, subject_date, start_time, end_time, fee, 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.order || index + 1)
|
||
));
|
||
}
|
||
|
||
for (const registration of state.registrations) {
|
||
add(
|
||
`INSERT INTO registrations (
|
||
id, user_id, exam_id, status, payment_status, created_at, reviewed_at, review_note,
|
||
registration_number, number_rule_id
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||
registration.id, registration.userId, registration.examId, registration.status,
|
||
registration.paymentStatus, registration.createdAt, nullable(registration.reviewedAt), nullable(registration.reviewNote),
|
||
nullable(registration.registrationNumber), nullable(registration.numberRuleId)
|
||
);
|
||
for (const subjectId of registration.subjectIds) {
|
||
add('INSERT INTO registration_subjects (registration_id, subject_id) VALUES (?, ?)', registration.id, subjectId);
|
||
}
|
||
if (registration.admitCard) {
|
||
add(
|
||
`INSERT INTO admit_cards (
|
||
registration_id, card_number, test_center, room, seat, generated_at
|
||
) VALUES (?, ?, ?, ?, ?, ?)`,
|
||
registration.id, registration.admitCard.number, registration.admitCard.testCenter,
|
||
registration.admitCard.room, registration.admitCard.seat, registration.admitCard.generatedAt
|
||
);
|
||
}
|
||
}
|
||
|
||
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, 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.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_start, seat_end, room_type, status, notes
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||
room.id, room.centerId, room.code, room.name, room.building, nullable(room.floor), Number(room.capacity),
|
||
Number(room.seatStart || 1), Number(room.seatEnd), room.roomType, room.status || 'active', nullable(room.notes)
|
||
);
|
||
}
|
||
|
||
for (const request of state.centerChangeRequests) {
|
||
add(
|
||
`INSERT INTO center_change_requests (
|
||
id, center_id, school_id, request_type, code, 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.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_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), Number(room.seatStart || 1), Number(room.seatEnd), 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 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 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 subjectsByExam = new Map();
|
||
for (const row of rows.subjects) {
|
||
const subject = {
|
||
id: row.id,
|
||
name: row.name,
|
||
date: row.subject_date,
|
||
start: row.start_time,
|
||
end: row.end_time,
|
||
fee: Number(row.fee),
|
||
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 admitCards = new Map(rows.admitCards.map(row => [row.registration_id, {
|
||
number: row.card_number,
|
||
testCenter: row.test_center,
|
||
room: row.room,
|
||
seat: row.seat,
|
||
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 },
|
||
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 || '',
|
||
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,
|
||
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),
|
||
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,
|
||
address: row.address || '',
|
||
emergencyContact: row.emergency_contact || '',
|
||
emergencyPhone: row.emergency_phone || '',
|
||
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,
|
||
status: row.status,
|
||
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,
|
||
createdAt: row.created_at,
|
||
reviewedAt: row.reviewed_at,
|
||
reviewNote: row.review_note || '',
|
||
registrationNumber: row.registration_number || '',
|
||
numberRuleId: row.number_rule_id || null,
|
||
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,
|
||
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),
|
||
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,
|
||
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),
|
||
seatStart: Number(row.seat_start),
|
||
seatEnd: Number(row.seat_end),
|
||
roomType: row.room_type,
|
||
status: row.status,
|
||
notes: row.notes || ''
|
||
})),
|
||
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) || []
|
||
})),
|
||
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
|
||
})),
|
||
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(),
|
||
admitCards: connection.prepare('SELECT * FROM admit_cards ORDER BY registration_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(),
|
||
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(),
|
||
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'),
|
||
admitCards: await query('SELECT * FROM admit_cards ORDER BY registration_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'),
|
||
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'),
|
||
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) {
|
||
const operations = [
|
||
operation(
|
||
`INSERT INTO users (
|
||
id, username, password_hash, role, admin_level, school_id, class_id, active, display_name, created_at
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||
user.id, user.username, user.passwordHash, user.role, optional(user.adminLevel), optional(user.schoolId),
|
||
optional(user.classId), user.active === false ? 0 : 1, user.displayName, user.createdAt
|
||
),
|
||
operation(
|
||
`INSERT INTO candidate_profiles (
|
||
id, user_id, name, gender, id_number, phone, email, school, grade, school_id, class_id, address,
|
||
emergency_contact, emergency_phone, 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.address),
|
||
optional(profile.emergencyContact), optional(profile.emergencyPhone), profile.status, optional(profile.reviewNote),
|
||
optional(profile.reviewedAt), optional(profile.reviewerId), profile.updatedAt
|
||
)
|
||
];
|
||
if (instance && action) operations.push(...workflowCreateOperations(instance, action));
|
||
await transaction(operations);
|
||
},
|
||
async updateCandidateProfile(profile, displayName, instance, action) {
|
||
const operations = [
|
||
operation(
|
||
`UPDATE candidate_profiles SET
|
||
name = ?, gender = ?, id_number = ?, phone = ?, email = ?, school = ?, grade = ?, address = ?,
|
||
school_id = ?, class_id = ?, emergency_contact = ?, emergency_phone = ?, status = ?, review_note = ?,
|
||
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.address), optional(profile.schoolId),
|
||
optional(profile.classId), optional(profile.emergencyContact), optional(profile.emergencyPhone), profile.status,
|
||
optional(profile.reviewNote), 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 createRegistration(registration, instance, action) {
|
||
const operations = [operation(
|
||
`INSERT INTO registrations (
|
||
id, user_id, exam_id, status, payment_status, created_at, reviewed_at, review_note,
|
||
registration_number, number_rule_id
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||
registration.id, registration.userId, registration.examId, registration.status,
|
||
registration.paymentStatus, registration.createdAt, optional(registration.reviewedAt), optional(registration.reviewNote),
|
||
optional(registration.registrationNumber), optional(registration.numberRuleId)
|
||
)];
|
||
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 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 = ?, reviewed_at = ?, review_note = ?,
|
||
registration_number = ?, number_rule_id = ? WHERE id = ?`,
|
||
business.status, business.paymentStatus, optional(business.reviewedAt), optional(business.reviewNote),
|
||
optional(business.registrationNumber), optional(business.numberRuleId), business.id
|
||
));
|
||
} else {
|
||
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
|
||
));
|
||
}
|
||
if (log) 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 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 saveTestCenter(center, isNew, log) {
|
||
const centerOperation = isNew
|
||
? operation(
|
||
`INSERT INTO test_centers (
|
||
id, school_id, name, address, contact, rooms, updated_at
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||
center.id, center.schoolId, center.name, center.address, optional(center.contact), center.rooms, center.updatedAt
|
||
)
|
||
: operation(
|
||
`UPDATE test_centers SET
|
||
name = ?, address = ?, contact = ?, rooms = ?, updated_at = ? WHERE id = ?`,
|
||
center.name, 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, 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.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_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), Number(room.seatStart), Number(room.seatEnd), 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, 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.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 = ?, address = ?, contact = ?, manager_name = ?, manager_phone = ?,
|
||
emergency_phone = ?, gate_open_time = ?, transport = ?, status = ?, notes = ?, rooms = ?, updated_at = ?
|
||
WHERE id = ?`,
|
||
center.code, center.name, 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_start, seat_end, room_type, status, notes
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||
room.id, center.id, room.code, room.name, room.building, optional(room.floor), Number(room.capacity),
|
||
Number(room.seatStart), Number(room.seatEnd), room.roomType, room.status, optional(room.notes)
|
||
));
|
||
}
|
||
operations.push(auditOperation(log));
|
||
await transaction(operations);
|
||
},
|
||
async createAdmitCard(registrationId, admitCard, log) {
|
||
await transaction([
|
||
operation(
|
||
`INSERT INTO admit_cards (
|
||
registration_id, card_number, test_center, room, seat, generated_at
|
||
) VALUES (?, ?, ?, ?, ?, ?)`,
|
||
registrationId, admitCard.number, admitCard.testCenter, admitCard.room, admitCard.seat, admitCard.generatedAt
|
||
),
|
||
auditOperation(log)
|
||
]);
|
||
},
|
||
async assignRegistrationNumber(registration, log) {
|
||
await transaction([
|
||
operation(
|
||
'UPDATE registrations SET registration_number = ?, number_rule_id = ? WHERE id = ?',
|
||
registration.registrationNumber, optional(registration.numberRuleId), registration.id
|
||
),
|
||
auditOperation(log)
|
||
]);
|
||
},
|
||
async assignRegistrationNumbers(registrations, log) {
|
||
const operations = registrations.map(registration => operation(
|
||
`UPDATE registrations SET registration_number = ?, number_rule_id = ?
|
||
WHERE id = ? AND (registration_number IS NULL OR registration_number = '')`,
|
||
registration.registrationNumber, optional(registration.numberRuleId), registration.id
|
||
));
|
||
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, status, 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.status, 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, 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.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 = ?, status = ? WHERE id = ?`,
|
||
exam.code, exam.name, exam.description || '', exam.registrationStart, exam.registrationEnd,
|
||
exam.examStart, exam.examEnd, exam.admitDownloadStart, exam.admitDownloadEnd,
|
||
exam.location || '', 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, 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.order || index + 1)
|
||
)));
|
||
}
|
||
operations.push(auditOperation(log));
|
||
await transaction(operations);
|
||
},
|
||
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 function createSqliteStore({ path, seed }) {
|
||
const { DatabaseSync } = await import('node:sqlite');
|
||
await mkdir(dirname(path), { recursive: true });
|
||
|
||
const connection = new DatabaseSync(path, { timeout: 5000 });
|
||
connection.exec('PRAGMA journal_mode = WAL;');
|
||
connection.exec('PRAGMA synchronous = NORMAL;');
|
||
const tableExists = name => Boolean(connection.prepare("SELECT name FROM sqlite_master WHERE type = 'table' AND name = ?").get(name));
|
||
const ensureColumns = (table, columns) => {
|
||
if (!tableExists(table)) return;
|
||
const existing = new Set(connection.prepare(`PRAGMA table_info(${table})`).all().map(item => item.name));
|
||
for (const [name, definition] of columns) {
|
||
if (!existing.has(name)) connection.exec(`ALTER TABLE ${table} ADD COLUMN ${name} ${definition}`);
|
||
}
|
||
};
|
||
ensureColumns('users', [
|
||
['admin_level', 'TEXT'], ['school_id', 'TEXT'], ['class_id', 'TEXT'], ['active', 'INTEGER NOT NULL DEFAULT 1']
|
||
]);
|
||
ensureColumns('candidate_profiles', [['school_id', 'TEXT'], ['class_id', 'TEXT']]);
|
||
ensureColumns('registrations', [['registration_number', 'TEXT'], ['number_rule_id', 'TEXT']]);
|
||
ensureColumns('test_centers', [
|
||
['code', 'TEXT'], ['manager_name', 'TEXT'], ['manager_phone', 'TEXT'], ['emergency_phone', 'TEXT'],
|
||
['gate_open_time', 'TEXT'], ['transport', 'TEXT'], ['status', "TEXT NOT NULL DEFAULT 'active'"], ['notes', 'TEXT']
|
||
]);
|
||
if (tableExists('workflow_definitions')) {
|
||
const definitionSql = connection.prepare("SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'workflow_definitions'").get()?.sql || '';
|
||
if (!definitionSql.includes('center_change')) {
|
||
connection.exec(`
|
||
PRAGMA foreign_keys = OFF;
|
||
BEGIN IMMEDIATE;
|
||
CREATE TABLE workflow_definitions_v3 (
|
||
id TEXT PRIMARY KEY,
|
||
business_type TEXT NOT NULL CHECK (business_type IN ('profile_change', 'registration_review', 'center_change')),
|
||
name TEXT NOT NULL,
|
||
active INTEGER NOT NULL DEFAULT 1 CHECK (active IN (0, 1)),
|
||
updated_by TEXT REFERENCES users(id) ON DELETE SET NULL,
|
||
updated_at TEXT NOT NULL,
|
||
UNIQUE (business_type, active)
|
||
) STRICT;
|
||
INSERT INTO workflow_definitions_v3 (id, business_type, name, active, updated_by, updated_at)
|
||
SELECT id, business_type, name, active, updated_by, updated_at FROM workflow_definitions;
|
||
DROP TABLE workflow_definitions;
|
||
ALTER TABLE workflow_definitions_v3 RENAME TO workflow_definitions;
|
||
COMMIT;
|
||
PRAGMA foreign_keys = ON;
|
||
`);
|
||
}
|
||
}
|
||
connection.exec(sqliteSchema);
|
||
|
||
const existingSystem = connection.prepare('SELECT * FROM schema_metadata WHERE id = 1').get();
|
||
if (existingSystem && Number(existingSystem.app_version || 1) < 2) {
|
||
const extension = seed();
|
||
connection.exec('BEGIN IMMEDIATE');
|
||
try {
|
||
for (const school of extension.schools) connection.prepare(
|
||
'INSERT OR IGNORE INTO schools (id, name, code, address, active) VALUES (?, ?, ?, ?, ?)'
|
||
).run(school.id, school.name, school.code, optional(school.address), school.active === false ? 0 : 1);
|
||
for (const schoolClass of extension.classes) connection.prepare(
|
||
'INSERT OR IGNORE INTO school_classes (id, school_id, name, grade, active) VALUES (?, ?, ?, ?, ?)'
|
||
).run(schoolClass.id, schoolClass.schoolId, schoolClass.name, schoolClass.grade, schoolClass.active === false ? 0 : 1);
|
||
connection.prepare("UPDATE users SET admin_level = COALESCE(admin_level, 'super'), active = COALESCE(active, 1) WHERE role = 'admin'").run();
|
||
for (const user of extension.users.filter(item => item.role === 'admin')) connection.prepare(
|
||
`INSERT OR IGNORE INTO users (
|
||
id, username, password_hash, role, admin_level, school_id, class_id, active, display_name, created_at
|
||
) VALUES (?, ?, ?, 'admin', ?, ?, ?, ?, ?, ?)`
|
||
).run(user.id, user.username, user.passwordHash, user.adminLevel, optional(user.schoolId), optional(user.classId), user.active === false ? 0 : 1, user.displayName, user.createdAt);
|
||
for (const profile of extension.candidateProfiles) connection.prepare(
|
||
`UPDATE candidate_profiles SET school_id = COALESCE(school_id, ?), class_id = COALESCE(class_id, ?)
|
||
WHERE school = ? AND grade = ?`
|
||
).run(optional(profile.schoolId), optional(profile.classId), profile.school, profile.grade);
|
||
if (!connection.prepare('SELECT id FROM test_centers LIMIT 1').get()) {
|
||
for (const center of extension.testCenters) connection.prepare(
|
||
'INSERT INTO test_centers (id, school_id, name, address, contact, rooms, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)'
|
||
).run(center.id, center.schoolId, center.name, center.address, optional(center.contact), center.rooms || '', center.updatedAt);
|
||
}
|
||
if (!connection.prepare('SELECT id FROM number_rules LIMIT 1').get()) {
|
||
for (const rule of extension.numberRules) {
|
||
connection.prepare('INSERT INTO number_rules (id, name, separator, active, created_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)').run(rule.id, rule.name, rule.separator || '', rule.active ? 1 : 0, optional(rule.createdBy), rule.updatedAt);
|
||
rule.segments.forEach((segment, index) => connection.prepare(
|
||
'INSERT INTO number_rule_segments (id, rule_id, position, type, value, width) VALUES (?, ?, ?, ?, ?, ?)'
|
||
).run(segment.id, rule.id, Number(segment.position || index + 1), segment.type, optional(segment.value), Number(segment.width || 0)));
|
||
}
|
||
}
|
||
if (!connection.prepare('SELECT id FROM workflow_definitions LIMIT 1').get()) {
|
||
for (const workflow of extension.workflows) {
|
||
connection.prepare(
|
||
'INSERT INTO workflow_definitions (id, business_type, name, active, updated_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)'
|
||
).run(workflow.id, workflow.businessType, workflow.name, workflow.active === false ? 0 : 1, optional(workflow.updatedBy), workflow.updatedAt);
|
||
workflow.steps.forEach((step, index) => connection.prepare(
|
||
'INSERT INTO workflow_steps (id, workflow_id, position, name, admin_level) VALUES (?, ?, ?, ?, ?)'
|
||
).run(step.id, workflow.id, Number(step.position || index + 1), step.name, step.adminLevel));
|
||
}
|
||
}
|
||
connection.prepare('UPDATE schema_metadata SET schema_version = 2, app_version = 2 WHERE id = 1').run();
|
||
connection.exec('COMMIT');
|
||
} catch (error) {
|
||
connection.exec('ROLLBACK');
|
||
connection.close();
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
if (existingSystem && Number(existingSystem.app_version || 1) < 3) {
|
||
const extension = seed();
|
||
connection.exec('BEGIN IMMEDIATE');
|
||
try {
|
||
for (const center of extension.testCenters) connection.prepare(
|
||
`UPDATE test_centers SET
|
||
code = COALESCE(NULLIF(code, ''), ?), manager_name = COALESCE(manager_name, ?),
|
||
manager_phone = COALESCE(manager_phone, ?), emergency_phone = COALESCE(emergency_phone, ?),
|
||
gate_open_time = COALESCE(gate_open_time, ?), transport = COALESCE(transport, ?),
|
||
status = COALESCE(status, 'active'), notes = COALESCE(notes, ?)
|
||
WHERE id = ?`
|
||
).run(center.code, optional(center.managerName), optional(center.managerPhone), optional(center.emergencyPhone),
|
||
optional(center.gateOpenTime), optional(center.transport), optional(center.notes), center.id);
|
||
connection.prepare("UPDATE test_centers SET code = 'CENTER-' || substr(id, -8) WHERE code IS NULL OR code = ''").run();
|
||
if (!connection.prepare('SELECT id FROM test_rooms LIMIT 1').get()) {
|
||
for (const room of extension.testRooms) connection.prepare(
|
||
`INSERT INTO test_rooms (
|
||
id, center_id, code, name, building, floor, capacity, seat_start, seat_end, room_type, status, notes
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||
).run(room.id, room.centerId, room.code, room.name, room.building, optional(room.floor), Number(room.capacity),
|
||
Number(room.seatStart), Number(room.seatEnd), room.roomType, room.status, optional(room.notes));
|
||
}
|
||
const centerWorkflow = extension.workflows.find(item => item.businessType === 'center_change');
|
||
if (centerWorkflow && !connection.prepare("SELECT id FROM workflow_definitions WHERE business_type = 'center_change' AND active = 1").get()) {
|
||
connection.prepare(
|
||
'INSERT INTO workflow_definitions (id, business_type, name, active, updated_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)'
|
||
).run(centerWorkflow.id, centerWorkflow.businessType, centerWorkflow.name, 1, optional(centerWorkflow.updatedBy), centerWorkflow.updatedAt);
|
||
for (const [index, step] of centerWorkflow.steps.entries()) connection.prepare(
|
||
'INSERT INTO workflow_steps (id, workflow_id, position, name, admin_level) VALUES (?, ?, ?, ?, ?)'
|
||
).run(step.id, centerWorkflow.id, Number(step.position || index + 1), step.name, step.adminLevel);
|
||
}
|
||
connection.prepare('UPDATE schema_metadata SET schema_version = 3, app_version = 3 WHERE id = 1').run();
|
||
connection.exec('COMMIT');
|
||
} catch (error) {
|
||
connection.exec('ROLLBACK');
|
||
connection.close();
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
if (!connection.prepare('SELECT id FROM schema_metadata WHERE id = 1').get()) {
|
||
const initialState = seed();
|
||
connection.exec('BEGIN IMMEDIATE');
|
||
try {
|
||
connection.prepare(`
|
||
INSERT INTO schema_metadata (id, schema_version, app_version, created_at)
|
||
VALUES (1, 3, ?, ?)
|
||
`).run(Number(initialState.meta?.version || 1), initialState.meta?.createdAt || new Date().toISOString());
|
||
for (const item of buildSeedOperations(initialState)) connection.prepare(item.sql).run(...item.params);
|
||
connection.exec('COMMIT');
|
||
} catch (error) {
|
||
connection.exec('ROLLBACK');
|
||
connection.close();
|
||
throw error;
|
||
}
|
||
}
|
||
|
||
const transaction = async operations => {
|
||
connection.exec('BEGIN IMMEDIATE');
|
||
try {
|
||
for (const item of operations) connection.prepare(item.sql).run(...item.params);
|
||
connection.exec('COMMIT');
|
||
} catch (error) {
|
||
connection.exec('ROLLBACK');
|
||
throw error;
|
||
}
|
||
};
|
||
return createRepository({
|
||
client: 'sqlite',
|
||
location: path,
|
||
read: async () => stateFromRows(readSqliteRows(connection)),
|
||
transaction,
|
||
close: async () => connection.close()
|
||
});
|
||
}
|
||
|
||
async function createMysqlStore({ seed }) {
|
||
const { default: mysql } = await import('mysql2/promise');
|
||
const connectionUrl = process.env.DATABASE_URL;
|
||
const database = process.env.MYSQL_DATABASE;
|
||
|
||
if (!connectionUrl && (!process.env.MYSQL_HOST || !process.env.MYSQL_USER || !database)) {
|
||
throw new Error('MySQL 配置不完整:请设置 DATABASE_URL,或 MYSQL_HOST、MYSQL_USER、MYSQL_DATABASE');
|
||
}
|
||
|
||
const pool = connectionUrl
|
||
? mysql.createPool(connectionUrl)
|
||
: mysql.createPool({
|
||
host: process.env.MYSQL_HOST,
|
||
port: Number(process.env.MYSQL_PORT || 3306),
|
||
user: process.env.MYSQL_USER,
|
||
password: process.env.MYSQL_PASSWORD || '',
|
||
database,
|
||
waitForConnections: true,
|
||
connectionLimit: Number(process.env.MYSQL_CONNECTION_LIMIT || 10),
|
||
charset: 'utf8mb4',
|
||
timezone: 'Z',
|
||
enableKeepAlive: true
|
||
});
|
||
|
||
for (const statement of mysqlSchema) await pool.execute(statement);
|
||
const mysqlColumnMigrations = [
|
||
"ALTER TABLE users ADD COLUMN IF NOT EXISTS admin_level ENUM('super', 'school', 'class') NULL",
|
||
'ALTER TABLE users ADD COLUMN IF NOT EXISTS school_id VARCHAR(64) NULL',
|
||
'ALTER TABLE users ADD COLUMN IF NOT EXISTS class_id VARCHAR(64) NULL',
|
||
'ALTER TABLE users ADD COLUMN IF NOT EXISTS active BOOLEAN NOT NULL DEFAULT TRUE',
|
||
'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS school_id VARCHAR(64) NULL',
|
||
'ALTER TABLE candidate_profiles ADD COLUMN IF NOT EXISTS class_id VARCHAR(64) NULL',
|
||
'ALTER TABLE registrations ADD COLUMN IF NOT EXISTS registration_number VARCHAR(120) NULL',
|
||
'ALTER TABLE registrations ADD COLUMN IF NOT EXISTS number_rule_id VARCHAR(64) NULL',
|
||
'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS code VARCHAR(40) NULL',
|
||
'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS manager_name VARCHAR(100) NULL',
|
||
'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS manager_phone VARCHAR(60) NULL',
|
||
'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS emergency_phone VARCHAR(60) NULL',
|
||
'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS gate_open_time VARCHAR(40) NULL',
|
||
'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS transport VARCHAR(500) NULL',
|
||
"ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS status ENUM('active', 'inactive') NOT NULL DEFAULT 'active'",
|
||
'ALTER TABLE test_centers ADD COLUMN IF NOT EXISTS notes VARCHAR(1000) NULL',
|
||
"ALTER TABLE workflow_definitions MODIFY COLUMN business_type ENUM('profile_change', 'registration_review', 'center_change') NOT NULL"
|
||
];
|
||
for (const statement of mysqlColumnMigrations) await pool.execute(statement);
|
||
const [existing] = await pool.execute('SELECT id FROM schema_metadata WHERE id = 1');
|
||
if (existing.length) {
|
||
const [metadataRows] = await pool.execute('SELECT app_version FROM schema_metadata WHERE id = 1');
|
||
if (Number(metadataRows[0]?.app_version || 1) < 2) {
|
||
const extension = seed();
|
||
const connection = await pool.getConnection();
|
||
try {
|
||
await connection.beginTransaction();
|
||
for (const school of extension.schools) await connection.execute(
|
||
'INSERT IGNORE INTO schools (id, name, code, address, active) VALUES (?, ?, ?, ?, ?)',
|
||
[school.id, school.name, school.code, optional(school.address), school.active === false ? 0 : 1]
|
||
);
|
||
for (const schoolClass of extension.classes) await connection.execute(
|
||
'INSERT IGNORE INTO school_classes (id, school_id, name, grade, active) VALUES (?, ?, ?, ?, ?)',
|
||
[schoolClass.id, schoolClass.schoolId, schoolClass.name, schoolClass.grade, schoolClass.active === false ? 0 : 1]
|
||
);
|
||
await connection.execute("UPDATE users SET admin_level = COALESCE(admin_level, 'super'), active = COALESCE(active, TRUE) WHERE role = 'admin'");
|
||
for (const user of extension.users.filter(item => item.role === 'admin')) await connection.execute(
|
||
`INSERT IGNORE 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]
|
||
);
|
||
for (const profile of extension.candidateProfiles) await connection.execute(
|
||
`UPDATE candidate_profiles SET school_id = COALESCE(school_id, ?), class_id = COALESCE(class_id, ?)
|
||
WHERE school = ? AND grade = ?`,
|
||
[optional(profile.schoolId), optional(profile.classId), profile.school, profile.grade]
|
||
);
|
||
const [centerRows] = await connection.execute('SELECT id FROM test_centers LIMIT 1');
|
||
if (!centerRows.length) for (const center of extension.testCenters) await connection.execute(
|
||
'INSERT INTO test_centers (id, school_id, name, address, contact, rooms, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||
[center.id, center.schoolId, center.name, center.address, optional(center.contact), center.rooms || '', center.updatedAt]
|
||
);
|
||
const [ruleRows] = await connection.execute('SELECT id FROM number_rules LIMIT 1');
|
||
if (!ruleRows.length) for (const rule of extension.numberRules) {
|
||
await connection.execute(
|
||
'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]
|
||
);
|
||
for (const [index, segment] of rule.segments.entries()) await connection.execute(
|
||
'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)]
|
||
);
|
||
}
|
||
const [workflowRows] = await connection.execute('SELECT id FROM workflow_definitions LIMIT 1');
|
||
if (!workflowRows.length) for (const workflow of extension.workflows) {
|
||
await connection.execute(
|
||
'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, optional(workflow.updatedBy), workflow.updatedAt]
|
||
);
|
||
for (const [index, step] of workflow.steps.entries()) await connection.execute(
|
||
'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]
|
||
);
|
||
}
|
||
await connection.execute('UPDATE schema_metadata SET schema_version = 2, app_version = 2 WHERE id = 1');
|
||
await connection.commit();
|
||
} catch (error) {
|
||
await connection.rollback();
|
||
throw error;
|
||
} finally {
|
||
connection.release();
|
||
}
|
||
}
|
||
if (Number(metadataRows[0]?.app_version || 1) < 3) {
|
||
const extension = seed();
|
||
const connection = await pool.getConnection();
|
||
try {
|
||
await connection.beginTransaction();
|
||
for (const center of extension.testCenters) await connection.execute(
|
||
`UPDATE test_centers SET
|
||
code = COALESCE(NULLIF(code, ''), ?), manager_name = COALESCE(manager_name, ?),
|
||
manager_phone = COALESCE(manager_phone, ?), emergency_phone = COALESCE(emergency_phone, ?),
|
||
gate_open_time = COALESCE(gate_open_time, ?), transport = COALESCE(transport, ?),
|
||
status = COALESCE(status, 'active'), notes = COALESCE(notes, ?)
|
||
WHERE id = ?`,
|
||
[center.code, optional(center.managerName), optional(center.managerPhone), optional(center.emergencyPhone),
|
||
optional(center.gateOpenTime), optional(center.transport), optional(center.notes), center.id]
|
||
);
|
||
await connection.execute("UPDATE test_centers SET code = CONCAT('CENTER-', RIGHT(id, 8)) WHERE code IS NULL OR code = ''");
|
||
const [roomRows] = await connection.execute('SELECT id FROM test_rooms LIMIT 1');
|
||
if (!roomRows.length) for (const room of extension.testRooms) await connection.execute(
|
||
`INSERT INTO test_rooms (
|
||
id, center_id, code, name, building, floor, capacity, seat_start, seat_end, room_type, status, notes
|
||
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||
[room.id, room.centerId, room.code, room.name, room.building, optional(room.floor), Number(room.capacity),
|
||
Number(room.seatStart), Number(room.seatEnd), room.roomType, room.status, optional(room.notes)]
|
||
);
|
||
const centerWorkflow = extension.workflows.find(item => item.businessType === 'center_change');
|
||
const [centerWorkflowRows] = await connection.execute("SELECT id FROM workflow_definitions WHERE business_type = 'center_change' AND active = 1");
|
||
if (centerWorkflow && !centerWorkflowRows.length) {
|
||
await connection.execute(
|
||
'INSERT INTO workflow_definitions (id, business_type, name, active, updated_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)',
|
||
[centerWorkflow.id, centerWorkflow.businessType, centerWorkflow.name, 1, optional(centerWorkflow.updatedBy), centerWorkflow.updatedAt]
|
||
);
|
||
for (const [index, step] of centerWorkflow.steps.entries()) await connection.execute(
|
||
'INSERT INTO workflow_steps (id, workflow_id, position, name, admin_level) VALUES (?, ?, ?, ?, ?)',
|
||
[step.id, centerWorkflow.id, Number(step.position || index + 1), step.name, step.adminLevel]
|
||
);
|
||
}
|
||
await connection.execute('UPDATE schema_metadata SET schema_version = 3, app_version = 3 WHERE id = 1');
|
||
await connection.commit();
|
||
} catch (error) {
|
||
await connection.rollback();
|
||
throw error;
|
||
} finally {
|
||
connection.release();
|
||
}
|
||
}
|
||
}
|
||
if (!existing.length) {
|
||
const initialState = seed();
|
||
const connection = await pool.getConnection();
|
||
try {
|
||
await connection.beginTransaction();
|
||
const [insert] = await connection.execute(`
|
||
INSERT IGNORE INTO schema_metadata (id, schema_version, app_version, created_at)
|
||
VALUES (1, 3, ?, ?)
|
||
`, [Number(initialState.meta?.version || 1), initialState.meta?.createdAt || new Date().toISOString()]);
|
||
if (insert.affectedRows === 1) {
|
||
for (const item of buildSeedOperations(initialState)) await connection.execute(item.sql, item.params);
|
||
}
|
||
await connection.commit();
|
||
} catch (error) {
|
||
await connection.rollback();
|
||
throw error;
|
||
} finally {
|
||
connection.release();
|
||
}
|
||
}
|
||
|
||
const [centerCodeIndexes] = await pool.execute("SHOW INDEX FROM test_centers WHERE Key_name = 'uq_centers_code'");
|
||
if (!centerCodeIndexes.length) {
|
||
await pool.execute('ALTER TABLE test_centers MODIFY COLUMN code VARCHAR(40) NOT NULL, ADD UNIQUE KEY uq_centers_code (code)');
|
||
}
|
||
|
||
const transaction = async operations => {
|
||
const connection = await pool.getConnection();
|
||
try {
|
||
await connection.beginTransaction();
|
||
for (const item of operations) await connection.execute(item.sql, item.params);
|
||
await connection.commit();
|
||
} catch (error) {
|
||
await connection.rollback();
|
||
throw error;
|
||
} finally {
|
||
connection.release();
|
||
}
|
||
};
|
||
const read = async () => {
|
||
const connection = await pool.getConnection();
|
||
try {
|
||
await connection.beginTransaction();
|
||
const state = stateFromRows(await readMysqlRows(connection));
|
||
await connection.commit();
|
||
return state;
|
||
} catch (error) {
|
||
await connection.rollback();
|
||
throw error;
|
||
} finally {
|
||
connection.release();
|
||
}
|
||
};
|
||
return createRepository({
|
||
client: 'mysql',
|
||
location: connectionUrl ? 'DATABASE_URL' : `${process.env.MYSQL_HOST}:${process.env.MYSQL_PORT || 3306}/${database}`,
|
||
read,
|
||
transaction,
|
||
close: async () => pool.end()
|
||
});
|
||
}
|
||
|
||
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 });
|
||
}
|