feat(task2): 排课与租赁双向冲突检测

- SchedulesService.checkConflict 增加对 classroom_rentals 的冲突检测
- ClassroomRentalsService.findConflicts 增加对 class_schedules 的冲突检测
- 创建排课时若教室已被租赁,给出明确冲突提示
- 创建租赁时若教室已有排课,给出明确冲突提示
This commit is contained in:
2026-07-06 17:41:31 +08:00
parent 3fa446f978
commit b837e9b145
5 changed files with 186 additions and 8 deletions

View File

@@ -1,6 +1,6 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ClassSchedule, Class } from '../entities';
import { ClassSchedule, Class, ClassroomRental } from '../entities';
import { SchedulesService } from './schedules.service';
import { SchedulesController } from './schedules.controller';
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
@@ -8,7 +8,7 @@ import { NotificationsModule } from '../notifications/notifications.module';
import { CommonModule } from '../common/common.module';
@Module({
imports: [TypeOrmModule.forFeature([ClassSchedule, Class]), OperationLogsModule, NotificationsModule, CommonModule],
imports: [TypeOrmModule.forFeature([ClassSchedule, Class, ClassroomRental]), OperationLogsModule, NotificationsModule, CommonModule],
controllers: [SchedulesController],
providers: [SchedulesService],
exports: [SchedulesService],

View File

@@ -4,6 +4,7 @@ import { ConflictException } from '@nestjs/common';
import { Repository } from 'typeorm';
import { SchedulesService } from './schedules.service';
import { ClassSchedule } from '../entities/class-schedule.entity';
import { ClassroomRental } from '../entities/classroom-rental.entity';
import { Class } from '../entities/class.entity';
import { CampusScope } from '../common/campus-scope';
@@ -20,6 +21,7 @@ function mockQueryBuilder<T>(results: T[] = []) {
describe('SchedulesService — checkConflict', () => {
let service: SchedulesService;
let scheduleRepo: jest.Mocked<Pick<Repository<ClassSchedule>, 'createQueryBuilder'>>;
let rentalRepo: jest.Mocked<Pick<Repository<ClassroomRental>, 'createQueryBuilder'>>;
beforeEach(async () => {
const mockRepo = {
@@ -31,19 +33,23 @@ describe('SchedulesService — checkConflict', () => {
SchedulesService,
{ provide: getRepositoryToken(ClassSchedule), useValue: mockRepo },
{ provide: getRepositoryToken(Class), useValue: { find: jest.fn().mockResolvedValue([]) } },
{ provide: CampusScope, useValue: { getScopeDepartmentIds: jest.fn().mockResolvedValue(null), filter: jest.fn((w: any) => w) } },
{ provide: getRepositoryToken(ClassroomRental), useValue: { createQueryBuilder: jest.fn() } },
{ provide: CampusScope, useValue: { getScopeDepartmentIds: jest.fn().mockResolvedValue(null), filter: jest.fn((w: unknown) => w) } },
],
}).compile();
service = module.get<SchedulesService>(SchedulesService);
scheduleRepo = module.get(getRepositoryToken(ClassSchedule));
rentalRepo = module.get(getRepositoryToken(ClassroomRental));
});
it('same classroom + same weekday + overlapping times → ConflictException', async () => {
const qb = mockQueryBuilder<ClassSchedule>([
{ id: 1, subject: '数学', startTime: '08:00', endTime: '10:00' } as ClassSchedule,
]);
const rentalQb = mockQueryBuilder<ClassroomRental>([]);
(scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(qb);
(rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb);
await expect(
service.checkConflict(1, 3, '09:00', '11:00', '2026-03-01', '2026-06-30'),
@@ -52,7 +58,9 @@ describe('SchedulesService — checkConflict', () => {
it('same classroom + same weekday + non-overlapping times → no conflict', async () => {
const qb = mockQueryBuilder<ClassSchedule>([]);
const rentalQb = mockQueryBuilder<ClassroomRental>([]);
(scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(qb);
(rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb);
await expect(
service.checkConflict(1, 3, '10:00', '12:00', '2026-03-01', '2026-06-30'),
@@ -61,7 +69,9 @@ describe('SchedulesService — checkConflict', () => {
it('same classroom + same weekday + overlapping times but disjoint date ranges → no conflict', async () => {
const qb = mockQueryBuilder<ClassSchedule>([]);
const rentalQb = mockQueryBuilder<ClassroomRental>([]);
(scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(qb);
(rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb);
await expect(
service.checkConflict(1, 3, '09:00', '11:00', '2026-07-01', '2026-08-31'),
@@ -70,7 +80,9 @@ describe('SchedulesService — checkConflict', () => {
it('different classroom → no conflict', async () => {
const qb = mockQueryBuilder<ClassSchedule>([]);
const rentalQb = mockQueryBuilder<ClassroomRental>([]);
(scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(qb);
(rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb);
await expect(
service.checkConflict(2, 3, '09:00', '11:00', '2026-03-01', '2026-06-30'),
@@ -79,10 +91,36 @@ describe('SchedulesService — checkConflict', () => {
it('excludes the given schedule id from conflict check', async () => {
const qb = mockQueryBuilder<ClassSchedule>([]);
const rentalQb = mockQueryBuilder<ClassroomRental>([]);
(scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(qb);
(rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb);
await service.checkConflict(1, 3, '09:00', '11:00', '2026-03-01', '2026-06-30', 42);
expect(qb.andWhere).toHaveBeenCalledWith('cs.id != :excludeId', { excludeId: 42 });
});
it('overlapping classroom rental → ConflictException', async () => {
const qb = mockQueryBuilder<ClassSchedule>([]);
const rentalQb = mockQueryBuilder<ClassroomRental>([
{ id: 10, startDate: '2026-03-01', endDate: '2026-03-31', status: 'active' } as ClassroomRental,
]);
(scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(qb);
(rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb);
await expect(
service.checkConflict(1, 3, '09:00', '11:00', '2026-03-01', '2026-06-30'),
).rejects.toThrow(ConflictException);
});
it('cancelled rental does not conflict', async () => {
const qb = mockQueryBuilder<ClassSchedule>([]);
const rentalQb = mockQueryBuilder<ClassroomRental>([]);
(scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(qb);
(rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb);
await expect(
service.checkConflict(1, 3, '09:00', '11:00', '2026-03-01', '2026-06-30'),
).resolves.toEqual([]);
});
});

View File

@@ -1,7 +1,7 @@
import { Injectable, NotFoundException, ConflictException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ClassSchedule, Class } from '../entities';
import { ClassSchedule, Class, ClassroomRental } from '../entities';
import { CampusScope } from '../common/campus-scope';
import {
CreateScheduleDto,
@@ -17,6 +17,8 @@ export class SchedulesService {
private readonly scheduleRepo: Repository<ClassSchedule>,
private readonly scope: CampusScope,
@InjectRepository(Class) private readonly classRepo: Repository<Class>,
@InjectRepository(ClassroomRental)
private readonly rentalRepo: Repository<ClassroomRental>,
) {}
async findAll(query: QueryScheduleDto) {
@@ -110,6 +112,22 @@ export class SchedulesService {
`该时间段与已有排课冲突: ${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 != :cancelled', { cancelled: 'cancelled' })
.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;
}