feat: add attendance device SN classroom bindings

This commit is contained in:
2026-07-16 10:22:23 +08:00
parent d037787346
commit 559b8a56e9
16 changed files with 1323 additions and 23 deletions

View File

@@ -11,6 +11,7 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
async onApplicationBootstrap(): Promise<void> {
await this.ensureAiConfigTable();
await this.ensureCourseAttendanceSchema();
await this.ensureAttendanceDevicesSchema();
await this.ensureStudentWalletSchema();
await this.backfillOrganizations();
await this.normalizeClassDates();
@@ -22,6 +23,64 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
await this.normalizeClassroomStatuses();
}
private async ensureAttendanceDevicesSchema(): Promise<void> {
const runner = this.dataSource.createQueryRunner();
await runner.connect();
try {
const isMySQL = this.dataSource.options.type === 'mysql';
const pk = isMySQL ? 'INTEGER PRIMARY KEY AUTO_INCREMENT' : 'INTEGER PRIMARY KEY AUTOINCREMENT';
await runner.query(`CREATE TABLE IF NOT EXISTS attendance_devices (
id ${pk},
device_sn VARCHAR(100) NOT NULL,
device_name VARCHAR(100) NOT NULL,
classroom_id INTEGER NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'active',
location VARCHAR(200),
notes TEXT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
)`);
const table = await runner.getTable('attendance_devices');
const columnNames = new Set(table?.columns.map((column) => column.name) ?? []);
const additions: Array<[string, string]> = [
['device_sn', 'VARCHAR(100) NOT NULL DEFAULT \'\''],
['device_name', 'VARCHAR(100) NOT NULL DEFAULT \'\''],
['classroom_id', 'INTEGER NOT NULL DEFAULT 0'],
['status', "VARCHAR(20) NOT NULL DEFAULT 'active'"],
['location', 'VARCHAR(200)'],
['notes', 'TEXT'],
['created_at', 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP'],
['updated_at', 'DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP'],
];
for (const [name, definition] of additions) {
if (!columnNames.has(name)) await runner.query(`ALTER TABLE attendance_devices ADD COLUMN ${name} ${definition}`);
}
const refreshed = await runner.getTable('attendance_devices');
const createIndex = async (sql: string) => {
try {
await runner.query(sql);
} catch {
// Existing MySQL indexes cannot use IF NOT EXISTS; startup must stay idempotent.
}
};
const uniqueSn = refreshed?.indices.some((index) => index.columnNames.length === 1 && index.columnNames[0] === 'device_sn' && index.isUnique);
if (!uniqueSn) {
await createIndex(
isMySQL
? 'CREATE UNIQUE INDEX idx_attendance_devices_device_sn ON attendance_devices (device_sn)'
: 'CREATE UNIQUE INDEX IF NOT EXISTS idx_attendance_devices_device_sn ON attendance_devices (device_sn)',
);
}
await createIndex(
isMySQL
? 'CREATE INDEX idx_attendance_devices_classroom_id ON attendance_devices (classroom_id)'
: 'CREATE INDEX IF NOT EXISTS idx_attendance_devices_classroom_id ON attendance_devices (classroom_id)',
);
} finally {
await runner.release();
}
}
private async ensureStudentWalletSchema(): Promise<void> {
const runner = this.dataSource.createQueryRunner();
await runner.connect();
@@ -456,25 +515,57 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
private async normalizeClassDates(): Promise<void> {
const driver = this.dataSource.options.type;
const dateExpression = (column: string) =>
driver === 'mysql' ? `DATE(${column})` : `substr(${column}, 1, 10)`;
let columns: Array<'start_date' | 'end_date'> = ['start_date', 'end_date'];
const runner = this.dataSource.createQueryRunner();
await runner.connect();
try {
const table = await runner.getTable('classes');
if (!table) return;
// Fresh MySQL schemas created by TypeORM already use native DATE columns.
// This cleanup is only for legacy schemas that stored dates as strings;
// comparing a native DATE column with '' raises ER_TRUNCATED_WRONG_VALUE
// in strict SQL mode.
if (driver === 'mysql') {
columns = columns.filter((columnName) => {
const column = table.columns.find((item) => item.name === columnName);
const type = String(column?.type ?? '').toLowerCase();
return !['date', 'datetime', 'timestamp'].includes(type);
});
if (columns.length === 0) return;
}
} finally {
await runner.release();
}
const columnText = (column: string) =>
driver === 'mysql' ? `CAST(${column} AS CHAR)` : column;
const firstTenChars = (column: string) =>
driver === 'mysql'
? `NULLIF(LEFT(${columnText(column)}, 10), '')`
: `NULLIF(substr(${column}, 1, 10), '')`;
const normalizedDate = (column: string) => `CASE
WHEN ${column} IS NULL THEN NULL
ELSE ${firstTenChars(column)}
END`;
const lengthFunction = driver === 'mysql' ? 'CHAR_LENGTH' : 'length';
const needsNormalization = (column: string) => `(
${column} IS NOT NULL
AND (${columnText(column)} = '' OR ${lengthFunction}(${columnText(column)}) > 10)
)`;
const assignments = columns
.map((column) => `${column} = ${normalizedDate(column)}`)
.join(',\n ');
const predicates = columns.map((column) => needsNormalization(column)).join('\n OR ');
const result = await this.dataSource.transaction((manager) =>
manager.query(`
UPDATE classes
SET
start_date = CASE
WHEN start_date IS NULL OR start_date = '' THEN start_date
ELSE ${dateExpression('start_date')}
END,
end_date = CASE
WHEN end_date IS NULL OR end_date = '' THEN end_date
ELSE ${dateExpression('end_date')}
END
${assignments}
WHERE
(start_date IS NOT NULL AND ${lengthFunction}(start_date) > 10)
OR (end_date IS NOT NULL AND ${lengthFunction}(end_date) > 10)
${predicates}
`),
);