fix: keep room capacity and beds consistent

This commit is contained in:
2026-07-14 10:53:45 +08:00
parent 77714642a5
commit a93ba657a8
2 changed files with 206 additions and 5 deletions

View File

@@ -0,0 +1,136 @@
import { BadRequestException } from '@nestjs/common';
import { DataSource, EntityManager, Repository } from 'typeorm';
import { Bed } from '../entities/bed.entity';
import { Locker } from '../entities/locker.entity';
import { Occupancy } from '../entities/occupancy.entity';
import { Room } from '../entities/room.entity';
import { RoomExpense } from '../entities/room-expense.entity';
import { RoomsService } from './rooms.service';
describe('RoomsService — capacity consistency', () => {
const createService = (options?: {
room?: Partial<Room>;
beds?: Partial<Bed>[];
activeOccupantCount?: number;
}) => {
const room = { id: 1, capacity: 2, status: 'full', ...options?.room } as Room;
const beds = (options?.beds ?? [
{ id: 1, roomId: 1, bedNumber: '1号床' },
{ id: 2, roomId: 1, bedNumber: '2号床' },
]) as Bed[];
const roomRepo = {
findOne: jest
.fn()
.mockResolvedValueOnce(room)
.mockResolvedValue({ ...room, capacity: 4 }),
update: jest.fn().mockResolvedValue(undefined),
} as unknown as Repository<Room>;
const bedRepo = {
find: jest.fn().mockResolvedValue(beds),
create: jest.fn((value) => value),
save: jest.fn(async (value) => value),
} as unknown as Repository<Bed>;
const occupancyRepo = {
count: jest.fn().mockResolvedValue(options?.activeOccupantCount ?? 2),
} as unknown as Repository<Occupancy>;
const manager = {
getRepository: jest.fn((entity) => {
if (entity === Room) return roomRepo;
if (entity === Bed) return bedRepo;
if (entity === Occupancy) return occupancyRepo;
throw new Error(`Unexpected repository: ${String(entity)}`);
}),
} as unknown as EntityManager;
const dataSource = {
transaction: jest.fn(async (callback) => callback(manager)),
} as unknown as DataSource;
const service = new RoomsService(
roomRepo,
occupancyRepo,
{} as Repository<RoomExpense>,
bedRepo,
{} as Repository<Locker>,
dataSource,
);
return { service, roomRepo, bedRepo, occupancyRepo };
};
it('automatically creates missing beds when capacity increases', async () => {
const { service, roomRepo, bedRepo } = createService();
await service.update(1, { capacity: 4 });
expect(bedRepo.create).toHaveBeenNthCalledWith(1, { roomId: 1, bedNumber: '3号床' });
expect(bedRepo.create).toHaveBeenNthCalledWith(2, { roomId: 1, bedNumber: '4号床' });
expect(bedRepo.save).toHaveBeenCalledWith([
{ roomId: 1, bedNumber: '3号床' },
{ roomId: 1, bedNumber: '4号床' },
]);
expect(roomRepo.update).toHaveBeenCalledWith(1, { capacity: 4, status: 'available' });
});
it('rejects capacity lower than the active occupant count', async () => {
const { service, roomRepo, bedRepo } = createService({
room: { capacity: 4, status: 'available' },
beds: [{ id: 1, roomId: 1, bedNumber: '1号床' }],
activeOccupantCount: 3,
});
await expect(service.update(1, { capacity: 2 })).rejects.toThrow(
new BadRequestException('额定人数不能少于当前入住人数,当前有 3 人入住'),
);
expect(roomRepo.update).not.toHaveBeenCalled();
expect(bedRepo.save).not.toHaveBeenCalled();
});
it('rejects capacity lower than the existing bed count', async () => {
const { service, roomRepo, bedRepo } = createService({
room: { capacity: 4, status: 'available' },
activeOccupantCount: 1,
beds: [
{ id: 1, roomId: 1, bedNumber: '1号床' },
{ id: 2, roomId: 1, bedNumber: '2号床' },
{ id: 3, roomId: 1, bedNumber: '3号床' },
{ id: 4, roomId: 1, bedNumber: '4号床' },
],
});
await expect(service.update(1, { capacity: 3 })).rejects.toThrow(
new BadRequestException(
'额定人数不能少于现有床位数,当前有 4 张床位,请先删除多余的空闲床位',
),
);
expect(roomRepo.update).not.toHaveBeenCalled();
expect(bedRepo.save).not.toHaveBeenCalled();
});
it('marks the room full when a valid capacity reduction reaches the occupant count', async () => {
const { service, roomRepo, bedRepo } = createService({
room: { capacity: 4, status: 'available' },
activeOccupantCount: 2,
beds: [
{ id: 1, roomId: 1, bedNumber: '1号床' },
{ id: 2, roomId: 1, bedNumber: '2号床' },
],
});
await service.update(1, { capacity: 2 });
expect(roomRepo.update).toHaveBeenCalledWith(1, { capacity: 2, status: 'full' });
expect(bedRepo.save).not.toHaveBeenCalled();
});
it('updates other room fields without changing beds', async () => {
const { service, roomRepo, bedRepo, occupancyRepo } = createService();
await service.update(1, { building: '2号楼' });
expect(roomRepo.update).toHaveBeenCalledWith(1, { building: '2号楼' });
expect(bedRepo.find).not.toHaveBeenCalled();
expect(occupancyRepo.count).not.toHaveBeenCalled();
});
});

View File

@@ -1,6 +1,15 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Like, IsNull, Not, In, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
import {
DataSource,
Repository,
Like,
IsNull,
Not,
In,
LessThanOrEqual,
MoreThanOrEqual,
} from 'typeorm';
import { Room } from '../entities/room.entity';
import { Occupancy } from '../entities/occupancy.entity';
@@ -19,6 +28,7 @@ export class RoomsService {
@InjectRepository(RoomExpense) private roomExpRepo: Repository<RoomExpense>,
@InjectRepository(Bed) private bedRepo: Repository<Bed>,
@InjectRepository(Locker) private lockerRepo: Repository<Locker>,
private dataSource: DataSource,
) {}
/**
@@ -116,9 +126,54 @@ export class RoomsService {
}
async update(id: number, dto: UpdateRoomDto) {
await this.findOne(id);
await this.repo.update(id, dto);
return this.repo.findOne({ where: { id } });
return this.dataSource.transaction(async (manager) => {
const roomRepo = manager.getRepository(Room);
const bedRepo = manager.getRepository(Bed);
const occupancyRepo = manager.getRepository(Occupancy);
const room = await roomRepo.findOne({ where: { id } });
if (!room) throw new NotFoundException('宿舍不存在');
if (dto.capacity !== undefined) {
const [beds, activeOccupantCount] = await Promise.all([
bedRepo.find({ where: { roomId: id }, order: { bedNumber: 'ASC' } }),
occupancyRepo.count({ where: { roomId: id, checkOutDate: IsNull() } }),
]);
if (dto.capacity < activeOccupantCount) {
throw new BadRequestException(
`额定人数不能少于当前入住人数,当前有 ${activeOccupantCount} 人入住`,
);
}
if (dto.capacity < beds.length) {
throw new BadRequestException(
`额定人数不能少于现有床位数,当前有 ${beds.length} 张床位,请先删除多余的空闲床位`,
);
}
if (dto.capacity > beds.length) {
const countToCreate = dto.capacity - beds.length;
const start = this.getNextBedNumber(beds);
const newBeds = Array.from({ length: countToCreate }, (_, index) =>
bedRepo.create({ roomId: id, bedNumber: `${start + index}号床` }),
);
await bedRepo.save(newBeds);
}
if (
dto.status === undefined &&
room.status !== 'maintenance' &&
room.status !== 'archived'
) {
dto = {
...dto,
status: activeOccupantCount >= dto.capacity ? 'full' : 'available',
};
}
}
await roomRepo.update(id, dto);
return roomRepo.findOne({ where: { id } });
});
}
async remove(id: number) {
@@ -413,6 +468,14 @@ export class RoomsService {
await this.bedRepo.save(beds);
}
private getNextBedNumber(beds: Pick<Bed, 'bedNumber'>[]): number {
const numbers = beds.map((bed) => {
const match = bed.bedNumber.match(/^\d+/);
return match ? parseInt(match[0], 10) : 0;
});
return numbers.length > 0 ? Math.max(...numbers) + 1 : 1;
}
private async assertCanAddBeds(room: Room, count: number): Promise<void> {
const existingCount = await this.bedRepo.count({ where: { roomId: room.id } });
this.assertCanAddBedsFromCount(room, existingCount, count);
@@ -421,7 +484,9 @@ export class RoomsService {
private assertCanAddBedsFromCount(room: Room, existingCount: number, count: number): void {
const remaining = Math.max((room.capacity ?? 0) - existingCount, 0);
if (count > remaining) {
throw new BadRequestException(`床位不能超过额定人数,当前已有 ${existingCount} 张,额定 ${room.capacity} 张,最多还能添加 ${remaining}`);
throw new BadRequestException(
`床位不能超过额定人数,当前已有 ${existingCount} 张,额定 ${room.capacity} 张,最多还能添加 ${remaining}`,
);
}
}