Implement batch candidate account requests and approval-based number eal

This commit is contained in:
2026-07-20 09:02:30 +08:00 Unverified
parent 45d48c8ffa
commit 4fa001cece
7 changed files with 549 additions and 164 deletions
+237 -35
View File
@@ -21,6 +21,8 @@ export const relationalTables = [
'center_change_rooms',
'number_rules',
'number_rule_segments',
'candidate_account_batches',
'candidate_account_batch_items',
'workflow_definitions',
'workflow_steps',
'workflow_instances',
@@ -284,9 +286,31 @@ const sqliteSchema = `
UNIQUE (rule_id, position)
) STRICT;
CREATE TABLE IF NOT EXISTS candidate_account_batches (
id TEXT PRIMARY KEY,
school_id TEXT NOT NULL REFERENCES schools(id) ON DELETE CASCADE,
requested_by TEXT REFERENCES users(id) ON DELETE SET NULL,
status TEXT NOT NULL CHECK (status IN ('pending', 'approved', 'rejected')),
review_note TEXT,
created_at TEXT NOT NULL,
reviewed_at TEXT
) STRICT;
CREATE TABLE IF NOT EXISTS candidate_account_batch_items (
id TEXT PRIMARY KEY,
batch_id TEXT NOT NULL REFERENCES candidate_account_batches(id) ON DELETE CASCADE,
class_id TEXT NOT NULL REFERENCES school_classes(id) ON DELETE RESTRICT,
position INTEGER NOT NULL,
candidate_number TEXT UNIQUE,
initial_password TEXT,
user_id TEXT UNIQUE REFERENCES users(id) ON DELETE SET NULL,
created_at TEXT,
UNIQUE (batch_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')),
business_type TEXT NOT NULL CHECK (business_type IN ('profile_change', 'registration_review', 'center_change', 'candidate_account_batch')),
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,
@@ -345,6 +369,8 @@ const sqliteSchema = `
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_account_batches_school ON candidate_account_batches(school_id, status, created_at);
CREATE INDEX IF NOT EXISTS idx_account_batch_items ON candidate_account_batch_items(batch_id, class_id, position);
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);
`;
@@ -647,9 +673,40 @@ const mysqlSchema = [
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 candidate_account_batches (
id VARCHAR(64) NOT NULL,
school_id VARCHAR(64) NOT NULL,
requested_by VARCHAR(64) NULL,
status ENUM('pending', 'approved', 'rejected') NOT NULL,
review_note VARCHAR(500) NULL,
created_at VARCHAR(35) NOT NULL,
reviewed_at VARCHAR(35) NULL,
PRIMARY KEY (id),
KEY idx_account_batches_school (school_id, status, created_at),
CONSTRAINT fk_account_batches_school FOREIGN KEY (school_id) REFERENCES schools(id) ON DELETE CASCADE,
CONSTRAINT fk_account_batches_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 candidate_account_batch_items (
id VARCHAR(64) NOT NULL,
batch_id VARCHAR(64) NOT NULL,
class_id VARCHAR(64) NOT NULL,
position INT UNSIGNED NOT NULL,
candidate_number VARCHAR(120) NULL,
initial_password VARCHAR(120) NULL,
user_id VARCHAR(64) NULL,
created_at VARCHAR(35) NULL,
PRIMARY KEY (id),
UNIQUE KEY uq_account_batch_position (batch_id, position),
UNIQUE KEY uq_account_batch_number (candidate_number),
UNIQUE KEY uq_account_batch_user (user_id),
KEY idx_account_batch_items (batch_id, class_id, position),
CONSTRAINT fk_account_batch_items_batch FOREIGN KEY (batch_id) REFERENCES candidate_account_batches(id) ON DELETE CASCADE,
CONSTRAINT fk_account_batch_items_class FOREIGN KEY (class_id) REFERENCES school_classes(id),
CONSTRAINT fk_account_batch_items_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE SET NULL
) 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,
business_type ENUM('profile_change', 'registration_review', 'center_change', 'candidate_account_batch') NOT NULL,
name VARCHAR(120) NOT NULL,
active BOOLEAN NOT NULL DEFAULT TRUE,
updated_by VARCHAR(64) NULL,
@@ -715,7 +772,8 @@ function validateState(state, source = '数据库') {
const collections = [
'schools', 'classes', 'users', 'candidateProfiles', 'notices', 'exams', 'registrations', 'results',
'testCenters', 'testRooms', 'centerChangeRequests', 'centerChangeRooms',
'numberRules', 'workflows', 'workflowInstances', 'workflowActions', 'auditLogs'
'numberRules', 'candidateAccountBatches', 'candidateAccountBatchItems',
'workflows', 'workflowInstances', 'workflowActions', 'auditLogs'
];
if (!state || typeof state !== 'object' || collections.some(name => !Array.isArray(state[name]))) {
throw new Error(`${source}中的应用数据格式无效`);
@@ -730,7 +788,7 @@ function buildSeedOperations(state) {
const nullable = value => value == null || value === '' ? null : value;
add(
'UPDATE schema_metadata SET schema_version = 4, app_version = ?, self_registration_enabled = ?, created_at = ? WHERE id = 1',
'UPDATE schema_metadata SET schema_version = 5, app_version = ?, self_registration_enabled = ?, created_at = ? WHERE id = 1',
Number(state.meta?.version || 1), state.settings?.selfRegistrationEnabled ? 1 : 0,
state.meta?.createdAt || new Date().toISOString()
);
@@ -904,6 +962,26 @@ function buildSeedOperations(state) {
));
}
for (const batch of state.candidateAccountBatches) {
add(
`INSERT INTO candidate_account_batches (
id, school_id, requested_by, status, review_note, created_at, reviewed_at
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
batch.id, batch.schoolId, nullable(batch.requestedBy), batch.status, nullable(batch.reviewNote),
batch.createdAt, nullable(batch.reviewedAt)
);
}
for (const item of state.candidateAccountBatchItems) {
add(
`INSERT INTO candidate_account_batch_items (
id, batch_id, class_id, position, candidate_number, initial_password, user_id, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
item.id, item.batchId, item.classId, Number(item.position), nullable(item.candidateNumber),
nullable(item.initialPassword), nullable(item.userId), nullable(item.createdAt)
);
}
for (const workflow of state.workflows) {
add(
`INSERT INTO workflow_definitions (
@@ -1199,6 +1277,25 @@ function stateFromRows(rows) {
updatedAt: row.updated_at,
segments: ruleSegments.get(row.id) || []
})),
candidateAccountBatches: rows.candidateAccountBatches.map(row => ({
id: row.id,
schoolId: row.school_id,
requestedBy: row.requested_by,
status: row.status,
reviewNote: row.review_note || '',
createdAt: row.created_at,
reviewedAt: row.reviewed_at
})),
candidateAccountBatchItems: rows.candidateAccountBatchItems.map(row => ({
id: row.id,
batchId: row.batch_id,
classId: row.class_id,
position: Number(row.position),
candidateNumber: row.candidate_number || '',
initialPassword: row.initial_password || '',
userId: row.user_id,
createdAt: row.created_at
})),
workflows: rows.workflows.map(row => ({
id: row.id,
businessType: row.business_type,
@@ -1261,6 +1358,8 @@ function readSqliteRows(connection) {
centerChangeRooms: connection.prepare('SELECT * FROM center_change_rooms ORDER BY request_id, code, id').all(),
numberRules: connection.prepare('SELECT * FROM number_rules ORDER BY updated_at DESC, id').all(),
numberRuleSegments: connection.prepare('SELECT * FROM number_rule_segments ORDER BY rule_id, position, id').all(),
candidateAccountBatches: connection.prepare('SELECT * FROM candidate_account_batches ORDER BY created_at DESC, id').all(),
candidateAccountBatchItems: connection.prepare('SELECT * FROM candidate_account_batch_items ORDER BY batch_id, position, id').all(),
workflows: connection.prepare('SELECT * FROM workflow_definitions ORDER BY business_type, id').all(),
workflowSteps: connection.prepare('SELECT * FROM workflow_steps ORDER BY workflow_id, position, id').all(),
workflowInstances: connection.prepare('SELECT * FROM workflow_instances ORDER BY created_at DESC, id').all(),
@@ -1292,6 +1391,8 @@ async function readMysqlRows(connection) {
centerChangeRooms: await query('SELECT * FROM center_change_rooms ORDER BY request_id, code, id'),
numberRules: await query('SELECT * FROM number_rules ORDER BY updated_at DESC, id'),
numberRuleSegments: await query('SELECT * FROM number_rule_segments ORDER BY rule_id, position, id'),
candidateAccountBatches: await query('SELECT * FROM candidate_account_batches ORDER BY created_at DESC, id'),
candidateAccountBatchItems: await query('SELECT * FROM candidate_account_batch_items ORDER BY batch_id, position, id'),
workflows: await query('SELECT * FROM workflow_definitions ORDER BY business_type, id'),
workflowSteps: await query('SELECT * FROM workflow_steps ORDER BY workflow_id, position, id'),
workflowInstances: await query('SELECT * FROM workflow_instances ORDER BY created_at DESC, id'),
@@ -1374,6 +1475,26 @@ function createRepository({ client, location, read, transaction, close }) {
if (log) operations.push(auditOperation(log));
await transaction(operations);
},
async createCandidateAccountBatch(batch, items, instance, action, log) {
const operations = [
operation(
`INSERT INTO candidate_account_batches (
id, school_id, requested_by, status, review_note, created_at, reviewed_at
) VALUES (?, ?, ?, ?, ?, ?, ?)`,
batch.id, batch.schoolId, optional(batch.requestedBy), batch.status, optional(batch.reviewNote),
batch.createdAt, optional(batch.reviewedAt)
)
];
for (const item of items) operations.push(operation(
`INSERT INTO candidate_account_batch_items (
id, batch_id, class_id, position, candidate_number, initial_password, user_id, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
item.id, item.batchId, item.classId, Number(item.position), optional(item.candidateNumber),
optional(item.initialPassword), optional(item.userId), optional(item.createdAt)
));
operations.push(...workflowCreateOperations(instance, action), auditOperation(log));
await transaction(operations);
},
async updateCandidateProfile(profile, displayName, instance, action) {
const operations = [
operation(
@@ -1472,16 +1593,71 @@ function createRepository({ client, location, read, transaction, close }) {
business.status, business.paymentStatus, optional(business.reviewedAt), optional(business.reviewNote),
optional(business.registrationNumber), optional(business.numberRuleId), business.id
));
} else {
} else if (instance.businessType === 'center_change') {
operations.push(operation(
`UPDATE center_change_requests SET
status = ?, review_note = ?, reviewed_at = ? WHERE id = ?`,
business.status, optional(business.reviewNote), optional(business.reviewedAt), business.id
));
} else if (instance.businessType === 'candidate_account_batch') {
operations.push(operation(
`UPDATE candidate_account_batches SET
status = ?, review_note = ?, reviewed_at = ? WHERE id = ?`,
business.status, optional(business.reviewNote), optional(business.reviewedAt), business.id
));
}
if (log) operations.push(auditOperation(log));
await transaction(operations);
},
async completeCandidateAccountBatch(batch, items, users, profiles, instance, action, log) {
const operations = [
operation(
`UPDATE workflow_instances SET
status = ?, current_step = ?, assignee_id = ?, completed_at = ? WHERE id = ?`,
instance.status, Number(instance.currentStep), optional(instance.assigneeId), optional(instance.completedAt), instance.id
),
workflowActionOperation(action),
operation(
`UPDATE candidate_account_batches SET status = ?, review_note = ?, reviewed_at = ? WHERE id = ?`,
batch.status, optional(batch.reviewNote), optional(batch.reviewedAt), batch.id
)
];
for (let index = 0; index < users.length; index += 1) {
const user = users[index];
const profile = profiles[index];
const item = items[index];
operations.push(
operation(
`INSERT INTO users (
id, username, candidate_number, password_hash, role, admin_level, school_id, class_id, active,
must_change_password, display_name, created_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
user.id, user.username, user.candidateNumber, user.passwordHash, user.role, optional(user.adminLevel),
user.schoolId, user.classId, 1, 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, native_place, birth_date, ethnicity, postal_code, guardian_name,
guardian_phone, profile_completed, status, review_note, reviewed_at, reviewer_id, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
profile.id, profile.userId, profile.name, optional(profile.gender), profile.idNumber, profile.phone,
optional(profile.email), profile.school, profile.grade, profile.schoolId, profile.classId,
optional(profile.address), optional(profile.emergencyContact), optional(profile.emergencyPhone),
optional(profile.nativePlace), optional(profile.birthDate), optional(profile.ethnicity), optional(profile.postalCode),
optional(profile.guardianName), optional(profile.guardianPhone), 0, profile.status, optional(profile.reviewNote),
optional(profile.reviewedAt), optional(profile.reviewerId), profile.updatedAt
),
operation(
`UPDATE candidate_account_batch_items SET
candidate_number = ?, initial_password = ?, user_id = ?, created_at = ? WHERE id = ?`,
item.candidateNumber, item.initialPassword, item.userId, item.createdAt, item.id
)
);
}
operations.push(auditOperation(log));
await transaction(operations);
},
async transferWorkflow(instance, action, log) {
const operations = [
operation('UPDATE workflow_instances SET assignee_id = ? WHERE id = ?', optional(instance.assigneeId), instance.id),
@@ -1642,28 +1818,6 @@ function createRepository({ client, location, read, transaction, close }) {
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 assignCandidateNumbers(users, registrations, log) {
const operations = users.map(user => operation(
'UPDATE users SET candidate_number = ? WHERE id = ? AND (candidate_number IS NULL OR candidate_number = \'\')',
user.candidateNumber, user.id
));
operations.push(...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 (
@@ -1781,23 +1935,23 @@ async function createSqliteStore({ path, seed }) {
]);
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')) {
if (!definitionSql.includes('candidate_account_batch')) {
connection.exec(`
PRAGMA foreign_keys = OFF;
BEGIN IMMEDIATE;
CREATE TABLE workflow_definitions_v3 (
CREATE TABLE workflow_definitions_v5 (
id TEXT PRIMARY KEY,
business_type TEXT NOT NULL CHECK (business_type IN ('profile_change', 'registration_review', 'center_change')),
business_type TEXT NOT NULL CHECK (business_type IN ('profile_change', 'registration_review', 'center_change', 'candidate_account_batch')),
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)
INSERT INTO workflow_definitions_v5 (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;
ALTER TABLE workflow_definitions_v5 RENAME TO workflow_definitions;
COMMIT;
PRAGMA foreign_keys = ON;
`);
@@ -1958,13 +2112,35 @@ async function createSqliteStore({ path, seed }) {
}
}
if (existingSystem && Number(existingSystem.app_version || 1) < 5) {
const extension = seed();
const batchWorkflow = extension.workflows.find(item => item.businessType === 'candidate_account_batch');
connection.exec('BEGIN IMMEDIATE');
try {
if (batchWorkflow && !connection.prepare("SELECT id FROM workflow_definitions WHERE business_type = 'candidate_account_batch' AND active = 1").get()) {
connection.prepare(
'INSERT INTO workflow_definitions (id, business_type, name, active, updated_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)'
).run(batchWorkflow.id, batchWorkflow.businessType, batchWorkflow.name, 1, optional(batchWorkflow.updatedBy), batchWorkflow.updatedAt);
for (const [index, step] of batchWorkflow.steps.entries()) connection.prepare(
'INSERT INTO workflow_steps (id, workflow_id, position, name, admin_level) VALUES (?, ?, ?, ?, ?)'
).run(step.id, batchWorkflow.id, Number(step.position || index + 1), step.name, step.adminLevel);
}
connection.prepare('UPDATE schema_metadata SET schema_version = 5, app_version = 5 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, self_registration_enabled, created_at)
VALUES (1, 4, ?, ?, ?)
VALUES (1, 5, ?, ?, ?)
`).run(Number(initialState.meta?.version || 1), initialState.settings?.selfRegistrationEnabled ? 1 : 0, initialState.meta?.createdAt || new Date().toISOString());
for (const item of buildSeedOperations(initialState)) connection.prepare(item.sql).run(...item.params);
connection.exec('COMMIT');
@@ -2046,7 +2222,7 @@ async function createMysqlStore({ seed }) {
'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"
"ALTER TABLE workflow_definitions MODIFY COLUMN business_type ENUM('profile_change', 'registration_review', 'center_change', 'candidate_account_batch') NOT NULL"
];
for (const statement of mysqlColumnMigrations) await pool.execute(statement);
const [legacyRegistrationNumberIndexes] = await pool.execute("SHOW INDEX FROM registrations WHERE Key_name = 'uq_registrations_number'");
@@ -2193,6 +2369,32 @@ async function createMysqlStore({ seed }) {
connection.release();
}
}
if (Number(metadataRows[0]?.app_version || 1) < 5) {
const extension = seed();
const batchWorkflow = extension.workflows.find(item => item.businessType === 'candidate_account_batch');
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
const [batchWorkflowRows] = await connection.execute("SELECT id FROM workflow_definitions WHERE business_type = 'candidate_account_batch' AND active = 1");
if (batchWorkflow && !batchWorkflowRows.length) {
await connection.execute(
'INSERT INTO workflow_definitions (id, business_type, name, active, updated_by, updated_at) VALUES (?, ?, ?, ?, ?, ?)',
[batchWorkflow.id, batchWorkflow.businessType, batchWorkflow.name, 1, optional(batchWorkflow.updatedBy), batchWorkflow.updatedAt]
);
for (const [index, step] of batchWorkflow.steps.entries()) await connection.execute(
'INSERT INTO workflow_steps (id, workflow_id, position, name, admin_level) VALUES (?, ?, ?, ?, ?)',
[step.id, batchWorkflow.id, Number(step.position || index + 1), step.name, step.adminLevel]
);
}
await connection.execute('UPDATE schema_metadata SET schema_version = 5, app_version = 5 WHERE id = 1');
await connection.commit();
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
}
}
if (!existing.length) {
const initialState = seed();
@@ -2201,7 +2403,7 @@ async function createMysqlStore({ seed }) {
await connection.beginTransaction();
const [insert] = await connection.execute(`
INSERT IGNORE INTO schema_metadata (id, schema_version, app_version, self_registration_enabled, created_at)
VALUES (1, 4, ?, ?, ?)
VALUES (1, 5, ?, ?, ?)
`, [Number(initialState.meta?.version || 1), initialState.settings?.selfRegistrationEnabled ? 1 : 0, initialState.meta?.createdAt || new Date().toISOString()]);
if (insert.affectedRows === 1) {
for (const item of buildSeedOperations(initialState)) await connection.execute(item.sql, item.params);