feat: add Schedules module with conflict detection and weekly view
This commit is contained in:
@@ -19,6 +19,7 @@ import {
|
|||||||
ClassroomRental,
|
ClassroomRental,
|
||||||
Permission,
|
Permission,
|
||||||
Role,
|
Role,
|
||||||
|
ClassSchedule,
|
||||||
} from './entities';
|
} from './entities';
|
||||||
import { AuthModule } from './auth/auth.module';
|
import { AuthModule } from './auth/auth.module';
|
||||||
import { RbacModule } from './rbac/rbac.module';
|
import { RbacModule } from './rbac/rbac.module';
|
||||||
@@ -34,6 +35,7 @@ import { DepositsModule } from './deposits/deposits.module';
|
|||||||
import { ClassroomsModule } from './classrooms/classrooms.module';
|
import { ClassroomsModule } from './classrooms/classrooms.module';
|
||||||
import { ClassesModule } from './classes/classes.module';
|
import { ClassesModule } from './classes/classes.module';
|
||||||
import { TenantsModule } from './tenants/tenants.module';
|
import { TenantsModule } from './tenants/tenants.module';
|
||||||
|
import { SchedulesModule } from './schedules/schedules.module';
|
||||||
import { ClassroomRentalsModule } from './classroom-rentals/classroom-rentals.module';
|
import { ClassroomRentalsModule } from './classroom-rentals/classroom-rentals.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -66,6 +68,7 @@ import { ClassroomRentalsModule } from './classroom-rentals/classroom-rentals.mo
|
|||||||
ClassroomRental,
|
ClassroomRental,
|
||||||
Permission,
|
Permission,
|
||||||
Role,
|
Role,
|
||||||
|
ClassSchedule,
|
||||||
];
|
];
|
||||||
if (dbType === 'mysql') {
|
if (dbType === 'mysql') {
|
||||||
return {
|
return {
|
||||||
@@ -101,6 +104,7 @@ import { ClassroomRentalsModule } from './classroom-rentals/classroom-rentals.mo
|
|||||||
ClassroomsModule,
|
ClassroomsModule,
|
||||||
ClassesModule,
|
ClassesModule,
|
||||||
TenantsModule,
|
TenantsModule,
|
||||||
|
SchedulesModule,
|
||||||
ClassroomRentalsModule,
|
ClassroomRentalsModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
|
|||||||
152
apps/server/src/schedules/dto/schedule.dto.ts
Normal file
152
apps/server/src/schedules/dto/schedule.dto.ts
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
import {
|
||||||
|
IsOptional,
|
||||||
|
IsString,
|
||||||
|
IsNotEmpty,
|
||||||
|
IsInt,
|
||||||
|
IsDateString,
|
||||||
|
Matches,
|
||||||
|
Min,
|
||||||
|
Max,
|
||||||
|
} from 'class-validator';
|
||||||
|
|
||||||
|
export class CreateScheduleDto {
|
||||||
|
@IsInt()
|
||||||
|
@IsNotEmpty()
|
||||||
|
classId: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@IsNotEmpty()
|
||||||
|
classroomId: number;
|
||||||
|
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(7)
|
||||||
|
@IsNotEmpty()
|
||||||
|
weekDay: number;
|
||||||
|
|
||||||
|
@Matches(/^\d{2}:\d{2}$/)
|
||||||
|
@IsNotEmpty()
|
||||||
|
startTime: string;
|
||||||
|
|
||||||
|
@Matches(/^\d{2}:\d{2}$/)
|
||||||
|
@IsNotEmpty()
|
||||||
|
endTime: string;
|
||||||
|
|
||||||
|
@IsDateString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
startDate: string;
|
||||||
|
|
||||||
|
@IsDateString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
endDate: string;
|
||||||
|
|
||||||
|
@IsString()
|
||||||
|
@IsNotEmpty()
|
||||||
|
subject: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
teacherId?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
scheduleType?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
rentalId?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class UpdateScheduleDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
classId?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
classroomId?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(7)
|
||||||
|
weekDay?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Matches(/^\d{2}:\d{2}$/)
|
||||||
|
startTime?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Matches(/^\d{2}:\d{2}$/)
|
||||||
|
endTime?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
startDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
endDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
subject?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
teacherId?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
scheduleType?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
rentalId?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class QueryScheduleDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
classroomId?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
classId?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
@Max(7)
|
||||||
|
weekDay?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
startDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
endDate?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class WeeklyViewQueryDto {
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
startDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsDateString()
|
||||||
|
endDate?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsInt()
|
||||||
|
classroomId?: number;
|
||||||
|
}
|
||||||
119
apps/server/src/schedules/schedules.controller.ts
Normal file
119
apps/server/src/schedules/schedules.controller.ts
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import {
|
||||||
|
Controller,
|
||||||
|
Get,
|
||||||
|
Post,
|
||||||
|
Put,
|
||||||
|
Delete,
|
||||||
|
Body,
|
||||||
|
Param,
|
||||||
|
Query,
|
||||||
|
UseGuards,
|
||||||
|
Request,
|
||||||
|
} from '@nestjs/common';
|
||||||
|
import { SchedulesService } from './schedules.service';
|
||||||
|
import {
|
||||||
|
CreateScheduleDto,
|
||||||
|
UpdateScheduleDto,
|
||||||
|
QueryScheduleDto,
|
||||||
|
WeeklyViewQueryDto,
|
||||||
|
} from './dto/schedule.dto';
|
||||||
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||||
|
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||||
|
import { extractRequestInfo } from '../common/request-utils';
|
||||||
|
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||||
|
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
@Controller('class-schedules')
|
||||||
|
export class SchedulesController {
|
||||||
|
constructor(
|
||||||
|
private readonly service: SchedulesService,
|
||||||
|
private readonly logService: OperationLogsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
@Get()
|
||||||
|
@RequirePermission('schedule:view')
|
||||||
|
findAll(@Query() query: QueryScheduleDto) {
|
||||||
|
return this.service.findAll(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('weekly')
|
||||||
|
@RequirePermission('schedule:view')
|
||||||
|
getWeeklyView(@Query() query: WeeklyViewQueryDto) {
|
||||||
|
return this.service.getWeeklyView(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get('classroom/:id/occupancy')
|
||||||
|
@RequirePermission('schedule:view')
|
||||||
|
getClassroomOccupancy(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Query('date') date?: string,
|
||||||
|
) {
|
||||||
|
return this.service.getClassroomOccupancy(+id, date);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get(':id')
|
||||||
|
@RequirePermission('schedule:view')
|
||||||
|
findOne(@Param('id') id: string) {
|
||||||
|
return this.service.findOne(+id);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Post()
|
||||||
|
@RequirePermission('schedule:create')
|
||||||
|
async create(@Body() dto: CreateScheduleDto, @Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> }) {
|
||||||
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
|
const result = await this.service.create(dto);
|
||||||
|
await this.logService.log({
|
||||||
|
userId: req.user?.id,
|
||||||
|
username: req.user?.username,
|
||||||
|
module: '排课管理',
|
||||||
|
action: '创建排课',
|
||||||
|
targetId: result.id,
|
||||||
|
targetType: 'class-schedule',
|
||||||
|
detail: `${result.subject} 周${result.weekDay} ${result.startTime}-${result.endTime}`,
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Put(':id')
|
||||||
|
@RequirePermission('schedule:edit')
|
||||||
|
async update(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Body() dto: UpdateScheduleDto,
|
||||||
|
@Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> },
|
||||||
|
) {
|
||||||
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
|
const result = await this.service.update(+id, dto);
|
||||||
|
await this.logService.log({
|
||||||
|
userId: req.user?.id,
|
||||||
|
username: req.user?.username,
|
||||||
|
module: '排课管理',
|
||||||
|
action: '编辑排课',
|
||||||
|
targetId: +id,
|
||||||
|
targetType: 'class-schedule',
|
||||||
|
detail: JSON.stringify(dto),
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Delete(':id')
|
||||||
|
@RequirePermission('schedule:delete')
|
||||||
|
async remove(@Param('id') id: string, @Request() req: { user?: { id: number; username: string }; headers?: Record<string, string> }) {
|
||||||
|
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||||
|
const result = await this.service.remove(+id);
|
||||||
|
await this.logService.log({
|
||||||
|
userId: req.user?.id,
|
||||||
|
username: req.user?.username,
|
||||||
|
module: '排课管理',
|
||||||
|
action: '删除排课',
|
||||||
|
targetId: +id,
|
||||||
|
targetType: 'class-schedule',
|
||||||
|
ipAddress,
|
||||||
|
userAgent,
|
||||||
|
});
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
}
|
||||||
14
apps/server/src/schedules/schedules.module.ts
Normal file
14
apps/server/src/schedules/schedules.module.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
import { ClassSchedule } from '../entities';
|
||||||
|
import { SchedulesService } from './schedules.service';
|
||||||
|
import { SchedulesController } from './schedules.controller';
|
||||||
|
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
imports: [TypeOrmModule.forFeature([ClassSchedule]), OperationLogsModule],
|
||||||
|
controllers: [SchedulesController],
|
||||||
|
providers: [SchedulesService],
|
||||||
|
exports: [SchedulesService],
|
||||||
|
})
|
||||||
|
export class SchedulesModule {}
|
||||||
143
apps/server/src/schedules/schedules.service.ts
Normal file
143
apps/server/src/schedules/schedules.service.ts
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
import { ClassSchedule } from '../entities';
|
||||||
|
import {
|
||||||
|
CreateScheduleDto,
|
||||||
|
UpdateScheduleDto,
|
||||||
|
QueryScheduleDto,
|
||||||
|
WeeklyViewQueryDto,
|
||||||
|
} from './dto/schedule.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SchedulesService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(ClassSchedule)
|
||||||
|
private readonly scheduleRepo: Repository<ClassSchedule>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async findAll(query: QueryScheduleDto) {
|
||||||
|
const qb = this.scheduleRepo.createQueryBuilder('cs');
|
||||||
|
|
||||||
|
if (query.classroomId) qb.andWhere('cs.classroomId = :classroomId', { classroomId: query.classroomId });
|
||||||
|
if (query.classId) qb.andWhere('cs.classId = :classId', { classId: query.classId });
|
||||||
|
if (query.weekDay) qb.andWhere('cs.weekDay = :weekDay', { weekDay: query.weekDay });
|
||||||
|
if (query.startDate) qb.andWhere('cs.startDate >= :startDate', { startDate: query.startDate });
|
||||||
|
if (query.endDate) qb.andWhere('cs.endDate <= :endDate', { endDate: query.endDate });
|
||||||
|
|
||||||
|
qb.orderBy('cs.weekDay', 'ASC')
|
||||||
|
.addOrderBy('cs.startTime', 'ASC');
|
||||||
|
|
||||||
|
return qb.getMany();
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: number) {
|
||||||
|
const schedule = await this.scheduleRepo.findOne({ where: { id } });
|
||||||
|
if (!schedule) throw new NotFoundException('排课记录不存在');
|
||||||
|
return schedule;
|
||||||
|
}
|
||||||
|
|
||||||
|
async create(dto: CreateScheduleDto) {
|
||||||
|
await this.checkConflict(dto.classroomId, dto.weekDay, dto.startTime, dto.endTime);
|
||||||
|
|
||||||
|
const schedule = this.scheduleRepo.create(dto);
|
||||||
|
const saved = await this.scheduleRepo.save(schedule);
|
||||||
|
return this.findOne(saved.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async update(id: number, dto: UpdateScheduleDto) {
|
||||||
|
const existing = await this.scheduleRepo.findOne({ where: { id } });
|
||||||
|
if (!existing) throw new NotFoundException('排课记录不存在');
|
||||||
|
|
||||||
|
// If classroom, weekDay, or times are changing, check conflicts excluding self
|
||||||
|
const classroomId = dto.classroomId ?? existing.classroomId;
|
||||||
|
const weekDay = dto.weekDay ?? existing.weekDay;
|
||||||
|
const startTime = dto.startTime ?? existing.startTime;
|
||||||
|
const endTime = dto.endTime ?? existing.endTime;
|
||||||
|
|
||||||
|
await this.checkConflict(classroomId, weekDay, startTime, endTime, id);
|
||||||
|
|
||||||
|
await this.scheduleRepo.update(id, dto as Record<string, unknown>);
|
||||||
|
return this.findOne(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
async remove(id: number) {
|
||||||
|
const schedule = await this.scheduleRepo.findOne({ where: { id } });
|
||||||
|
if (!schedule) throw new NotFoundException('排课记录不存在');
|
||||||
|
await this.scheduleRepo.remove(schedule);
|
||||||
|
return { success: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
async checkConflict(
|
||||||
|
classroomId: number,
|
||||||
|
weekDay: number,
|
||||||
|
startTime: string,
|
||||||
|
endTime: string,
|
||||||
|
excludeId?: number,
|
||||||
|
) {
|
||||||
|
const qb = this.scheduleRepo
|
||||||
|
.createQueryBuilder('cs')
|
||||||
|
.where('cs.classroomId = :classroomId', { classroomId })
|
||||||
|
.andWhere('cs.weekDay = :weekDay', { weekDay })
|
||||||
|
.andWhere('cs.status = :status', { status: 'active' })
|
||||||
|
.andWhere('cs.startTime < :endTime', { endTime })
|
||||||
|
.andWhere('cs.endTime > :startTime', { startTime });
|
||||||
|
|
||||||
|
if (excludeId) qb.andWhere('cs.id != :excludeId', { excludeId });
|
||||||
|
|
||||||
|
const conflicts = await qb.getMany();
|
||||||
|
if (conflicts.length > 0) {
|
||||||
|
throw new ConflictException(
|
||||||
|
`该时间段与已有排课冲突: ${conflicts.map((c) => `${c.subject}(${c.startTime}-${c.endTime})`).join(', ')}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return conflicts;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getWeeklyView(query: WeeklyViewQueryDto) {
|
||||||
|
const qb = this.scheduleRepo.createQueryBuilder('cs');
|
||||||
|
|
||||||
|
if (query.classroomId) {
|
||||||
|
qb.andWhere('cs.classroomId = :classroomId', { classroomId: query.classroomId });
|
||||||
|
}
|
||||||
|
if (query.startDate) {
|
||||||
|
qb.andWhere('cs.endDate >= :startDate', { startDate: query.startDate });
|
||||||
|
}
|
||||||
|
if (query.endDate) {
|
||||||
|
qb.andWhere('cs.startDate <= :endDate', { endDate: query.endDate });
|
||||||
|
}
|
||||||
|
|
||||||
|
const schedules = await qb
|
||||||
|
.andWhere('cs.status = :status', { status: 'active' })
|
||||||
|
.orderBy('cs.weekDay', 'ASC')
|
||||||
|
.addOrderBy('cs.startTime', 'ASC')
|
||||||
|
.getMany();
|
||||||
|
|
||||||
|
// Group by classroomId → weekDay
|
||||||
|
const matrix: Record<number, Record<number, typeof schedules>> = {};
|
||||||
|
for (const s of schedules) {
|
||||||
|
if (!matrix[s.classroomId]) matrix[s.classroomId] = {};
|
||||||
|
if (!matrix[s.classroomId][s.weekDay]) matrix[s.classroomId][s.weekDay] = [];
|
||||||
|
matrix[s.classroomId][s.weekDay].push(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
return matrix;
|
||||||
|
}
|
||||||
|
|
||||||
|
async getClassroomOccupancy(classroomId: number, date?: string) {
|
||||||
|
const qb = this.scheduleRepo
|
||||||
|
.createQueryBuilder('cs')
|
||||||
|
.where('cs.classroomId = :classroomId', { classroomId })
|
||||||
|
.andWhere('cs.status = :status', { status: 'active' });
|
||||||
|
|
||||||
|
if (date) {
|
||||||
|
qb.andWhere('cs.startDate <= :date', { date })
|
||||||
|
.andWhere('cs.endDate >= :date', { date });
|
||||||
|
}
|
||||||
|
|
||||||
|
return qb
|
||||||
|
.orderBy('cs.weekDay', 'ASC')
|
||||||
|
.addOrderBy('cs.startTime', 'ASC')
|
||||||
|
.getMany();
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user