forked from wangziqi/gongxue-base
371 lines
13 KiB
TypeScript
371 lines
13 KiB
TypeScript
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();
|
||
}
|
||
}
|