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

@@ -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 });
});
});

View File

@@ -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);