From 037c0e07e0a6b3ee545f951948db59b47a3fea79 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Sun, 5 Jul 2026 19:21:37 +0800 Subject: [PATCH] feat: add Schedules module with conflict detection and weekly view --- apps/server/src/app.module.ts | 4 + apps/server/src/schedules/dto/schedule.dto.ts | 152 ++++++++++++++++++ .../src/schedules/schedules.controller.ts | 119 ++++++++++++++ apps/server/src/schedules/schedules.module.ts | 14 ++ .../server/src/schedules/schedules.service.ts | 143 ++++++++++++++++ 5 files changed, 432 insertions(+) create mode 100644 apps/server/src/schedules/dto/schedule.dto.ts create mode 100644 apps/server/src/schedules/schedules.controller.ts create mode 100644 apps/server/src/schedules/schedules.module.ts create mode 100644 apps/server/src/schedules/schedules.service.ts diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index fe7e630..b2b2f6a 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -19,6 +19,7 @@ import { ClassroomRental, Permission, Role, + ClassSchedule, } from './entities'; import { AuthModule } from './auth/auth.module'; import { RbacModule } from './rbac/rbac.module'; @@ -34,6 +35,7 @@ import { DepositsModule } from './deposits/deposits.module'; import { ClassroomsModule } from './classrooms/classrooms.module'; import { ClassesModule } from './classes/classes.module'; import { TenantsModule } from './tenants/tenants.module'; +import { SchedulesModule } from './schedules/schedules.module'; import { ClassroomRentalsModule } from './classroom-rentals/classroom-rentals.module'; @Module({ @@ -66,6 +68,7 @@ import { ClassroomRentalsModule } from './classroom-rentals/classroom-rentals.mo ClassroomRental, Permission, Role, + ClassSchedule, ]; if (dbType === 'mysql') { return { @@ -101,6 +104,7 @@ import { ClassroomRentalsModule } from './classroom-rentals/classroom-rentals.mo ClassroomsModule, ClassesModule, TenantsModule, + SchedulesModule, ClassroomRentalsModule, ], providers: [ diff --git a/apps/server/src/schedules/dto/schedule.dto.ts b/apps/server/src/schedules/dto/schedule.dto.ts new file mode 100644 index 0000000..12dca26 --- /dev/null +++ b/apps/server/src/schedules/dto/schedule.dto.ts @@ -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; +} diff --git a/apps/server/src/schedules/schedules.controller.ts b/apps/server/src/schedules/schedules.controller.ts new file mode 100644 index 0000000..6acedfd --- /dev/null +++ b/apps/server/src/schedules/schedules.controller.ts @@ -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 }) { + 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 }, + ) { + 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 }) { + 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; + } +} diff --git a/apps/server/src/schedules/schedules.module.ts b/apps/server/src/schedules/schedules.module.ts new file mode 100644 index 0000000..c9a7b03 --- /dev/null +++ b/apps/server/src/schedules/schedules.module.ts @@ -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 {} diff --git a/apps/server/src/schedules/schedules.service.ts b/apps/server/src/schedules/schedules.service.ts new file mode 100644 index 0000000..52b4ed8 --- /dev/null +++ b/apps/server/src/schedules/schedules.service.ts @@ -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, + ) {} + + 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); + 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> = {}; + 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(); + } +}