Files
gongxue-base/apps/server/src/schedules/schedules.service.ts

371 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<ClassSchedule>,
@InjectRepository(Class) private readonly classRepo: Repository<Class>,
@InjectRepository(Classroom) private readonly classroomRepo: Repository<Classroom>,
@InjectRepository(ClassroomRental)
private readonly rentalRepo: Repository<ClassroomRental>,
@InjectRepository(ClassTeacher)
private readonly classTeacherRepo: Repository<ClassTeacher>,
@InjectRepository(AttendanceSession)
private readonly attendanceSessionRepo: Repository<AttendanceSession>,
) {}
async getAccessibleClassIds(userId: number, canManageAll = false): Promise<number[] | undefined> {
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<void> {
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<T> {
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<number, Record<number, typeof visibleSchedules>> = {};
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();
}
}