Task 3: sync RENTAL class_schedule on rental create/update/delete

- ClassroomRentalsService.create/update now upsert a ClassSchedule row with schedule_type='RENTAL' and rental_id set.
- ClassroomRentalsService.remove deletes the synced schedule row; update to 'cancelled' also removes it.
- SchedulesService.getClassroomOccupancy explicitly returns INTERNAL and RENTAL schedules.
- Make classId/teacherId/rentalId nullable in ClassSchedule entity to support rental schedules.
- Add unit tests for rental schedule sync and mixed-type occupancy.
This commit is contained in:
2026-07-06 17:55:08 +08:00
parent 5ea49d0a9e
commit 38f6e18cca
6 changed files with 310 additions and 10 deletions

View File

@@ -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<T>(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<Repository<ClassroomRental>, 'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder'>
>;
let classroomRepo: jest.Mocked<Pick<Repository<Classroom>, 'findOne'>>;
let tenantRepo: jest.Mocked<Pick<Repository<Tenant>, 'findOne'>>;
let scheduleRepo: jest.Mocked<
Pick<Repository<ClassSchedule>, '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<Repository<ClassroomRental>, 'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder'>
>;
classroomRepo = { findOne: jest.fn() } as jest.Mocked<Pick<Repository<Classroom>, 'findOne'>>;
tenantRepo = { findOne: jest.fn() } as jest.Mocked<Pick<Repository<Tenant>, 'findOne'>>;
scheduleRepo = {
findOne: jest.fn(),
save: jest.fn(),
create: jest.fn(),
update: jest.fn(),
delete: jest.fn(),
createQueryBuilder: jest.fn(),
} as jest.Mocked<
Pick<Repository<ClassSchedule>, '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>(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<ClassroomRental>([]));
scheduleRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder<ClassSchedule>([]));
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<ClassroomRental>([]));
scheduleRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder<ClassSchedule>([]));
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);
});
});
});

View File

@@ -198,6 +198,45 @@ export class ClassroomRentalsService {
return { message: '删除成功' };
}
/**
* 同步租赁订单到 class_schedulesschedule_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();

View File

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

View File

@@ -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,

View File

@@ -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<T>(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<Pick<Repository<ClassSchedule>, '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>(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<ClassSchedule>([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' });
});
});

View File

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