import { Injectable, NotFoundException, ConflictException, ForbiddenException, BadRequestException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { In, Not, Repository } from 'typeorm'; import { ClassSchedule, Class, Classroom, ClassroomStatus, ClassroomRental, ClassTeacher, AttendanceSession, } from '../entities'; import { CreateScheduleDto, UpdateScheduleDto, QueryScheduleDto, WeeklyViewQueryDto, } from './dto/schedule.dto'; const SCHEDULE_GAP_MINUTES = 10; function shiftTime(time: string, minutes: number): string { const [hours, minutePart] = time.split(':').map(Number); const shifted = Math.min(24 * 60, Math.max(0, hours * 60 + minutePart + minutes)); const shiftedHours = Math.floor(shifted / 60); const shiftedMinutes = shifted % 60; return `${String(shiftedHours).padStart(2, '0')}:${String(shiftedMinutes).padStart(2, '0')}`; } @Injectable() export class SchedulesService { constructor( @InjectRepository(ClassSchedule) private readonly scheduleRepo: Repository, @InjectRepository(Class) private readonly classRepo: Repository, @InjectRepository(Classroom) private readonly classroomRepo: Repository, @InjectRepository(ClassroomRental) private readonly rentalRepo: Repository, @InjectRepository(ClassTeacher) private readonly classTeacherRepo: Repository, @InjectRepository(AttendanceSession) private readonly attendanceSessionRepo: Repository, ) {} async getAccessibleClassIds(userId: number, canManageAll = false): Promise { if (canManageAll) return undefined; const assignments = await this.classTeacherRepo.find({ where: { userId } }); return [...new Set(assignments.map((assignment) => assignment.classId))]; } async assertClassAccess(userId: number, classId: number, canManageAll = false): Promise { if (canManageAll) return; const assignment = await this.classTeacherRepo.findOne({ where: { userId, classId } }); if (!assignment) throw new ForbiddenException('只能管理自己被分配班级的排课'); } maskScheduleOccupancy(schedule: ClassSchedule) { return { id: null, classId: null, classroomId: schedule.classroomId, weekDay: schedule.weekDay, startTime: schedule.startTime, endTime: schedule.endTime, attendanceAdvanceMinutes: schedule.attendanceAdvanceMinutes, startDate: schedule.startDate, endDate: schedule.endDate, subject: '已占用', teacherId: null, scheduleType: schedule.scheduleType, status: schedule.status, notes: null, canViewDetails: false, }; } async getLookups(accessibleClassIds?: number[]) { const classes = accessibleClassIds ? accessibleClassIds.length > 0 ? await this.classRepo.find({ where: { id: In(accessibleClassIds) }, select: ['id', 'name', 'code'], order: { name: 'ASC' }, }) : [] : await this.classRepo.find({ select: ['id', 'name', 'code'], order: { name: 'ASC' }, }); const classrooms = await this.classroomRepo.find({ where: { status: ClassroomStatus.AVAILABLE }, select: ['id', 'name', 'building', 'floor', 'roomType'], order: { building: 'ASC', name: 'ASC' }, }); return { classes, classrooms, }; } async findAll(query: QueryScheduleDto, accessibleClassIds?: number[]) { 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 }); else if (accessibleClassIds) { if (accessibleClassIds.length === 0) return []; qb.andWhere('cs.classId IN (:...accessibleClassIds)', { accessibleClassIds }); } 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 getClassTeachers(classId: number) { const teachers = await this.classTeacherRepo.find({ where: { classId }, relations: ['user'], order: { roleType: 'ASC', subject: 'ASC' }, }); return teachers.map((teacher) => ({ id: teacher.id, userId: teacher.userId, username: teacher.user?.username, name: teacher.user?.name, roleType: teacher.roleType, subject: teacher.subject, })); } private async normalizeTeacherForSchedule< T extends { classId?: number; subject?: string; teacherId?: number | null }, >(dto: T): Promise { if (!dto.classId || !dto.subject || dto.teacherId) return dto; const teachers = await this.classTeacherRepo.find({ where: { classId: dto.classId, roleType: 'subject_teacher', subject: dto.subject }, }); if (teachers.length === 1) { dto.teacherId = teachers[0].userId; } return dto; } private async assertTeacherAssignedToClass( classId: number | null | undefined, teacherId: number | null | undefined, ) { if (!classId || !teacherId) return; const assignment = await this.classTeacherRepo.findOne({ where: { classId, userId: teacherId }, }); if (!assignment) throw new BadRequestException('只能选择该班级已配置的教师'); } async findOne(id: number) { const schedule = await this.scheduleRepo.findOne({ where: { id } }); if (!schedule) throw new NotFoundException('排课记录不存在'); return schedule; } private async assertClassroomAvailable(classroomId: number) { const classroom = await this.classroomRepo.findOne({ where: { id: classroomId } }); if (!classroom) throw new NotFoundException('教室不存在'); if (classroom.status !== ClassroomStatus.AVAILABLE) { throw new BadRequestException('仅可用教室可以排课'); } } private assertValidScheduleRange( startTime: string, endTime: string, startDate: string, endDate: string, ) { if (startTime === endTime) { throw new BadRequestException('上课时间和下课时间不能相同'); } if (startDate > endDate) { throw new BadRequestException('排课结束日期不能早于开始日期'); } } async create(dto: CreateScheduleDto) { this.assertValidScheduleRange(dto.startTime, dto.endTime, dto.startDate, dto.endDate); await this.assertClassroomAvailable(dto.classroomId); await this.normalizeTeacherForSchedule(dto); await this.assertTeacherAssignedToClass(dto.classId, dto.teacherId); await this.checkConflict( dto.classroomId, dto.weekDay, dto.startTime, dto.endTime, dto.startDate, dto.endDate, ); 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; if (dto.classroomId !== undefined && dto.classroomId !== existing.classroomId) { await this.assertClassroomAvailable(dto.classroomId); } const weekDay = dto.weekDay ?? existing.weekDay; const startTime = dto.startTime ?? existing.startTime; const endTime = dto.endTime ?? existing.endTime; const startDate = dto.startDate ?? existing.startDate; const endDate = dto.endDate ?? existing.endDate; this.assertValidScheduleRange(startTime, endTime, startDate, endDate); const normalized = await this.normalizeTeacherForSchedule({ ...dto, classId: dto.classId ?? existing.classId ?? undefined, subject: dto.subject ?? existing.subject, }); if (dto.teacherId === undefined && normalized.teacherId !== undefined) { dto.teacherId = normalized.teacherId; } const teacherId = dto.teacherId ?? existing.teacherId; await this.assertTeacherAssignedToClass(dto.classId ?? existing.classId, teacherId); await this.checkConflict(classroomId, weekDay, startTime, endTime, startDate, endDate, id); await this.scheduleRepo.update(id, dto); return this.findOne(id); } async remove(id: number) { const schedule = await this.scheduleRepo.findOne({ where: { id } }); if (!schedule) throw new NotFoundException('排课记录不存在'); const sessionCount = await this.attendanceSessionRepo.count({ where: { scheduleId: id }, }); if (sessionCount > 0) { throw new ConflictException( `无法删除已产生 ${sessionCount} 个考勤场次的排课。请先取消或停用排课以保护历史考勤数据。`, ); } await this.scheduleRepo.remove(schedule); return { success: true }; } async checkConflict( classroomId: number, weekDay: number, startTime: string, endTime: string, startDate: string, endDate: string, excludeId?: number, ) { // 为教室换场、整理和人员进出预留时间;恰好间隔 10 分钟允许排课。 const bufferedStartTime = shiftTime(startTime, -SCHEDULE_GAP_MINUTES); const bufferedEndTime = shiftTime(endTime, SCHEDULE_GAP_MINUTES); 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 < :bufferedEndTime', { bufferedEndTime }) .andWhere('cs.endTime > :bufferedStartTime', { bufferedStartTime }) .andWhere('cs.startDate <= :endDate', { endDate }) .andWhere('cs.endDate >= :startDate', { startDate }); if (excludeId) qb.andWhere('cs.id != :excludeId', { excludeId }); const conflicts = await qb.getMany(); if (conflicts.length > 0) { throw new ConflictException( `排课之间必须至少间隔 ${SCHEDULE_GAP_MINUTES} 分钟,与以下排课时间过近: ${conflicts.map((c) => `${c.subject}(${c.startTime}-${c.endTime})`).join(', ')}`, ); } // 同时检测同一教室在同一日期段是否存在租赁订单( status != cancelled ) const rentalConflicts = await this.rentalRepo .createQueryBuilder('r') .where('r.classroomId = :classroomId', { classroomId }) .andWhere('r.status = :activeRental', { activeRental: 'active' }) .andWhere('r.startDate <= :endDate', { endDate }) .andWhere('r.endDate >= :startDate', { startDate }) .getMany(); if (rentalConflicts.length > 0) { throw new ConflictException( `该教室在 ${rentalConflicts.map((r) => `${r.startDate}~${r.endDate}`).join('、')} 已被租赁,无法排课`, ); } return conflicts; } async getWeeklyView(query: WeeklyViewQueryDto, accessibleClassIds?: number[]) { 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(); const allowedClassIds = accessibleClassIds ? new Set(accessibleClassIds) : null; const visibleSchedules = schedules.map((schedule) => { const canViewDetails = allowedClassIds === null || (schedule.classId !== null && allowedClassIds.has(schedule.classId)); if (canViewDetails) return { ...schedule, canViewDetails: true }; // Other classes remain visible only as a room/time occupancy block. // Do not expose class, subject, teacher, notes, or internal record IDs. return this.maskScheduleOccupancy(schedule); }); // Group by classroomId → weekDay const matrix: Record> = {}; for (const schedule of visibleSchedules) { if (!matrix[schedule.classroomId]) matrix[schedule.classroomId] = {}; if (!matrix[schedule.classroomId][schedule.weekDay]) matrix[schedule.classroomId][schedule.weekDay] = []; matrix[schedule.classroomId][schedule.weekDay].push(schedule); } 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' }) .andWhere('cs.scheduleType IN (:...scheduleTypes)', { scheduleTypes: ['INTERNAL', 'RENTAL'], }); if (date) { qb.andWhere('cs.startDate <= :date', { date }).andWhere('cs.endDate >= :date', { date }); } return qb.orderBy('cs.weekDay', 'ASC').addOrderBy('cs.startTime', 'ASC').getMany(); } }