forked from wangziqi/gongxue-base
refactor: resolve remaining field audit issues
This commit is contained in:
21
apps/server/src/rooms/dto/room.dto.spec.ts
Normal file
21
apps/server/src/rooms/dto/room.dto.spec.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { CreateRoomDto, UpdateRoomDto } from './room.dto';
|
||||
|
||||
const pipe = new ValidationPipe({ transform: true, whitelist: true });
|
||||
const transform = <T extends object>(metatype: new () => T, value: unknown) =>
|
||||
pipe.transform(value, { type: 'body', metatype });
|
||||
|
||||
describe('room gender DTO', () => {
|
||||
it.each(['男', '女', null])('accepts %p', async (gender) => {
|
||||
await expect(
|
||||
transform(CreateRoomDto, { roomNumber: '1-101', capacity: 4, gender }),
|
||||
).resolves.toMatchObject({ gender });
|
||||
await expect(transform(UpdateRoomDto, { gender })).resolves.toMatchObject({ gender });
|
||||
});
|
||||
|
||||
it.each(['不限', 'male', '女生宿舍'])('rejects unsupported value %p', async (gender) => {
|
||||
await expect(
|
||||
transform(CreateRoomDto, { roomNumber: '1-101', capacity: 4, gender }),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { IsString, IsOptional, IsInt, IsEnum, Min, IsNumber } from 'class-validator';
|
||||
import { IsString, IsOptional, IsInt, IsEnum, IsIn, Min, IsNumber } from 'class-validator';
|
||||
|
||||
export class CreateRoomDto {
|
||||
@IsString()
|
||||
@@ -20,6 +20,10 @@ export class CreateRoomDto {
|
||||
@IsString()
|
||||
roomType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(['男', '女'])
|
||||
gender?: '男' | '女' | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
rentalCategory?: string;
|
||||
@@ -27,7 +31,6 @@ export class CreateRoomDto {
|
||||
@IsOptional()
|
||||
@IsNumber()
|
||||
monthlyRate?: number;
|
||||
|
||||
}
|
||||
|
||||
export class UpdateRoomDto {
|
||||
@@ -53,8 +56,8 @@ export class UpdateRoomDto {
|
||||
roomType?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
gender?: string;
|
||||
@IsIn(['男', '女'])
|
||||
gender?: '男' | '女' | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(['available', 'full', 'maintenance'])
|
||||
|
||||
@@ -67,6 +67,7 @@ export class RoomsController {
|
||||
{ header: '宿舍类型', key: 'roomType', width: 12 },
|
||||
{ header: '租赁类型(long/short)', key: 'rentalCategory', width: 18 },
|
||||
{ header: '月租金', key: 'monthlyRate', width: 10 },
|
||||
{ header: '宿舍性别(男/女)', key: 'gender', width: 18 },
|
||||
];
|
||||
ws.addRow({
|
||||
roomNumber: '4-102',
|
||||
@@ -76,6 +77,7 @@ export class RoomsController {
|
||||
roomType: '四人间',
|
||||
rentalCategory: 'long',
|
||||
monthlyRate: 800,
|
||||
gender: '男',
|
||||
});
|
||||
ws.addRow({
|
||||
roomNumber: '2-201',
|
||||
@@ -85,6 +87,7 @@ export class RoomsController {
|
||||
roomType: '单人间',
|
||||
rentalCategory: 'short',
|
||||
monthlyRate: 0,
|
||||
gender: '女',
|
||||
});
|
||||
res.setHeader(
|
||||
'Content-Type',
|
||||
@@ -168,11 +171,7 @@ export class RoomsController {
|
||||
|
||||
@Put(':roomId/beds/:id')
|
||||
@RequirePermission('room:edit')
|
||||
updateBed(
|
||||
@Param('roomId') roomId: string,
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateBedDto,
|
||||
) {
|
||||
updateBed(@Param('roomId') roomId: string, @Param('id') id: string, @Body() dto: UpdateBedDto) {
|
||||
return this.service.updateBed(+roomId, +id, dto);
|
||||
}
|
||||
|
||||
@@ -340,16 +339,26 @@ export class RoomsController {
|
||||
roomType?: string;
|
||||
rentalCategory?: string;
|
||||
monthlyRate?: number;
|
||||
gender?: '男' | '女';
|
||||
}[] = [];
|
||||
ws.eachRow((row, idx) => {
|
||||
if (idx === 1) return;
|
||||
const rentalCategoryRaw = String(row.getCell(6).value || '').trim().toLowerCase();
|
||||
const rentalCategoryRaw = String(row.getCell(6).value || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
const rentalCategory =
|
||||
rentalCategoryRaw === 'long' || rentalCategoryRaw === 'short'
|
||||
? rentalCategoryRaw
|
||||
: undefined;
|
||||
const monthlyRateRaw = Number(row.getCell(7).value);
|
||||
const monthlyRate = isNaN(monthlyRateRaw) ? undefined : monthlyRateRaw;
|
||||
const genderRaw = String(row.getCell(8).value || '').trim();
|
||||
const gender =
|
||||
genderRaw === '男' || genderRaw === '男生'
|
||||
? '男'
|
||||
: genderRaw === '女' || genderRaw === '女生'
|
||||
? '女'
|
||||
: undefined;
|
||||
rows.push({
|
||||
roomNumber: String(row.getCell(1).value || ''),
|
||||
building: String(row.getCell(2).value || '') || undefined,
|
||||
@@ -358,6 +367,7 @@ export class RoomsController {
|
||||
roomType: String(row.getCell(5).value || '').trim() || undefined,
|
||||
rentalCategory,
|
||||
monthlyRate,
|
||||
gender,
|
||||
});
|
||||
});
|
||||
const result = await this.service.batchImport(rows);
|
||||
|
||||
43
apps/server/src/rooms/rooms.gender.spec.ts
Normal file
43
apps/server/src/rooms/rooms.gender.spec.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { RoomsService } from './rooms.service';
|
||||
|
||||
function createService(room: { id: number; gender: '男' | '女' | null }, activeCount: number) {
|
||||
const repo = {
|
||||
findOne: jest.fn().mockResolvedValue(room),
|
||||
update: jest.fn(),
|
||||
};
|
||||
const occRepo = { count: jest.fn().mockResolvedValue(activeCount) };
|
||||
const service = new RoomsService(
|
||||
repo as never,
|
||||
occRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
return { service, repo, occRepo };
|
||||
}
|
||||
|
||||
describe('RoomsService — room gender maintenance', () => {
|
||||
it('allows an empty room gender to be changed or cleared', async () => {
|
||||
const { service, repo } = createService({ id: 1, gender: '男' }, 0);
|
||||
|
||||
await service.update(1, { gender: null });
|
||||
|
||||
expect(repo.update).toHaveBeenCalledWith(1, { gender: null });
|
||||
});
|
||||
|
||||
it('rejects changing gender while students are living in the room', async () => {
|
||||
const { service, repo } = createService({ id: 1, gender: '男' }, 1);
|
||||
|
||||
await expect(service.update(1, { gender: '女' })).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(repo.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does not query occupants when gender is unchanged or omitted', async () => {
|
||||
const { service, occRepo } = createService({ id: 1, gender: '男' }, 1);
|
||||
|
||||
await service.update(1, { roomType: '四人间' });
|
||||
|
||||
expect(occRepo.count).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -114,7 +114,13 @@ export class RoomsService {
|
||||
}
|
||||
|
||||
async update(id: number, dto: UpdateRoomDto) {
|
||||
await this.findOne(id);
|
||||
const room = await this.findOne(id);
|
||||
if (Object.prototype.hasOwnProperty.call(dto, 'gender') && dto.gender !== room.gender) {
|
||||
const activeCount = await this.occRepo.count({
|
||||
where: { roomId: id, checkOutDate: IsNull() },
|
||||
});
|
||||
if (activeCount > 0) throw new BadRequestException('该宿舍有在住人员,无法修改宿舍性别');
|
||||
}
|
||||
await this.repo.update(id, dto);
|
||||
return this.repo.findOne({ where: { id } });
|
||||
}
|
||||
@@ -202,10 +208,7 @@ export class RoomsService {
|
||||
for (const occ of occupancies) {
|
||||
if (!occMap.has(occ.roomId)) occMap.set(occ.roomId, []);
|
||||
const checkIn = new Date(occ.checkInDate);
|
||||
const days = Math.max(
|
||||
1,
|
||||
Math.ceil((refTime - checkIn.getTime()) / (1000 * 60 * 60 * 24)),
|
||||
);
|
||||
const days = Math.max(1, Math.ceil((refTime - checkIn.getTime()) / (1000 * 60 * 60 * 24)));
|
||||
occMap.get(occ.roomId)!.push({
|
||||
studentId: occ.studentId,
|
||||
studentName: occ.student?.name || '未知',
|
||||
@@ -250,8 +253,11 @@ export class RoomsService {
|
||||
const allSameOrg = occ.every((o: any) => o.organization && o.organization === orgs[0]);
|
||||
orgLabel = allSameOrg ? `均为${orgs[0]}人员` : `存在${orgs.join('、')}人员`;
|
||||
}
|
||||
const organizationColors = [...new Set(occ.map((o: any) => o.organizationColor).filter(Boolean))];
|
||||
const organizationColor: string | null = organizationColors.length === 1 ? organizationColors[0] : null;
|
||||
const organizationColors = [
|
||||
...new Set(occ.map((o: any) => o.organizationColor).filter(Boolean)),
|
||||
];
|
||||
const organizationColor: string | null =
|
||||
organizationColors.length === 1 ? organizationColors[0] : null;
|
||||
const organizationIds = [...new Set(occ.map((o: any) => o.organizationId).filter(Boolean))];
|
||||
return {
|
||||
id: room.id,
|
||||
@@ -274,7 +280,14 @@ export class RoomsService {
|
||||
...new Map(
|
||||
occupancies
|
||||
.filter((o) => o.responsibleOrganizationId && o.responsibleOrganization)
|
||||
.map((o) => [o.responsibleOrganizationId, { id: o.responsibleOrganizationId, name: o.responsibleOrganization.name, color: o.responsibleOrganization.color || null }]),
|
||||
.map((o) => [
|
||||
o.responsibleOrganizationId,
|
||||
{
|
||||
id: o.responsibleOrganizationId,
|
||||
name: o.responsibleOrganization.name,
|
||||
color: o.responsibleOrganization.color || null,
|
||||
},
|
||||
]),
|
||||
).values(),
|
||||
].sort((a, b) => a.name.localeCompare(b.name)),
|
||||
};
|
||||
@@ -289,6 +302,7 @@ export class RoomsService {
|
||||
roomType?: string;
|
||||
rentalCategory?: string;
|
||||
monthlyRate?: number;
|
||||
gender?: '男' | '女';
|
||||
}[],
|
||||
) {
|
||||
let imported = 0;
|
||||
@@ -314,6 +328,7 @@ export class RoomsService {
|
||||
roomType: row.roomType || parsed.roomType || undefined,
|
||||
rentalCategory: row.rentalCategory || undefined,
|
||||
monthlyRate: row.monthlyRate ?? undefined,
|
||||
gender: row.gender ?? undefined,
|
||||
}),
|
||||
);
|
||||
imported++;
|
||||
@@ -336,7 +351,10 @@ export class RoomsService {
|
||||
async getRoomAvailableBeds(roomId: number): Promise<Bed[]> {
|
||||
const room = await this.repo.findOne({ where: { id: roomId } });
|
||||
if (!room) throw new NotFoundException('宿舍不存在');
|
||||
return this.bedRepo.find({ where: { roomId, status: 'available' }, order: { bedNumber: 'ASC' } });
|
||||
return this.bedRepo.find({
|
||||
where: { roomId, status: 'available' },
|
||||
order: { bedNumber: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async createBed(roomId: number, dto: CreateBedDto): Promise<Bed> {
|
||||
@@ -377,7 +395,7 @@ export class RoomsService {
|
||||
if (!room) throw new NotFoundException('宿舍不存在');
|
||||
if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加床位');
|
||||
const existing = await this.bedRepo.find({ where: { roomId }, order: { bedNumber: 'ASC' } });
|
||||
const numbers = existing.map(b => {
|
||||
const numbers = existing.map((b) => {
|
||||
const match = b.bedNumber.match(/^\d+/);
|
||||
return match ? parseInt(match[0]) : 0;
|
||||
});
|
||||
@@ -400,14 +418,19 @@ export class RoomsService {
|
||||
async getRoomAvailableLockers(roomId: number): Promise<Locker[]> {
|
||||
const room = await this.repo.findOne({ where: { id: roomId } });
|
||||
if (!room) throw new NotFoundException('宿舍不存在');
|
||||
return this.lockerRepo.find({ where: { roomId, status: 'available' }, order: { lockerNumber: 'ASC' } });
|
||||
return this.lockerRepo.find({
|
||||
where: { roomId, status: 'available' },
|
||||
order: { lockerNumber: 'ASC' },
|
||||
});
|
||||
}
|
||||
|
||||
async createLocker(roomId: number, dto: CreateLockerDto): Promise<Locker> {
|
||||
const room = await this.repo.findOne({ where: { id: roomId } });
|
||||
if (!room) throw new NotFoundException('宿舍不存在');
|
||||
if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加柜子');
|
||||
const existing = await this.lockerRepo.findOne({ where: { roomId, lockerNumber: dto.lockerNumber } });
|
||||
const existing = await this.lockerRepo.findOne({
|
||||
where: { roomId, lockerNumber: dto.lockerNumber },
|
||||
});
|
||||
if (existing) throw new BadRequestException('该柜子编号已存在');
|
||||
const locker = this.lockerRepo.create({ ...dto, roomId });
|
||||
return this.lockerRepo.save(locker);
|
||||
@@ -420,7 +443,9 @@ export class RoomsService {
|
||||
throw new BadRequestException('该柜子有人占用,请先释放');
|
||||
}
|
||||
if (dto.lockerNumber && dto.lockerNumber !== locker.lockerNumber) {
|
||||
const dup = await this.lockerRepo.findOne({ where: { roomId, lockerNumber: dto.lockerNumber } });
|
||||
const dup = await this.lockerRepo.findOne({
|
||||
where: { roomId, lockerNumber: dto.lockerNumber },
|
||||
});
|
||||
if (dup) throw new BadRequestException('该柜子编号已存在');
|
||||
}
|
||||
Object.assign(locker, dto);
|
||||
@@ -438,8 +463,11 @@ export class RoomsService {
|
||||
const room = await this.repo.findOne({ where: { id: roomId } });
|
||||
if (!room) throw new NotFoundException('宿舍不存在');
|
||||
if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加柜子');
|
||||
const existing = await this.lockerRepo.find({ where: { roomId }, order: { lockerNumber: 'ASC' } });
|
||||
const numbers = existing.map(b => {
|
||||
const existing = await this.lockerRepo.find({
|
||||
where: { roomId },
|
||||
order: { lockerNumber: 'ASC' },
|
||||
});
|
||||
const numbers = existing.map((b) => {
|
||||
const match = b.lockerNumber.match(/^\d+/);
|
||||
return match ? parseInt(match[0]) : 0;
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user