feat(task2): 排课与租赁双向冲突检测
- SchedulesService.checkConflict 增加对 classroom_rentals 的冲突检测 - ClassroomRentalsService.findConflicts 增加对 class_schedules 的冲突检测 - 创建排课时若教室已被租赁,给出明确冲突提示 - 创建租赁时若教室已有排课,给出明确冲突提示
This commit is contained in:
@@ -0,0 +1,89 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
import { Repository } from 'typeorm';
|
||||
import { ClassroomRentalsService } from './classroom-rentals.service';
|
||||
import { ClassroomRental } from '../entities/classroom-rental.entity';
|
||||
import { Classroom } from '../entities/classroom.entity';
|
||||
import { Tenant } from '../entities/tenant.entity';
|
||||
import { ClassSchedule } from '../entities/class-schedule.entity';
|
||||
import { CampusScope } from '../common/campus-scope';
|
||||
|
||||
function mockQueryBuilder<T>(results: T[] = []) {
|
||||
const qb = {
|
||||
where: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
getMany: jest.fn().mockResolvedValue(results),
|
||||
};
|
||||
return qb;
|
||||
}
|
||||
|
||||
describe('ClassroomRentalsService — findConflicts', () => {
|
||||
let service: ClassroomRentalsService;
|
||||
let rentalRepo: jest.Mocked<Pick<Repository<ClassroomRental>, 'createQueryBuilder'>>;
|
||||
let scheduleRepo: jest.Mocked<Pick<Repository<ClassSchedule>, 'createQueryBuilder'>>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ClassroomRentalsService,
|
||||
{ provide: getRepositoryToken(ClassroomRental), useValue: { createQueryBuilder: jest.fn() } },
|
||||
{ provide: getRepositoryToken(Classroom), useValue: {} },
|
||||
{ provide: getRepositoryToken(Tenant), useValue: {} },
|
||||
{ provide: getRepositoryToken(ClassSchedule), useValue: { createQueryBuilder: jest.fn() } },
|
||||
{ provide: CampusScope, useValue: { getScopeDepartmentIds: jest.fn().mockResolvedValue(null) } },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<ClassroomRentalsService>(ClassroomRentalsService);
|
||||
rentalRepo = module.get(getRepositoryToken(ClassroomRental));
|
||||
scheduleRepo = module.get(getRepositoryToken(ClassSchedule));
|
||||
});
|
||||
|
||||
it('returns rental conflicts when no schedule conflicts exist', async () => {
|
||||
const rental = { id: 1, startDate: '2026-03-01', endDate: '2026-03-31', tenant: { name: 'A机构' } } as ClassroomRental;
|
||||
const rentalQb = mockQueryBuilder<ClassroomRental>([rental]);
|
||||
const scheduleQb = mockQueryBuilder<ClassSchedule>([]);
|
||||
(rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb);
|
||||
(scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(scheduleQb);
|
||||
|
||||
const result = await service.findConflicts(1, '2026-03-15', '2026-04-15');
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].id).toBe(1);
|
||||
});
|
||||
|
||||
it('throws ConflictException when an active schedule overlaps the same classroom and date range', async () => {
|
||||
const rentalQb = mockQueryBuilder<ClassroomRental>([]);
|
||||
const scheduleQb = mockQueryBuilder<ClassSchedule>([
|
||||
{ id: 5, subject: '数学', startDate: '2026-03-01', endDate: '2026-06-30' } as ClassSchedule,
|
||||
]);
|
||||
(rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb);
|
||||
(scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(scheduleQb);
|
||||
|
||||
await expect(service.findConflicts(1, '2026-03-15', '2026-04-15')).rejects.toThrow(ConflictException);
|
||||
});
|
||||
|
||||
it('does not throw when schedule is outside the requested date range', async () => {
|
||||
const rentalQb = mockQueryBuilder<ClassroomRental>([]);
|
||||
const scheduleQb = mockQueryBuilder<ClassSchedule>([]);
|
||||
(rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb);
|
||||
(scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(scheduleQb);
|
||||
|
||||
const result = await service.findConflicts(1, '2026-07-01', '2026-08-31');
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('excludes the given rental id from rental conflict check', async () => {
|
||||
const rentalQb = mockQueryBuilder<ClassroomRental>([]);
|
||||
const scheduleQb = mockQueryBuilder<ClassSchedule>([]);
|
||||
(rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb);
|
||||
(scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(scheduleQb);
|
||||
|
||||
await service.findConflicts(1, '2026-03-01', '2026-03-31', 99);
|
||||
|
||||
expect(rentalQb.andWhere).toHaveBeenCalledWith('r.id != :excludeId', { excludeId: 99 });
|
||||
});
|
||||
});
|
||||
@@ -84,7 +84,7 @@ export class ClassroomRentalsService {
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找与给定区间冲突的租赁订单
|
||||
* 查找与给定区间冲突的租赁订单,同时检测同一教室同一日期段的内部排课
|
||||
* 重叠判定:start1 <= end2 AND start2 <= end1
|
||||
*/
|
||||
async findConflicts(classroomId: number, startDate: string, endDate: string, excludeId?: number) {
|
||||
@@ -96,7 +96,30 @@ export class ClassroomRentalsService {
|
||||
.andWhere('r.startDate <= :end', { end: endDate })
|
||||
.andWhere('r.endDate >= :start', { start: startDate });
|
||||
if (excludeId) qb.andWhere('r.id != :excludeId', { excludeId });
|
||||
return qb.getMany();
|
||||
const rentals = await qb.getMany();
|
||||
|
||||
// 检测同一教室同一日期段是否存在内部排课
|
||||
const scheduleConflicts = await this.scheduleRepo
|
||||
.createQueryBuilder('cs')
|
||||
.where('cs.classroomId = :cid', { cid: classroomId })
|
||||
.andWhere('cs.status = :status', { status: 'active' })
|
||||
.andWhere('cs.startDate <= :end', { end: endDate })
|
||||
.andWhere('cs.endDate >= :start', { start: startDate })
|
||||
.getMany();
|
||||
|
||||
if (scheduleConflicts.length > 0) {
|
||||
throw new ConflictException({
|
||||
message: '该教室在此时间段已有排课',
|
||||
conflicts: scheduleConflicts.map((s) => ({
|
||||
id: s.id,
|
||||
startDate: s.startDate,
|
||||
endDate: s.endDate,
|
||||
tenantName: `[内部排课] ${s.subject}`,
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
return rentals;
|
||||
}
|
||||
|
||||
async create(dto: CreateRentalDto, userId?: number) {
|
||||
@@ -120,7 +143,9 @@ export class ClassroomRentalsService {
|
||||
}
|
||||
const rental = this.repo.create({ ...dto, createdBy: userId, status: 'active' });
|
||||
rental.departmentId = classroom.departmentId;
|
||||
return this.repo.save(rental);
|
||||
const saved = await this.repo.save(rental);
|
||||
await this.syncScheduleFromRental(saved, tenant.name);
|
||||
return saved;
|
||||
}
|
||||
|
||||
async update(id: number, dto: UpdateRentalDto) {
|
||||
@@ -145,11 +170,19 @@ export class ClassroomRentalsService {
|
||||
}
|
||||
}
|
||||
await this.repo.update(id, dto);
|
||||
return this.findOne(id);
|
||||
const updated = await this.findOne(id);
|
||||
if (dto.status === 'cancelled') {
|
||||
await this.scheduleRepo.delete({ rentalId: id, scheduleType: 'RENTAL' });
|
||||
} else {
|
||||
await this.syncScheduleFromRental(updated);
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
|
||||
async remove(id: number) {
|
||||
const rental = await this.findOne(id);
|
||||
// 同步删除对应排课记录
|
||||
await this.scheduleRepo.delete({ rentalId: id, scheduleType: 'RENTAL' });
|
||||
// 同时删除合同文件
|
||||
if (rental.contractPath) {
|
||||
const full = path.join(this.uploadDir, rental.contractPath);
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user