diff --git a/apps/server/src/classroom-rentals/classroom-rentals.service.spec.ts b/apps/server/src/classroom-rentals/classroom-rentals.service.spec.ts new file mode 100644 index 0000000..32b61f7 --- /dev/null +++ b/apps/server/src/classroom-rentals/classroom-rentals.service.spec.ts @@ -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(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, 'createQueryBuilder'>>; + let scheduleRepo: jest.Mocked, '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); + 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([rental]); + const scheduleQb = mockQueryBuilder([]); + (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([]); + const scheduleQb = mockQueryBuilder([ + { 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([]); + const scheduleQb = mockQueryBuilder([]); + (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([]); + const scheduleQb = mockQueryBuilder([]); + (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 }); + }); +}); diff --git a/apps/server/src/classroom-rentals/classroom-rentals.service.ts b/apps/server/src/classroom-rentals/classroom-rentals.service.ts index 9e79881..ec76b96 100644 --- a/apps/server/src/classroom-rentals/classroom-rentals.service.ts +++ b/apps/server/src/classroom-rentals/classroom-rentals.service.ts @@ -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); diff --git a/apps/server/src/schedules/schedules.module.ts b/apps/server/src/schedules/schedules.module.ts index faadc7e..a3a4624 100644 --- a/apps/server/src/schedules/schedules.module.ts +++ b/apps/server/src/schedules/schedules.module.ts @@ -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], diff --git a/apps/server/src/schedules/schedules.service.spec.ts b/apps/server/src/schedules/schedules.service.spec.ts index 3c17c87..df310fd 100644 --- a/apps/server/src/schedules/schedules.service.spec.ts +++ b/apps/server/src/schedules/schedules.service.spec.ts @@ -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(results: T[] = []) { describe('SchedulesService — checkConflict', () => { let service: SchedulesService; let scheduleRepo: jest.Mocked, 'createQueryBuilder'>>; + let rentalRepo: jest.Mocked, '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); scheduleRepo = module.get(getRepositoryToken(ClassSchedule)); + rentalRepo = module.get(getRepositoryToken(ClassroomRental)); }); it('same classroom + same weekday + overlapping times → ConflictException', async () => { const qb = mockQueryBuilder([ { id: 1, subject: '数学', startTime: '08:00', endTime: '10:00' } as ClassSchedule, ]); + const rentalQb = mockQueryBuilder([]); (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([]); + const rentalQb = mockQueryBuilder([]); (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([]); + const rentalQb = mockQueryBuilder([]); (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([]); + const rentalQb = mockQueryBuilder([]); (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([]); + const rentalQb = mockQueryBuilder([]); (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([]); + const rentalQb = mockQueryBuilder([ + { 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([]); + const rentalQb = mockQueryBuilder([]); + (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([]); + }); }); diff --git a/apps/server/src/schedules/schedules.service.ts b/apps/server/src/schedules/schedules.service.ts index c2785fc..ef28da7 100644 --- a/apps/server/src/schedules/schedules.service.ts +++ b/apps/server/src/schedules/schedules.service.ts @@ -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, private readonly scope: CampusScope, @InjectRepository(Class) private readonly classRepo: Repository, + @InjectRepository(ClassroomRental) + private readonly rentalRepo: Repository, ) {} 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; }