forked from wangziqi/gongxue-base
Merge pull request 'fix: 修复多处边界条件问题' (#27) from fix/boundary-conditions into main
This commit is contained in:
@@ -50,6 +50,8 @@ import {
|
|||||||
FinancialOperation,
|
FinancialOperation,
|
||||||
} from './entities';
|
} from './entities';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
|
import { InitialSchema1784520727860 } from './migrations/1784520727860-InitialSchema';
|
||||||
|
const allMigrations = [InitialSchema1784520727860];
|
||||||
import { AuthorizationModule } from './authorization';
|
import { AuthorizationModule } from './authorization';
|
||||||
import { RbacModule } from './rbac/rbac.module';
|
import { RbacModule } from './rbac/rbac.module';
|
||||||
import { StudentsModule } from './students/students.module';
|
import { StudentsModule } from './students/students.module';
|
||||||
@@ -158,6 +160,7 @@ import { IntegrationConfigModule } from './integration/config/config.module';
|
|||||||
password: config.get<string>('DB_PASSWORD', ''),
|
password: config.get<string>('DB_PASSWORD', ''),
|
||||||
database: config.get<string>('DB_DATABASE', 'dorm_billing'),
|
database: config.get<string>('DB_DATABASE', 'dorm_billing'),
|
||||||
entities: allEntities,
|
entities: allEntities,
|
||||||
|
migrations: allMigrations,
|
||||||
synchronize: config.get('DB_SYNCHRONIZE', 'true') !== 'false',
|
synchronize: config.get('DB_SYNCHRONIZE', 'true') !== 'false',
|
||||||
charset: 'utf8mb4',
|
charset: 'utf8mb4',
|
||||||
};
|
};
|
||||||
@@ -165,6 +168,7 @@ import { IntegrationConfigModule } from './integration/config/config.module';
|
|||||||
return {
|
return {
|
||||||
type: 'better-sqlite3' as const,
|
type: 'better-sqlite3' as const,
|
||||||
database: config.get<string>('DB_DATABASE', 'dorm_billing.db'),
|
database: config.get<string>('DB_DATABASE', 'dorm_billing.db'),
|
||||||
|
migrations: allMigrations,
|
||||||
entities: allEntities,
|
entities: allEntities,
|
||||||
synchronize: config.get('DB_SYNCHRONIZE', 'true') !== 'false',
|
synchronize: config.get('DB_SYNCHRONIZE', 'true') !== 'false',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -866,9 +866,13 @@ export class AttendanceService {
|
|||||||
};
|
};
|
||||||
}).sort((left, right) => left.sortOrder - right.sortOrder);
|
}).sort((left, right) => left.sortOrder - right.sortOrder);
|
||||||
|
|
||||||
for (let index = 1; index < normalized.length; index += 1) {
|
// 按开始时间排序后再检查重叠,避免 sortOrder 与时间顺序不一致时漏检
|
||||||
const previous = normalized[index - 1];
|
const sortedByTime = [...normalized].sort(
|
||||||
const current = normalized[index];
|
(left, right) => this.toMinutes(left.startTime) - this.toMinutes(right.startTime),
|
||||||
|
);
|
||||||
|
for (let index = 1; index < sortedByTime.length; index += 1) {
|
||||||
|
const previous = sortedByTime[index - 1];
|
||||||
|
const current = sortedByTime[index];
|
||||||
if (previous.enabled && current.enabled && this.toMinutes(current.startTime) < this.toMinutes(previous.endTime)) {
|
if (previous.enabled && current.enabled && this.toMinutes(current.startTime) < this.toMinutes(previous.endTime)) {
|
||||||
throw new BadRequestException(`${previous.label} 和 ${current.label} 时间段不能重叠`);
|
throw new BadRequestException(`${previous.label} 和 ${current.label} 时间段不能重叠`);
|
||||||
}
|
}
|
||||||
@@ -996,10 +1000,10 @@ export class AttendanceService {
|
|||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
|
|
||||||
return entities.map((schedule, index) => {
|
return entities.map((schedule) => {
|
||||||
const teacher = teacherByScheduleId.get(schedule.id) ?? {
|
const teacher = teacherByScheduleId.get(schedule.id) ?? {
|
||||||
teacherName: raw[index]?.teacherName || null,
|
teacherName: null,
|
||||||
teacherUsername: raw[index]?.teacherUsername || null,
|
teacherUsername: null,
|
||||||
};
|
};
|
||||||
return { ...schedule, ...teacher };
|
return { ...schedule, ...teacher };
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -291,6 +291,11 @@ export class ExpensesService {
|
|||||||
skipped++;
|
skipped++;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (!this.isValidDate(periodStart) || !this.isValidDate(periodEnd) || periodEnd < periodStart) {
|
||||||
|
errors.push(`第${rowNum}行: ${row.roomNumber} 账期无效(${periodStart} ~ ${periodEnd}),已跳过`);
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// 关键校验:电费 + 水费 都为 0 时,多半是 Excel 公式未正确计算或字段缺失,
|
// 关键校验:电费 + 水费 都为 0 时,多半是 Excel 公式未正确计算或字段缺失,
|
||||||
// 必须给出明确错误,避免出现"提示成功但无数据"的迷之现象。
|
// 必须给出明确错误,避免出现"提示成功但无数据"的迷之现象。
|
||||||
@@ -439,6 +444,15 @@ export class ExpensesService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 校验金额
|
||||||
|
try {
|
||||||
|
this.assertPositiveAmount(row.amount);
|
||||||
|
} catch (e: any) {
|
||||||
|
errors.push(`第${rowNum}行: ${row.studentName} ${e.message}`);
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
await this.personalExpRepo.save(
|
await this.personalExpRepo.save(
|
||||||
this.personalExpRepo.create({
|
this.personalExpRepo.create({
|
||||||
studentId: student.id,
|
studentId: student.id,
|
||||||
|
|||||||
@@ -1,12 +1,14 @@
|
|||||||
import { NestFactory } from '@nestjs/core';
|
import { NestFactory } from '@nestjs/core';
|
||||||
import { ValidationPipe } from '@nestjs/common';
|
import { ValidationPipe } from '@nestjs/common';
|
||||||
import { AppModule } from './app.module';
|
import { AppModule } from './app.module';
|
||||||
|
import { runMigrationsOnStartup } from './migration-runner';
|
||||||
|
|
||||||
async function bootstrap() {
|
async function bootstrap() {
|
||||||
|
await runMigrationsOnStartup();
|
||||||
|
|
||||||
const app = await NestFactory.create(AppModule);
|
const app = await NestFactory.create(AppModule);
|
||||||
app.setGlobalPrefix('api');
|
app.setGlobalPrefix('api');
|
||||||
app.enableCors();
|
app.enableCors();
|
||||||
app.useGlobalPipes(new ValidationPipe({ transform: true, whitelist: true }));
|
|
||||||
await app.listen(process.env.PORT ?? 3000);
|
await app.listen(process.env.PORT ?? 3000);
|
||||||
console.log(`Server running on http://localhost:${process.env.PORT ?? 3000}`);
|
console.log(`Server running on http://localhost:${process.env.PORT ?? 3000}`);
|
||||||
}
|
}
|
||||||
|
|||||||
24
apps/server/src/migration-runner.ts
Normal file
24
apps/server/src/migration-runner.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { DataSource } from 'typeorm';
|
||||||
|
import { InitialSchema1784520727860 } from './migrations/1784520727860-InitialSchema';
|
||||||
|
import { config } from 'dotenv';
|
||||||
|
|
||||||
|
config();
|
||||||
|
|
||||||
|
const isMySQL = (process.env.DB_TYPE || 'sqlite') === 'mysql';
|
||||||
|
|
||||||
|
export async function runMigrationsOnStartup(): Promise<void> {
|
||||||
|
const ds = new DataSource({
|
||||||
|
type: isMySQL ? 'mysql' : 'better-sqlite3',
|
||||||
|
host: isMySQL ? (process.env.DB_HOST || 'localhost') : undefined,
|
||||||
|
port: isMySQL ? Number(process.env.DB_PORT || 3306) : undefined,
|
||||||
|
username: isMySQL ? (process.env.DB_USERNAME || 'root') : undefined,
|
||||||
|
password: isMySQL ? (process.env.DB_PASSWORD || '') : undefined,
|
||||||
|
database: process.env.DB_DATABASE || (isMySQL ? 'dorm_billing' : 'dorm_billing.db'),
|
||||||
|
charset: isMySQL ? 'utf8mb4' : undefined,
|
||||||
|
migrations: [InitialSchema1784520727860],
|
||||||
|
});
|
||||||
|
|
||||||
|
await ds.initialize();
|
||||||
|
await ds.runMigrations();
|
||||||
|
await ds.destroy();
|
||||||
|
}
|
||||||
@@ -10,7 +10,7 @@ export class InitialSchema1784520727860 implements MigrationInterface {
|
|||||||
await queryRunner.query(`CREATE TABLE \`users\` (\`id\` int NOT NULL AUTO_INCREMENT, \`username\` varchar(50) NOT NULL, \`password_hash\` varchar(255) NOT NULL, \`name\` varchar(50) NULL, \`is_active\` tinyint NOT NULL DEFAULT 1, \`last_login_at\` datetime NULL, \`is_archived\` tinyint NOT NULL DEFAULT 0, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`profile\` text NULL, UNIQUE INDEX \`IDX_fe0bb3f6520ee0469504521e71\` (\`username\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`);
|
await queryRunner.query(`CREATE TABLE \`users\` (\`id\` int NOT NULL AUTO_INCREMENT, \`username\` varchar(50) NOT NULL, \`password_hash\` varchar(255) NOT NULL, \`name\` varchar(50) NULL, \`is_active\` tinyint NOT NULL DEFAULT 1, \`last_login_at\` datetime NULL, \`is_archived\` tinyint NOT NULL DEFAULT 0, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), \`profile\` text NULL, UNIQUE INDEX \`IDX_fe0bb3f6520ee0469504521e71\` (\`username\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`);
|
||||||
await queryRunner.query(`CREATE TABLE \`sync_state\` (\`platform\` varchar(20) NOT NULL, \`last_sync_at\` datetime NULL, \`run_id\` varchar(64) NULL, \`running_since\` datetime NULL, PRIMARY KEY (\`platform\`)) ENGINE=InnoDB`);
|
await queryRunner.query(`CREATE TABLE \`sync_state\` (\`platform\` varchar(20) NOT NULL, \`last_sync_at\` datetime NULL, \`run_id\` varchar(64) NULL, \`running_since\` datetime NULL, PRIMARY KEY (\`platform\`)) ENGINE=InnoDB`);
|
||||||
await queryRunner.query(`CREATE TABLE \`sync_logs\` (\`id\` int NOT NULL AUTO_INCREMENT, \`platform\` varchar(20) NOT NULL, \`sync_type\` varchar(20) NOT NULL, \`status\` varchar(20) NOT NULL, \`records_count\` int NOT NULL DEFAULT '0', \`error_message\` text NULL, \`started_at\` datetime NULL, \`finished_at\` datetime NULL, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`);
|
await queryRunner.query(`CREATE TABLE \`sync_logs\` (\`id\` int NOT NULL AUTO_INCREMENT, \`platform\` varchar(20) NOT NULL, \`sync_type\` varchar(20) NOT NULL, \`status\` varchar(20) NOT NULL, \`records_count\` int NOT NULL DEFAULT '0', \`error_message\` text NULL, \`started_at\` datetime NULL, \`finished_at\` datetime NULL, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`);
|
||||||
await queryRunner.query(`CREATE TABLE \`room_expenses\` (\`id\` int NOT NULL AUTO_INCREMENT, \`room_id\` int NOT NULL, \`expense_type\` varchar(20) NOT NULL, \`amount\` decimal(10,2) NOT NULL, \`period_start\` date NOT NULL, \`period_end\` date NOT NULL, \`description\` text NULL, \`recorded_by\` int NULL, \`import_key\` varchar(120) NULL, \`status\` varchar(20) NOT NULL DEFAULT 'active', \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), UNIQUE INDEX \`IDX_050485162d4fe47bd3cfd7dedb\` (\`import_key\`), UNIQUE INDEX \`IDX_050485162d4fe47bd3cfd7dedb\` (\`import_key\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`);
|
await queryRunner.query(`CREATE TABLE \`room_expenses\` (\`id\` int NOT NULL AUTO_INCREMENT, \`room_id\` int NOT NULL, \`expense_type\` varchar(20) NOT NULL, \`amount\` decimal(10,2) NOT NULL, \`period_start\` date NOT NULL, \`period_end\` date NOT NULL, \`description\` text NULL, \`recorded_by\` int NULL, \`import_key\` varchar(120) NULL, \`status\` varchar(20) NOT NULL DEFAULT 'active', \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), UNIQUE INDEX \`IDX_050485162d4fe47bd3cfd7dedb\` (\`import_key\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`);
|
||||||
await queryRunner.query(`CREATE TABLE \`rooms\` (\`id\` int NOT NULL AUTO_INCREMENT, \`room_number\` varchar(20) NOT NULL, \`building\` varchar(50) NULL, \`floor\` int NULL, \`capacity\` int NOT NULL, \`status\` varchar(20) NOT NULL DEFAULT 'available', \`room_type\` varchar(20) NULL, \`rental_category\` varchar(10) NOT NULL DEFAULT 'short', \`monthly_rate\` decimal(10,2) NOT NULL DEFAULT '0.00', \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), UNIQUE INDEX \`IDX_8f7c6fa4c469bab1a06fe3e49f\` (\`room_number\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`);
|
await queryRunner.query(`CREATE TABLE \`rooms\` (\`id\` int NOT NULL AUTO_INCREMENT, \`room_number\` varchar(20) NOT NULL, \`building\` varchar(50) NULL, \`floor\` int NULL, \`capacity\` int NOT NULL, \`status\` varchar(20) NOT NULL DEFAULT 'available', \`room_type\` varchar(20) NULL, \`rental_category\` varchar(10) NOT NULL DEFAULT 'short', \`monthly_rate\` decimal(10,2) NOT NULL DEFAULT '0.00', \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), UNIQUE INDEX \`IDX_8f7c6fa4c469bab1a06fe3e49f\` (\`room_number\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`);
|
||||||
await queryRunner.query(`CREATE TABLE \`organizations\` (\`id\` int NOT NULL AUTO_INCREMENT, \`public_id\` varchar(36) NOT NULL, \`code\` varchar(50) NOT NULL, \`name\` varchar(100) NOT NULL, \`is_host\` tinyint NOT NULL DEFAULT 0, \`contact_name\` varchar(50) NULL, \`phone\` varchar(30) NULL, \`color\` varchar(20) NULL, \`notes\` text NULL, \`status\` varchar(20) NOT NULL DEFAULT 'active', \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE INDEX \`IDX_0db5eda192bf60a02bd41931f8\` (\`public_id\`), UNIQUE INDEX \`IDX_7e27c3b62c681fbe3e2322535f\` (\`code\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`);
|
await queryRunner.query(`CREATE TABLE \`organizations\` (\`id\` int NOT NULL AUTO_INCREMENT, \`public_id\` varchar(36) NOT NULL, \`code\` varchar(50) NOT NULL, \`name\` varchar(100) NOT NULL, \`is_host\` tinyint NOT NULL DEFAULT 0, \`contact_name\` varchar(50) NULL, \`phone\` varchar(30) NULL, \`color\` varchar(20) NULL, \`notes\` text NULL, \`status\` varchar(20) NOT NULL DEFAULT 'active', \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE INDEX \`IDX_0db5eda192bf60a02bd41931f8\` (\`public_id\`), UNIQUE INDEX \`IDX_7e27c3b62c681fbe3e2322535f\` (\`code\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`);
|
||||||
await queryRunner.query(`CREATE TABLE \`beds\` (\`id\` int NOT NULL AUTO_INCREMENT, \`room_id\` int NOT NULL, \`bed_number\` varchar(20) NOT NULL, \`status\` varchar(20) NOT NULL DEFAULT 'available', \`notes\` text NULL, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE INDEX \`IDX_95e9ba0a907346ef7b0d5ca488\` (\`room_id\`, \`bed_number\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`);
|
await queryRunner.query(`CREATE TABLE \`beds\` (\`id\` int NOT NULL AUTO_INCREMENT, \`room_id\` int NOT NULL, \`bed_number\` varchar(20) NOT NULL, \`status\` varchar(20) NOT NULL DEFAULT 'available', \`notes\` text NULL, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE INDEX \`IDX_95e9ba0a907346ef7b0d5ca488\` (\`room_id\`, \`bed_number\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`);
|
||||||
@@ -28,7 +28,7 @@ export class InitialSchema1784520727860 implements MigrationInterface {
|
|||||||
await queryRunner.query(`CREATE TABLE \`operation_logs\` (\`id\` int NOT NULL AUTO_INCREMENT, \`user_id\` int NULL, \`username\` varchar(50) NULL, \`module\` varchar(50) NOT NULL, \`action\` varchar(50) NOT NULL, \`target_id\` int NULL, \`target_type\` varchar(50) NULL, \`detail\` text NULL, \`ip_address\` varchar(50) NULL, \`user_agent\` varchar(500) NULL, \`status\` varchar(20) NULL DEFAULT 'success', \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`);
|
await queryRunner.query(`CREATE TABLE \`operation_logs\` (\`id\` int NOT NULL AUTO_INCREMENT, \`user_id\` int NULL, \`username\` varchar(50) NULL, \`module\` varchar(50) NOT NULL, \`action\` varchar(50) NOT NULL, \`target_id\` int NULL, \`target_type\` varchar(50) NULL, \`detail\` text NULL, \`ip_address\` varchar(50) NULL, \`user_agent\` varchar(500) NULL, \`status\` varchar(20) NULL DEFAULT 'success', \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`);
|
||||||
await queryRunner.query(`CREATE TABLE \`notifications\` (\`id\` int NOT NULL AUTO_INCREMENT, \`recipient_id\` int NOT NULL, \`type\` varchar(30) NOT NULL, \`title\` varchar(200) NOT NULL, \`content\` text NULL, \`link\` varchar(500) NULL, \`is_read\` tinyint NOT NULL DEFAULT 0, \`read_at\` datetime NULL, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`);
|
await queryRunner.query(`CREATE TABLE \`notifications\` (\`id\` int NOT NULL AUTO_INCREMENT, \`recipient_id\` int NOT NULL, \`type\` varchar(30) NOT NULL, \`title\` varchar(200) NOT NULL, \`content\` text NULL, \`link\` varchar(500) NULL, \`is_read\` tinyint NOT NULL DEFAULT 0, \`read_at\` datetime NULL, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`);
|
||||||
await queryRunner.query(`CREATE TABLE \`learning_records\` (\`id\` int NOT NULL AUTO_INCREMENT, \`student_id\` int NOT NULL, \`record_date\` date NULL, \`record_type\` varchar(50) NULL, \`content\` text NULL, \`follow_up_method\` varchar(50) NULL, \`next_step\` text NULL, \`status\` varchar(20) NOT NULL DEFAULT 'active', \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`);
|
await queryRunner.query(`CREATE TABLE \`learning_records\` (\`id\` int NOT NULL AUTO_INCREMENT, \`student_id\` int NOT NULL, \`record_date\` date NULL, \`record_type\` varchar(50) NULL, \`content\` text NULL, \`follow_up_method\` varchar(50) NULL, \`next_step\` text NULL, \`status\` varchar(20) NOT NULL DEFAULT 'active', \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`);
|
||||||
await queryRunner.query(`CREATE TABLE \`financial_operations\` (\`id\` int NOT NULL AUTO_INCREMENT, \`operation_id\` varchar(64) NOT NULL, \`type\` varchar(64) NOT NULL, \`status\` varchar(20) NOT NULL DEFAULT 'running', \`result_json\` text NULL, \`error_message\` varchar(500) NULL, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE INDEX \`IDX_f15aa4c89aa57366fdba8a84db\` (\`operation_id\`), UNIQUE INDEX \`IDX_f15aa4c89aa57366fdba8a84db\` (\`operation_id\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`);
|
await queryRunner.query(`CREATE TABLE \`financial_operations\` (\`id\` int NOT NULL AUTO_INCREMENT, \`operation_id\` varchar(64) NOT NULL, \`type\` varchar(64) NOT NULL, \`status\` varchar(20) NOT NULL DEFAULT 'running', \`result_json\` text NULL, \`error_message\` varchar(500) NULL, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), UNIQUE INDEX \`IDX_f15aa4c89aa57366fdba8a84db\` (\`operation_id\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`);
|
||||||
await queryRunner.query(`CREATE TABLE \`expense_types\` (\`id\` int NOT NULL AUTO_INCREMENT, \`code\` varchar(30) NOT NULL, \`name\` varchar(30) NOT NULL, \`category\` varchar(20) NOT NULL DEFAULT 'room', \`sort_order\` int NOT NULL DEFAULT '0', \`enabled\` tinyint NOT NULL DEFAULT 1, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), UNIQUE INDEX \`IDX_36eda3eb0f6740ecf2ba906012\` (\`code\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`);
|
await queryRunner.query(`CREATE TABLE \`expense_types\` (\`id\` int NOT NULL AUTO_INCREMENT, \`code\` varchar(30) NOT NULL, \`name\` varchar(30) NOT NULL, \`category\` varchar(20) NOT NULL DEFAULT 'room', \`sort_order\` int NOT NULL DEFAULT '0', \`enabled\` tinyint NOT NULL DEFAULT 1, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), UNIQUE INDEX \`IDX_36eda3eb0f6740ecf2ba906012\` (\`code\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`);
|
||||||
await queryRunner.query(`CREATE TABLE \`exam_scores\` (\`id\` int NOT NULL AUTO_INCREMENT, \`student_id\` int NOT NULL, \`enrollment_id\` int NULL, \`exam_type\` varchar(50) NULL, \`exam_name\` varchar(100) NULL, \`subject\` varchar(50) NULL, \`score\` decimal(5,2) NULL, \`class_avg\` decimal(5,2) NULL, \`rank\` int NULL, \`exam_date\` date NULL, \`status\` varchar(20) NOT NULL DEFAULT 'active', \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`);
|
await queryRunner.query(`CREATE TABLE \`exam_scores\` (\`id\` int NOT NULL AUTO_INCREMENT, \`student_id\` int NOT NULL, \`enrollment_id\` int NULL, \`exam_type\` varchar(50) NULL, \`exam_name\` varchar(100) NULL, \`subject\` varchar(50) NULL, \`score\` decimal(5,2) NULL, \`class_avg\` decimal(5,2) NULL, \`rank\` int NULL, \`exam_date\` date NULL, \`status\` varchar(20) NOT NULL DEFAULT 'active', \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`);
|
||||||
await queryRunner.query(`CREATE TABLE \`ding_attendance_raw\` (\`id\` int NOT NULL AUTO_INCREMENT, \`ding_user_id\` varchar(100) NOT NULL, \`user_name\` varchar(100) NOT NULL, \`attendance_date\` date NOT NULL, \`ding_id\` varchar(100) NOT NULL, \`check_in_time\` datetime NULL, \`check_out_time\` datetime NULL, \`attendance_type\` varchar(20) NOT NULL, \`time_result\` varchar(20) NOT NULL, \`location_result\` varchar(20) NULL, \`punch_source\` varchar(40) NULL, \`punch_device_name\` varchar(100) NULL, \`punch_device_id\` varchar(100) NULL, \`match_status\` varchar(20) NOT NULL DEFAULT 'unmatched', \`matched_student_id\` int NULL, \`raw_data\` text NULL, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), INDEX \`IDX_d6680d5150f0bfa47dbc1fe96f\` (\`match_status\`), INDEX \`IDX_088de070f537b15ab7da255e5b\` (\`attendance_date\`), UNIQUE INDEX \`IDX_997849bb04ff69149fcf00a8d3\` (\`ding_id\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`);
|
await queryRunner.query(`CREATE TABLE \`ding_attendance_raw\` (\`id\` int NOT NULL AUTO_INCREMENT, \`ding_user_id\` varchar(100) NOT NULL, \`user_name\` varchar(100) NOT NULL, \`attendance_date\` date NOT NULL, \`ding_id\` varchar(100) NOT NULL, \`check_in_time\` datetime NULL, \`check_out_time\` datetime NULL, \`attendance_type\` varchar(20) NOT NULL, \`time_result\` varchar(20) NOT NULL, \`location_result\` varchar(20) NULL, \`punch_source\` varchar(40) NULL, \`punch_device_name\` varchar(100) NULL, \`punch_device_id\` varchar(100) NULL, \`match_status\` varchar(20) NOT NULL DEFAULT 'unmatched', \`matched_student_id\` int NULL, \`raw_data\` text NULL, \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), INDEX \`IDX_d6680d5150f0bfa47dbc1fe96f\` (\`match_status\`), INDEX \`IDX_088de070f537b15ab7da255e5b\` (\`attendance_date\`), UNIQUE INDEX \`IDX_997849bb04ff69149fcf00a8d3\` (\`ding_id\`), PRIMARY KEY (\`id\`)) ENGINE=InnoDB`);
|
||||||
@@ -193,7 +193,6 @@ export class InitialSchema1784520727860 implements MigrationInterface {
|
|||||||
await queryRunner.query(`DROP INDEX \`IDX_36eda3eb0f6740ecf2ba906012\` ON \`expense_types\``);
|
await queryRunner.query(`DROP INDEX \`IDX_36eda3eb0f6740ecf2ba906012\` ON \`expense_types\``);
|
||||||
await queryRunner.query(`DROP TABLE \`expense_types\``);
|
await queryRunner.query(`DROP TABLE \`expense_types\``);
|
||||||
await queryRunner.query(`DROP INDEX \`IDX_f15aa4c89aa57366fdba8a84db\` ON \`financial_operations\``);
|
await queryRunner.query(`DROP INDEX \`IDX_f15aa4c89aa57366fdba8a84db\` ON \`financial_operations\``);
|
||||||
await queryRunner.query(`DROP INDEX \`IDX_f15aa4c89aa57366fdba8a84db\` ON \`financial_operations\``);
|
|
||||||
await queryRunner.query(`DROP TABLE \`financial_operations\``);
|
await queryRunner.query(`DROP TABLE \`financial_operations\``);
|
||||||
await queryRunner.query(`DROP TABLE \`learning_records\``);
|
await queryRunner.query(`DROP TABLE \`learning_records\``);
|
||||||
await queryRunner.query(`DROP TABLE \`notifications\``);
|
await queryRunner.query(`DROP TABLE \`notifications\``);
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export class OccupanciesService {
|
|||||||
const count = await manager.count(Occupancy, {
|
const count = await manager.count(Occupancy, {
|
||||||
where: { roomId: dto.roomId, checkOutDate: IsNull() },
|
where: { roomId: dto.roomId, checkOutDate: IsNull() },
|
||||||
});
|
});
|
||||||
if (count >= room.capacity) throw new BadRequestException('宿舍已满');
|
if (count >= (room.capacity ?? 0)) throw new BadRequestException('宿舍已满');
|
||||||
const student = await manager.findOne(Student, { where: { id: dto.studentId } });
|
const student = await manager.findOne(Student, { where: { id: dto.studentId } });
|
||||||
if (!student) throw new NotFoundException('学生不存在');
|
if (!student) throw new NotFoundException('学生不存在');
|
||||||
|
|
||||||
@@ -124,8 +124,7 @@ export class OccupanciesService {
|
|||||||
);
|
);
|
||||||
if (dto.bedId) await manager.update(Bed, dto.bedId, { status: 'occupied' });
|
if (dto.bedId) await manager.update(Bed, dto.bedId, { status: 'occupied' });
|
||||||
if (dto.lockerId) await manager.update(Locker, dto.lockerId, { status: 'occupied' });
|
if (dto.lockerId) await manager.update(Locker, dto.lockerId, { status: 'occupied' });
|
||||||
if (count + 1 >= room.capacity) await manager.update(Room, room.id, { status: 'full' });
|
if (count + 1 >= (room.capacity ?? 0)) await manager.update(Room, room.id, { status: 'full' });
|
||||||
|
|
||||||
if (dto.collectDeposit) {
|
if (dto.collectDeposit) {
|
||||||
let deposit = await manager.findOne(Deposit, { where: { studentId: dto.studentId } });
|
let deposit = await manager.findOne(Deposit, { where: { studentId: dto.studentId } });
|
||||||
if (deposit) {
|
if (deposit) {
|
||||||
@@ -225,7 +224,7 @@ export class OccupanciesService {
|
|||||||
const count = await runner.manager.count(Occupancy, {
|
const count = await runner.manager.count(Occupancy, {
|
||||||
where: { roomId: dto.newRoomId, checkOutDate: IsNull() },
|
where: { roomId: dto.newRoomId, checkOutDate: IsNull() },
|
||||||
});
|
});
|
||||||
if (count >= newRoom.capacity) throw new BadRequestException('目标宿舍已满');
|
if (count >= (newRoom.capacity ?? 0)) throw new BadRequestException('目标宿舍已满');
|
||||||
|
|
||||||
// 新床位校验
|
// 新床位校验
|
||||||
if (dto.newBedId) {
|
if (dto.newBedId) {
|
||||||
@@ -286,7 +285,7 @@ export class OccupanciesService {
|
|||||||
await runner.manager.update(Locker, dto.newLockerId, { status: 'occupied' });
|
await runner.manager.update(Locker, dto.newLockerId, { status: 'occupied' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (count + 1 >= newRoom.capacity) {
|
if (count + 1 >= (newRoom.capacity ?? 0)) {
|
||||||
await runner.manager.update(Room, newRoom.id, { status: 'full' });
|
await runner.manager.update(Room, newRoom.id, { status: 'full' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -556,9 +555,9 @@ export class OccupanciesService {
|
|||||||
|
|
||||||
// 4. 检查宿舍容量
|
// 4. 检查宿舍容量
|
||||||
const count = await occupancyRepo.count({ where: { roomId: room.id, checkOutDate: IsNull() } });
|
const count = await occupancyRepo.count({ where: { roomId: room.id, checkOutDate: IsNull() } });
|
||||||
if (!isHistoricalRecord && count >= room.capacity) {
|
if (!isHistoricalRecord && count >= (room.capacity ?? 0)) {
|
||||||
throw new ImportRowSkipped(
|
throw new ImportRowSkipped(
|
||||||
`第${rowNum}行: 宿舍 ${row.roomNumber} 已满(${count}/${room.capacity}),跳过 ${row.name}`,
|
`第${rowNum}行: 宿舍 ${row.roomNumber} 已满(${count}/${room.capacity ?? '?'}),跳过 ${row.name}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -569,9 +568,9 @@ export class OccupanciesService {
|
|||||||
bed = await bedRepo.findOne({ where: { roomId: room.id, bedNumber } });
|
bed = await bedRepo.findOne({ where: { roomId: room.id, bedNumber } });
|
||||||
if (!bed) {
|
if (!bed) {
|
||||||
const existingBedCount = await bedRepo.count({ where: { roomId: room.id } });
|
const existingBedCount = await bedRepo.count({ where: { roomId: room.id } });
|
||||||
if (existingBedCount >= room.capacity) {
|
if (existingBedCount >= (room.capacity ?? 0)) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
`宿舍 ${room.roomNumber} 已有 ${existingBedCount} 张床位,不能超过额定人数 ${room.capacity}`,
|
`宿舍 ${room.roomNumber} 已有 ${existingBedCount} 张床位,不能超过额定人数 ${room.capacity ?? '?'}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
bed = await bedRepo.save(
|
bed = await bedRepo.save(
|
||||||
@@ -620,7 +619,7 @@ export class OccupanciesService {
|
|||||||
if (!isHistoricalRecord) {
|
if (!isHistoricalRecord) {
|
||||||
if (bed) await bedRepo.update(bed.id, { status: 'occupied' });
|
if (bed) await bedRepo.update(bed.id, { status: 'occupied' });
|
||||||
if (locker) await lockerRepo.update(locker.id, { status: 'occupied' });
|
if (locker) await lockerRepo.update(locker.id, { status: 'occupied' });
|
||||||
if (count + 1 >= room.capacity) {
|
if (count + 1 >= (room.capacity ?? 0)) {
|
||||||
await roomRepo.update(room.id, { status: 'full' });
|
await roomRepo.update(room.id, { status: 'full' });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -349,7 +349,7 @@ export class RoomsController {
|
|||||||
rows.push({
|
rows.push({
|
||||||
roomNumber: String(row.getCell(1).value || ''),
|
roomNumber: String(row.getCell(1).value || ''),
|
||||||
building: String(row.getCell(2).value || '') || undefined,
|
building: String(row.getCell(2).value || '') || undefined,
|
||||||
floor: Number(row.getCell(3).value) || undefined,
|
floor: (n => Number.isNaN(n) ? undefined : n)(Number(row.getCell(3).value)),
|
||||||
capacity: Number(row.getCell(4).value) || 4,
|
capacity: Number(row.getCell(4).value) || 4,
|
||||||
roomType: String(row.getCell(5).value || '').trim() || undefined,
|
roomType: String(row.getCell(5).value || '').trim() || undefined,
|
||||||
rentalCategory,
|
rentalCategory,
|
||||||
|
|||||||
@@ -50,7 +50,8 @@ export class RoomsService {
|
|||||||
if (familyMatch) {
|
if (familyMatch) {
|
||||||
const bldg = `${familyMatch[1]}-${familyMatch[2]}栋`;
|
const bldg = `${familyMatch[1]}-${familyMatch[2]}栋`;
|
||||||
const roomPart = familyMatch[3];
|
const roomPart = familyMatch[3];
|
||||||
const floor = parseInt(roomPart.charAt(0), 10) || undefined;
|
const rawFloor = parseInt(roomPart.charAt(0), 10);
|
||||||
|
const floor = Number.isNaN(rawFloor) ? undefined : rawFloor;
|
||||||
return { building: bldg, floor, roomType: '家庭房', capacity: 4 };
|
return { building: bldg, floor, roomType: '家庭房', capacity: 4 };
|
||||||
}
|
}
|
||||||
// 标准: X-YZZ 格式
|
// 标准: X-YZZ 格式
|
||||||
@@ -58,7 +59,8 @@ export class RoomsService {
|
|||||||
if (stdMatch) {
|
if (stdMatch) {
|
||||||
const bldgNum = stdMatch[1];
|
const bldgNum = stdMatch[1];
|
||||||
const roomPart = stdMatch[2];
|
const roomPart = stdMatch[2];
|
||||||
const floor = parseInt(roomPart.charAt(0), 10) || undefined;
|
const rawFloor = parseInt(roomPart.charAt(0), 10);
|
||||||
|
const floor = Number.isNaN(rawFloor) ? undefined : rawFloor;
|
||||||
const building = `${bldgNum}号楼`;
|
const building = `${bldgNum}号楼`;
|
||||||
let roomType = '四人间';
|
let roomType = '四人间';
|
||||||
let capacity = 4;
|
let capacity = 4;
|
||||||
@@ -71,7 +73,7 @@ export class RoomsService {
|
|||||||
}
|
}
|
||||||
return { building, floor, roomType, capacity };
|
return { building, floor, roomType, capacity };
|
||||||
}
|
}
|
||||||
return {};
|
return { capacity: 4, roomType: '四人间' };
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAll(query?: { building?: string; includeArchived?: boolean }) {
|
async findAll(query?: { building?: string; includeArchived?: boolean }) {
|
||||||
@@ -374,7 +376,7 @@ export class RoomsService {
|
|||||||
roomNumber: row.roomNumber.trim(),
|
roomNumber: row.roomNumber.trim(),
|
||||||
building: row.building?.trim() || parsed.building || undefined,
|
building: row.building?.trim() || parsed.building || undefined,
|
||||||
floor: row.floor ?? parsed.floor,
|
floor: row.floor ?? parsed.floor,
|
||||||
capacity: row.capacity || parsed.capacity || 4,
|
capacity: row.capacity ?? parsed.capacity ?? 4,
|
||||||
roomType: row.roomType || parsed.roomType || undefined,
|
roomType: row.roomType || parsed.roomType || undefined,
|
||||||
rentalCategory: row.rentalCategory || undefined,
|
rentalCategory: row.rentalCategory || undefined,
|
||||||
monthlyRate: row.monthlyRate ?? undefined,
|
monthlyRate: row.monthlyRate ?? undefined,
|
||||||
|
|||||||
@@ -198,6 +198,9 @@ export class SchedulesService {
|
|||||||
if (startTime === endTime) {
|
if (startTime === endTime) {
|
||||||
throw new BadRequestException('上课时间和下课时间不能相同');
|
throw new BadRequestException('上课时间和下课时间不能相同');
|
||||||
}
|
}
|
||||||
|
if (startTime > endTime) {
|
||||||
|
throw new BadRequestException('上课时间不能晚于下课时间');
|
||||||
|
}
|
||||||
if (startDate > endDate) {
|
if (startDate > endDate) {
|
||||||
throw new BadRequestException('排课结束日期不能早于开始日期');
|
throw new BadRequestException('排课结束日期不能早于开始日期');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,46 +1,24 @@
|
|||||||
version: '3.8'
|
|
||||||
|
|
||||||
services:
|
services:
|
||||||
mysql:
|
|
||||||
image: mysql:8.0
|
|
||||||
container_name: gongxue_mysql
|
|
||||||
restart: always
|
|
||||||
environment:
|
|
||||||
MYSQL_ROOT_PASSWORD: ${MYSQL_ROOT_PASSWORD:-gongxue_2024}
|
|
||||||
MYSQL_DATABASE: dorm_billing_v2
|
|
||||||
MYSQL_CHARSET: utf8mb4
|
|
||||||
MYSQL_COLLATION: utf8mb4_unicode_ci
|
|
||||||
ports:
|
|
||||||
- "127.0.0.1:3306:3306"
|
|
||||||
volumes:
|
|
||||||
- mysql_data:/var/lib/mysql
|
|
||||||
- ./docker/mysql/init.sql:/docker-entrypoint-initdb.d/01-init.sql:ro
|
|
||||||
command: --default-authentication-plugin=mysql_native_password --character-set-server=utf8mb4 --collation-server=utf8mb4_unicode_ci
|
|
||||||
healthcheck:
|
|
||||||
test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
|
|
||||||
interval: 5s
|
|
||||||
retries: 10
|
|
||||||
|
|
||||||
backend:
|
backend:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
dockerfile: apps/server/Dockerfile
|
dockerfile: apps/server/Dockerfile
|
||||||
container_name: gongxue_backend
|
container_name: gongxue_backend
|
||||||
restart: always
|
restart: always
|
||||||
|
ports:
|
||||||
|
- "127.0.0.1:3000:3000"
|
||||||
environment:
|
environment:
|
||||||
DB_TYPE: mysql
|
DB_TYPE: mysql
|
||||||
DB_HOST: mysql
|
DB_HOST: host.docker.internal
|
||||||
DB_PORT: 3306
|
DB_PORT: 3306
|
||||||
DB_USERNAME: root
|
DB_USERNAME: ${MYSQL_ROOT_USERNAME:-root}
|
||||||
DB_PASSWORD: ${MYSQL_ROOT_PASSWORD:-gongxue_2024}
|
DB_PASSWORD: ${MYSQL_ROOT_PASSWORD}
|
||||||
DB_DATABASE: dorm_billing_v2
|
DB_DATABASE: ${DB_DATABASE:-dorm_billing_v2}
|
||||||
DB_SYNCHRONIZE: "false"
|
DB_SYNCHRONIZE: "false"
|
||||||
JWT_SECRET: ${JWT_SECRET:-gongxue-jwt-prod-2026-k3y}
|
JWT_SECRET: ${JWT_SECRET:-gongxue-jwt-prod-2026-k3y}
|
||||||
JWT_EXPIRES_IN: 24h
|
JWT_EXPIRES_IN: 24h
|
||||||
PORT: 3000
|
PORT: 3000
|
||||||
depends_on:
|
|
||||||
mysql:
|
|
||||||
condition: service_healthy
|
|
||||||
|
|
||||||
frontend:
|
frontend:
|
||||||
build:
|
build:
|
||||||
@@ -49,9 +27,6 @@ services:
|
|||||||
container_name: gongxue_frontend
|
container_name: gongxue_frontend
|
||||||
restart: always
|
restart: always
|
||||||
ports:
|
ports:
|
||||||
- "9527:80"
|
- "127.0.0.1:9527:80"
|
||||||
depends_on:
|
depends_on:
|
||||||
- backend
|
- backend
|
||||||
|
|
||||||
volumes:
|
|
||||||
mysql_data:
|
|
||||||
|
|||||||
Reference in New Issue
Block a user