diff --git a/apps/server/src/classroom-rentals/classroom-rentals.service.spec.ts b/apps/server/src/classroom-rentals/classroom-rentals.service.spec.ts index 32b61f7..b3e9da3 100644 --- a/apps/server/src/classroom-rentals/classroom-rentals.service.spec.ts +++ b/apps/server/src/classroom-rentals/classroom-rentals.service.spec.ts @@ -8,6 +8,7 @@ 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'; +import { CreateRentalDto, UpdateRentalDto } from './dto/rental.dto'; function mockQueryBuilder(results: T[] = []) { const qb = { @@ -87,3 +88,206 @@ describe('ClassroomRentalsService — findConflicts', () => { expect(rentalQb.andWhere).toHaveBeenCalledWith('r.id != :excludeId', { excludeId: 99 }); }); }); + +describe('ClassroomRentalsService — rental schedule sync', () => { + let service: ClassroomRentalsService; + let rentalRepo: jest.Mocked< + Pick, 'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder'> + >; + let classroomRepo: jest.Mocked, 'findOne'>>; + let tenantRepo: jest.Mocked, 'findOne'>>; + let scheduleRepo: jest.Mocked< + Pick, 'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder'> + >; + + beforeEach(async () => { + rentalRepo = { + findOne: jest.fn(), + save: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + createQueryBuilder: jest.fn(), + } as jest.Mocked< + Pick, 'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder'> + >; + + classroomRepo = { findOne: jest.fn() } as jest.Mocked, 'findOne'>>; + + tenantRepo = { findOne: jest.fn() } as jest.Mocked, 'findOne'>>; + + scheduleRepo = { + findOne: jest.fn(), + save: jest.fn(), + create: jest.fn(), + update: jest.fn(), + delete: jest.fn(), + createQueryBuilder: jest.fn(), + } as jest.Mocked< + Pick, 'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder'> + >; + + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ClassroomRentalsService, + { provide: getRepositoryToken(ClassroomRental), useValue: rentalRepo }, + { provide: getRepositoryToken(Classroom), useValue: classroomRepo }, + { provide: getRepositoryToken(Tenant), useValue: tenantRepo }, + { provide: getRepositoryToken(ClassSchedule), useValue: scheduleRepo }, + { provide: CampusScope, useValue: { getScopeDepartmentIds: jest.fn().mockResolvedValue(null) } }, + ], + }).compile(); + + service = module.get(ClassroomRentalsService); + }); + + describe('create()', () => { + it('saves the rental and creates a RENTAL class_schedule row', async () => { + const dto: CreateRentalDto = { + classroomId: 1, + tenantId: 2, + startDate: '2026-03-01', + endDate: '2026-03-31', + }; + const classroom = { id: 1, departmentId: 10 } as Classroom; + const tenant = { id: 2, name: 'Tenant A' } as Tenant; + + classroomRepo.findOne.mockResolvedValue(classroom); + tenantRepo.findOne.mockResolvedValue(tenant); + rentalRepo.create.mockImplementation((entity) => ({ ...(entity as object) } as ClassroomRental)); + rentalRepo.save.mockImplementation((entity) => Promise.resolve({ ...(entity as object), id: 1 } as ClassroomRental)); + rentalRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder([])); + scheduleRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder([])); + scheduleRepo.findOne.mockResolvedValue(null); + scheduleRepo.create.mockImplementation((entity) => ({ ...(entity as object) } as ClassSchedule)); + scheduleRepo.save.mockImplementation((entity) => Promise.resolve({ ...(entity as object), id: 100 } as ClassSchedule)); + + const result = await service.create(dto); + + expect(result.id).toBe(1); + expect(rentalRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + classroomId: 1, + tenantId: 2, + startDate: '2026-03-01', + endDate: '2026-03-31', + status: 'active', + departmentId: 10, + }), + ); + expect(scheduleRepo.create).toHaveBeenCalledWith( + expect.objectContaining({ + classroomId: 1, + classId: null, + startTime: '00:00', + endTime: '23:59', + startDate: '2026-03-01', + endDate: '2026-03-31', + subject: 'Tenant A 租赁', + teacherId: null, + scheduleType: 'RENTAL', + rentalId: 1, + status: 'active', + departmentId: 10, + }), + ); + expect(scheduleRepo.save).toHaveBeenCalled(); + }); + }); + + describe('update()', () => { + it('updates the existing RENTAL schedule row when dates or classroom change', async () => { + const existingRental = { + id: 1, + classroomId: 1, + tenantId: 2, + startDate: '2026-03-01', + endDate: '2026-03-31', + status: 'active', + notes: '', + departmentId: 10, + tenant: { id: 2, name: 'Tenant A' } as Tenant, + classroom: { id: 1 } as Classroom, + } as ClassroomRental; + const updatedRental = { + ...existingRental, + startDate: '2026-04-01', + endDate: '2026-04-30', + } as ClassroomRental; + const existingSchedule = { id: 50, rentalId: 1, scheduleType: 'RENTAL', classroomId: 1 } as ClassSchedule; + + rentalRepo.findOne.mockResolvedValueOnce(existingRental).mockResolvedValueOnce(updatedRental); + rentalRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder([])); + scheduleRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder([])); + scheduleRepo.findOne.mockResolvedValue(existingSchedule); + + const dto: UpdateRentalDto = { startDate: '2026-04-01', endDate: '2026-04-30' }; + await service.update(1, dto); + + expect(rentalRepo.update).toHaveBeenCalledWith( + 1, + expect.objectContaining({ startDate: '2026-04-01', endDate: '2026-04-30' }), + ); + expect(scheduleRepo.update).toHaveBeenCalledWith( + 50, + expect.objectContaining({ + scheduleType: 'RENTAL', + rentalId: 1, + classroomId: 1, + startDate: '2026-04-01', + endDate: '2026-04-30', + status: 'active', + subject: 'Tenant A 租赁', + }), + ); + expect(scheduleRepo.create).not.toHaveBeenCalled(); + expect(scheduleRepo.delete).not.toHaveBeenCalled(); + }); + + it('deletes the RENTAL schedule row when status changes to cancelled', async () => { + const rental = { + id: 1, + classroomId: 1, + tenantId: 2, + startDate: '2026-03-01', + endDate: '2026-03-31', + status: 'active', + departmentId: 10, + tenant: { id: 2, name: 'Tenant A' } as Tenant, + } as ClassroomRental; + const cancelledRental = { ...rental, status: 'cancelled' } as ClassroomRental; + + rentalRepo.findOne.mockResolvedValueOnce(rental).mockResolvedValueOnce(cancelledRental); + + await service.update(1, { status: 'cancelled' }); + + expect(rentalRepo.update).toHaveBeenCalledWith(1, { status: 'cancelled' }); + expect(scheduleRepo.delete).toHaveBeenCalledWith({ rentalId: 1, scheduleType: 'RENTAL' }); + expect(scheduleRepo.findOne).not.toHaveBeenCalled(); + expect(scheduleRepo.update).not.toHaveBeenCalled(); + expect(scheduleRepo.create).not.toHaveBeenCalled(); + }); + }); + + describe('remove()', () => { + it('deletes the RENTAL schedule row and the rental', async () => { + const rental = { + id: 1, + classroomId: 1, + tenantId: 2, + startDate: '2026-03-01', + endDate: '2026-03-31', + status: 'active', + departmentId: 10, + tenant: { id: 2, name: 'Tenant A' } as Tenant, + } as ClassroomRental; + + rentalRepo.findOne.mockResolvedValue(rental); + + await service.remove(1); + + expect(scheduleRepo.delete).toHaveBeenCalledWith({ rentalId: 1, scheduleType: 'RENTAL' }); + expect(rentalRepo.delete).toHaveBeenCalledWith(1); + }); + }); +}); diff --git a/apps/server/src/classroom-rentals/classroom-rentals.service.ts b/apps/server/src/classroom-rentals/classroom-rentals.service.ts index ec76b96..df55820 100644 --- a/apps/server/src/classroom-rentals/classroom-rentals.service.ts +++ b/apps/server/src/classroom-rentals/classroom-rentals.service.ts @@ -198,6 +198,45 @@ export class ClassroomRentalsService { return { message: '删除成功' }; } + /** + * 同步租赁订单到 class_schedules(schedule_type = 'RENTAL') + */ + private async syncScheduleFromRental(rental: ClassroomRental, tenantName?: string) { + const name = tenantName || rental.tenant?.name || '租赁方'; + const weekDay = this.dateToWeekDay(rental.startDate); + let schedule = await this.scheduleRepo.findOne({ + where: { rentalId: rental.id, scheduleType: 'RENTAL' }, + }); + const data = { + classroomId: rental.classroomId, + classId: null, + weekDay, + startTime: '00:00', + endTime: '23:59', + startDate: rental.startDate, + endDate: rental.endDate, + subject: `${name} 租赁`, + teacherId: null, + scheduleType: 'RENTAL', + rentalId: rental.id, + status: 'active', + notes: rental.notes, + departmentId: rental.departmentId, + }; + if (schedule) { + await this.scheduleRepo.update(schedule.id, data); + } else { + schedule = this.scheduleRepo.create(data); + await this.scheduleRepo.save(schedule); + } + } + + private dateToWeekDay(date: string): number { + const d = new Date(date); + const day = d.getDay(); + return day === 0 ? 7 : day; + } + async attachContract(id: number, file: Express.Multer.File) { const rental = await this.findOne(id); this.ensureUploadDir(); diff --git a/apps/server/src/entities/class-schedule.entity.ts b/apps/server/src/entities/class-schedule.entity.ts index 6345781..dfde566 100644 --- a/apps/server/src/entities/class-schedule.entity.ts +++ b/apps/server/src/entities/class-schedule.entity.ts @@ -21,11 +21,11 @@ export class ClassSchedule { @PrimaryGeneratedColumn() id: number; - @Column({ name: 'class_id', type: 'integer' }) - classId: number; + @Column({ name: 'class_id', type: 'integer', nullable: true }) + classId: number | null; // Forward reference — Class entity - @ManyToOne('Class') + @ManyToOne('Class', { nullable: true }) @JoinColumn({ name: 'class_id' }) class: unknown; @@ -56,7 +56,7 @@ export class ClassSchedule { subject: string; @Column({ name: 'teacher_id', type: 'integer', nullable: true }) - teacherId: number; + teacherId: number | null; // Forward reference — User entity @ManyToOne('User', { nullable: true }) @@ -67,7 +67,7 @@ export class ClassSchedule { scheduleType: string; @Column({ name: 'rental_id', type: 'integer', nullable: true }) - rentalId: number; + rentalId: number | null; @Column({ name: 'status', length: 20, default: 'active' }) status: string; diff --git a/apps/server/src/schedules/schedules.controller.ts b/apps/server/src/schedules/schedules.controller.ts index a96cede..53c9d51 100644 --- a/apps/server/src/schedules/schedules.controller.ts +++ b/apps/server/src/schedules/schedules.controller.ts @@ -85,7 +85,7 @@ export class SchedulesController { const conflicts = await this.service.checkConflict( dto.classroomId, dto.weekDay, dto.startTime, dto.endTime, dto.startDate, dto.endDate, ); - const teacherIds = [...new Set(conflicts.map(c => c.teacherId).filter(Boolean))]; + const teacherIds = [...new Set(conflicts.map(c => c.teacherId).filter((id): id is number => id != null))]; if (teacherIds.length > 0) { void this.notificationsService.create({ recipientIds: teacherIds, @@ -129,7 +129,7 @@ export class SchedulesController { const conflicts = await this.service.checkConflict( existing.classroomId, existing.weekDay, existing.startTime, existing.endTime, existing.startDate, existing.endDate, ); - const teacherIds = [...new Set(conflicts.map(c => c.teacherId).filter(Boolean))]; + const teacherIds = [...new Set(conflicts.map(c => c.teacherId).filter((id): id is number => id != null))]; if (teacherIds.length > 0) { void this.notificationsService.create({ recipientIds: teacherIds, diff --git a/apps/server/src/schedules/schedules.service.spec.ts b/apps/server/src/schedules/schedules.service.spec.ts index df310fd..c5beb42 100644 --- a/apps/server/src/schedules/schedules.service.spec.ts +++ b/apps/server/src/schedules/schedules.service.spec.ts @@ -3,7 +3,7 @@ import { getRepositoryToken } from '@nestjs/typeorm'; import { ConflictException } from '@nestjs/common'; import { Repository } from 'typeorm'; import { SchedulesService } from './schedules.service'; -import { ClassSchedule } from '../entities/class-schedule.entity'; +import { ClassSchedule, ScheduleType } from '../entities/class-schedule.entity'; import { ClassroomRental } from '../entities/classroom-rental.entity'; import { Class } from '../entities/class.entity'; import { CampusScope } from '../common/campus-scope'; @@ -13,6 +13,8 @@ function mockQueryBuilder(results: T[] = []) { const qb = { where: jest.fn().mockReturnThis(), andWhere: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + addOrderBy: jest.fn().mockReturnThis(), getMany: jest.fn().mockResolvedValue(results), }; return qb; @@ -124,3 +126,57 @@ describe('SchedulesService — checkConflict', () => { ).resolves.toEqual([]); }); }); + +describe('SchedulesService — getClassroomOccupancy', () => { + let service: SchedulesService; + let scheduleRepo: jest.Mocked, 'createQueryBuilder'>>; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SchedulesService, + { provide: getRepositoryToken(ClassSchedule), useValue: { createQueryBuilder: jest.fn() } }, + { provide: getRepositoryToken(Class), useValue: { find: jest.fn().mockResolvedValue([]) } }, + { 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)); + }); + + it('returns both INTERNAL and RENTAL schedules', async () => { + const internalSchedule = { + id: 1, + scheduleType: ScheduleType.INTERNAL, + subject: '数学', + } as ClassSchedule; + const rentalSchedule = { + id: 2, + scheduleType: ScheduleType.RENTAL, + subject: 'Tenant A 租赁', + } as ClassSchedule; + const qb = mockQueryBuilder([internalSchedule, rentalSchedule]); + (scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(qb); + + const result = await service.getClassroomOccupancy(1, '2026-03-15'); + + expect(result).toHaveLength(2); + expect(result.map((s) => s.scheduleType)).toContain(ScheduleType.INTERNAL); + expect(result.map((s) => s.scheduleType)).toContain(ScheduleType.RENTAL); + expect(qb.where).toHaveBeenCalledWith('cs.classroomId = :classroomId', { classroomId: 1 }); + expect(qb.andWhere).toHaveBeenCalledWith('cs.status = :status', { status: 'active' }); + expect(qb.andWhere).toHaveBeenCalledWith('cs.scheduleType IN (:...scheduleTypes)', { + scheduleTypes: [ScheduleType.INTERNAL, ScheduleType.RENTAL], + }); + expect(qb.andWhere).toHaveBeenCalledWith('cs.startDate <= :date', { date: '2026-03-15' }); + expect(qb.andWhere).toHaveBeenCalledWith('cs.endDate >= :date', { date: '2026-03-15' }); + }); +}); diff --git a/apps/server/src/schedules/schedules.service.ts b/apps/server/src/schedules/schedules.service.ts index ef28da7..a448c9d 100644 --- a/apps/server/src/schedules/schedules.service.ts +++ b/apps/server/src/schedules/schedules.service.ts @@ -74,7 +74,7 @@ export class SchedulesService { await this.checkConflict(classroomId, weekDay, startTime, endTime, startDate, endDate, id); - await this.scheduleRepo.update(id, dto as Record); + await this.scheduleRepo.update(id, dto); return this.findOne(id); } @@ -168,7 +168,8 @@ export class SchedulesService { const qb = this.scheduleRepo .createQueryBuilder('cs') .where('cs.classroomId = :classroomId', { classroomId }) - .andWhere('cs.status = :status', { status: 'active' }); + .andWhere('cs.status = :status', { status: 'active' }) + .andWhere('cs.scheduleType IN (:...scheduleTypes)', { scheduleTypes: ['INTERNAL', 'RENTAL'] }); if (date) { qb.andWhere('cs.startDate <= :date', { date })