From f98c0ccaa8c0b9922132ad48613eec6fa6aa9f20 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Mon, 13 Jul 2026 15:19:09 +0800 Subject: [PATCH] refactor(rooms): remove dorm gender restrictions --- apps/admin/src/pages/Rooms/index.tsx | 25 +---------- .../database-migrations.room-gender.spec.ts | 38 ++++++++++++++++ .../database/database-migrations.service.ts | 17 ++++++++ apps/server/src/entities/room.entity.ts | 3 -- .../src/occupancies/occupancies.controller.ts | 3 +- .../occupancies/occupancies.service.spec.ts | 2 +- .../src/occupancies/occupancies.service.ts | 41 ------------------ apps/server/src/rooms/dto/room.dto.spec.ts | 21 --------- apps/server/src/rooms/dto/room.dto.ts | 10 +---- apps/server/src/rooms/rooms.controller.ts | 14 ------ apps/server/src/rooms/rooms.gender.spec.ts | 43 ------------------- apps/server/src/rooms/rooms.service.ts | 10 +---- 12 files changed, 61 insertions(+), 166 deletions(-) create mode 100644 apps/server/src/database/database-migrations.room-gender.spec.ts delete mode 100644 apps/server/src/rooms/dto/room.dto.spec.ts delete mode 100644 apps/server/src/rooms/rooms.gender.spec.ts diff --git a/apps/admin/src/pages/Rooms/index.tsx b/apps/admin/src/pages/Rooms/index.tsx index c7cd535..b7ba609 100644 --- a/apps/admin/src/pages/Rooms/index.tsx +++ b/apps/admin/src/pages/Rooms/index.tsx @@ -159,10 +159,7 @@ const RoomsPage: React.FC = () => { const handleSave = async () => { const values = await form.validateFields(); - const payload = { - ...values, - gender: values.gender === '__unset__' ? null : values.gender, - }; + const payload = values; setSaving(true); try { if (editing) { @@ -341,13 +338,6 @@ const RoomsPage: React.FC = () => { /> ), }, - { - title: '性别', - dataIndex: 'gender', - width: 80, - render: (v: string | null) => - v ? {v}生宿舍 : '未指定', - }, { title: '状态', dataIndex: 'status', @@ -391,7 +381,7 @@ const RoomsPage: React.FC = () => { onClick={() => { const rec = record as { id: number }; setEditing(rec); - form.setFieldsValue({ ...rec, gender: (record as any).gender ?? '__unset__' }); + form.setFieldsValue(rec); setModalOpen(true); }} > @@ -473,7 +463,6 @@ const RoomsPage: React.FC = () => { onClick={() => { setEditing(null); form.resetFields(); - form.setFieldsValue({ gender: '__unset__' }); setModalOpen(true); }} > @@ -577,15 +566,6 @@ const RoomsPage: React.FC = () => { placeholder="留空自动解析" /> - - {
楼栋:{drawerRoom.building || '-'}
楼层:{drawerRoom.floor ?? '-'}
类型:{drawerRoom.roomType || '-'}
-
宿舍性别:{drawerRoom.gender ? `${drawerRoom.gender}生宿舍` : '未指定'}
额定人数:{drawerRoom.capacity}
租赁类别:{drawerRoom.rentalCategory === 'long' ? '长租' : '短租'}
月租金:{drawerRoom.monthlyRate ? `¥${drawerRoom.monthlyRate}` : '-'}
diff --git a/apps/server/src/database/database-migrations.room-gender.spec.ts b/apps/server/src/database/database-migrations.room-gender.spec.ts new file mode 100644 index 0000000..3728e3c --- /dev/null +++ b/apps/server/src/database/database-migrations.room-gender.spec.ts @@ -0,0 +1,38 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getDataSourceToken } from '@nestjs/typeorm'; +import { DatabaseMigrationsService } from './database-migrations.service'; + +describe('DatabaseMigrationsService — room gender cleanup', () => { + it('drops the retired rooms.gender column', async () => { + const runner = { + connect: jest.fn(), + release: jest.fn(), + getTables: jest.fn().mockResolvedValue([{ name: 'rooms' }]), + getTable: jest.fn().mockResolvedValue({ + name: 'rooms', + columns: [{ name: 'id' }, { name: 'gender' }], + }), + dropColumn: jest.fn().mockResolvedValue(undefined), + }; + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DatabaseMigrationsService, + { + provide: getDataSourceToken(), + useValue: { + options: { type: 'better-sqlite3' }, + createQueryRunner: jest.fn().mockReturnValue(runner), + }, + }, + ], + }).compile(); + const service = module.get(DatabaseMigrationsService) as DatabaseMigrationsService & { + removeUnusedRoomColumns(): Promise; + }; + + await service.removeUnusedRoomColumns(); + + expect(runner.dropColumn).toHaveBeenCalledWith('rooms', 'gender'); + expect(runner.release).toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/database/database-migrations.service.ts b/apps/server/src/database/database-migrations.service.ts index b348687..305cb95 100644 --- a/apps/server/src/database/database-migrations.service.ts +++ b/apps/server/src/database/database-migrations.service.ts @@ -15,6 +15,7 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap { await this.normalizeClassDates(); await this.protectAttendanceHistory(); await this.removeUnusedClassroomColumns(); + await this.removeUnusedRoomColumns(); await this.cleanupDepositRefundColumns(); await this.removeUnusedClassStudentColumns(); await this.normalizeClassroomStatuses(); @@ -39,6 +40,22 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap { } } + private async removeUnusedRoomColumns(): Promise { + const runner = this.dataSource.createQueryRunner(); + await runner.connect(); + try { + const tables = await runner.getTables(['rooms']); + if (tables.length === 0) return; + + const table = await runner.getTable('rooms'); + if (table?.columns.some((column) => column.name === 'gender')) { + await runner.dropColumn('rooms', 'gender'); + } + } finally { + await runner.release(); + } + } + private async cleanupDepositRefundColumns(): Promise { const runner = this.dataSource.createQueryRunner(); await runner.connect(); diff --git a/apps/server/src/entities/room.entity.ts b/apps/server/src/entities/room.entity.ts index fea5ae8..c5290b2 100644 --- a/apps/server/src/entities/room.entity.ts +++ b/apps/server/src/entities/room.entity.ts @@ -33,9 +33,6 @@ export class Room { @Column({ name: 'room_type', length: 20, nullable: true }) roomType: string; - @Column({ length: 10, nullable: true }) - gender: '男' | '女' | null; - @Column({ name: 'rental_category', length: 10, default: 'short' }) rentalCategory: string; diff --git a/apps/server/src/occupancies/occupancies.controller.ts b/apps/server/src/occupancies/occupancies.controller.ts index 4e4a5ee..ee0aa79 100644 --- a/apps/server/src/occupancies/occupancies.controller.ts +++ b/apps/server/src/occupancies/occupancies.controller.ts @@ -300,8 +300,7 @@ export class OccupanciesController { helpWs.addRow(['4. 已存在的学生(按姓名匹配)会自动补充缺失信息(性别、民族等)']); helpWs.addRow(['5. 已有在住记录的学生会自动跳过,不会重复入住']); helpWs.addRow(['6. 填了离宿时间的记录会直接标记为已退宿(用于导入历史数据)']); - helpWs.addRow(['7. 性别约束:同一宿舍只能住同性别学生,首位入住者确定宿舍性别']); - helpWs.addRow(['8. 床位号仅做标识参考,不影响入住逻辑']); + helpWs.addRow(['7. 床位号仅做标识参考,不影响入住逻辑']); helpWs.getRow(1).font = { bold: true, size: 14 }; res.setHeader( 'Content-Type', diff --git a/apps/server/src/occupancies/occupancies.service.spec.ts b/apps/server/src/occupancies/occupancies.service.spec.ts index 982c9f0..45ff838 100644 --- a/apps/server/src/occupancies/occupancies.service.spec.ts +++ b/apps/server/src/occupancies/occupancies.service.spec.ts @@ -16,7 +16,7 @@ describe('OccupanciesService — responsible organization', () => { save: jest.fn(async (value) => ({ ...value, id: 10 })), } as any as Repository; const roomRepo = { - findOne: jest.fn().mockResolvedValue({ id: 2, capacity: 4, gender: null }), + findOne: jest.fn().mockResolvedValue({ id: 2, capacity: 4 }), update: jest.fn(), } as any as Repository; const studentRepo = { diff --git a/apps/server/src/occupancies/occupancies.service.ts b/apps/server/src/occupancies/occupancies.service.ts index 9252e98..79444bf 100644 --- a/apps/server/src/occupancies/occupancies.service.ts +++ b/apps/server/src/occupancies/occupancies.service.ts @@ -61,14 +61,8 @@ export class OccupanciesService { const count = await this.repo.count({ where: { roomId: dto.roomId, checkOutDate: IsNull() } }); if (count >= room.capacity) throw new BadRequestException('宿舍已满'); - // 房间级别性别约束 const student = await this.studentRepo.findOne({ where: { id: dto.studentId } }); if (!student) throw new NotFoundException('学生不存在'); - if (student.gender && room.gender && student.gender !== room.gender) { - throw new BadRequestException( - `该宿舍当前为${room.gender}生寝室,${student.gender}生无法入住`, - ); - } // 床位校验 if (dto.bedId) { @@ -107,11 +101,6 @@ export class OccupanciesService { await this.lockerRepo.update(dto.lockerId, { status: 'occupied' }); } - // 首位入住者确定房间性别 - if ((student.gender === '男' || student.gender === '女') && !room.gender) { - await this.roomRepo.update(room.id, { gender: student.gender }); - } - // 更新宿舍状态 if (count + 1 >= room.capacity) { await this.roomRepo.update(room.id, { status: 'full' }); @@ -173,12 +162,6 @@ export class OccupanciesService { }); if (count >= newRoom.capacity) throw new BadRequestException('目标宿舍已满'); - // 换房性别约束检查 - const student = await runner.manager.findOne(Student, { where: { id: oldOcc.studentId } }); - if (student?.gender && newRoom.gender && student.gender !== newRoom.gender) { - throw new BadRequestException(`目标宿舍为${newRoom.gender}生寝室,无法换入`); - } - // 新床位校验 if (dto.newBedId) { const newBed = await runner.manager.findOne(Bed, { @@ -223,11 +206,6 @@ export class OccupanciesService { await runner.manager.update(Locker, dto.newLockerId, { status: 'occupied' }); } - // 首位入住者确定新房性别 - if ((student?.gender === '男' || student?.gender === '女') && !newRoom.gender) { - await runner.manager.update(Room, newRoom.id, { gender: student.gender }); - } - if (count + 1 >= newRoom.capacity) { await runner.manager.update(Room, newRoom.id, { status: 'full' }); } @@ -472,15 +450,6 @@ export class OccupanciesService { continue; } - // 5. 房间级别性别约束 - if (student.gender && room.gender && student.gender !== room.gender) { - errors.push( - `第${rowNum}行: 宿舍 ${row.roomNumber} 为${room.gender}生寝室,${row.name}(${student.gender})无法入住,跳过`, - ); - skipped++; - continue; - } - // 6. 创建入住记录 const checkInDate = row.checkInDate?.trim() || new Date().toISOString().split('T')[0]; const occData: any = { @@ -497,16 +466,6 @@ export class OccupanciesService { } await this.repo.save(this.repo.create(occData)); - // 7. 首位入住者确定房间性别 - if ( - !row.checkOutDate?.trim() && - (student.gender === '男' || student.gender === '女') && - !room.gender - ) { - await this.roomRepo.update(room.id, { gender: student.gender }); - room.gender = student.gender; - } - // 8. 更新宿舍状态 if (!row.checkOutDate?.trim() && count + 1 >= room.capacity) { await this.roomRepo.update(room.id, { status: 'full' }); diff --git a/apps/server/src/rooms/dto/room.dto.spec.ts b/apps/server/src/rooms/dto/room.dto.spec.ts deleted file mode 100644 index c0cb72e..0000000 --- a/apps/server/src/rooms/dto/room.dto.spec.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { ValidationPipe } from '@nestjs/common'; -import { CreateRoomDto, UpdateRoomDto } from './room.dto'; - -const pipe = new ValidationPipe({ transform: true, whitelist: true }); -const transform = (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(); - }); -}); diff --git a/apps/server/src/rooms/dto/room.dto.ts b/apps/server/src/rooms/dto/room.dto.ts index 883b75c..2a5add1 100644 --- a/apps/server/src/rooms/dto/room.dto.ts +++ b/apps/server/src/rooms/dto/room.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsOptional, IsInt, IsEnum, IsIn, Min, IsNumber } from 'class-validator'; +import { IsString, IsOptional, IsInt, IsEnum, Min, IsNumber } from 'class-validator'; export class CreateRoomDto { @IsString() @@ -20,10 +20,6 @@ export class CreateRoomDto { @IsString() roomType?: string; - @IsOptional() - @IsIn(['男', '女']) - gender?: '男' | '女' | null; - @IsOptional() @IsString() rentalCategory?: string; @@ -55,10 +51,6 @@ export class UpdateRoomDto { @IsString() roomType?: string; - @IsOptional() - @IsIn(['男', '女']) - gender?: '男' | '女' | null; - @IsOptional() @IsEnum(['available', 'full', 'maintenance']) status?: string; diff --git a/apps/server/src/rooms/rooms.controller.ts b/apps/server/src/rooms/rooms.controller.ts index d923ac9..8d10c92 100644 --- a/apps/server/src/rooms/rooms.controller.ts +++ b/apps/server/src/rooms/rooms.controller.ts @@ -67,7 +67,6 @@ 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', @@ -77,7 +76,6 @@ export class RoomsController { roomType: '四人间', rentalCategory: 'long', monthlyRate: 800, - gender: '男', }); ws.addRow({ roomNumber: '2-201', @@ -87,7 +85,6 @@ export class RoomsController { roomType: '单人间', rentalCategory: 'short', monthlyRate: 0, - gender: '女', }); res.setHeader( 'Content-Type', @@ -113,7 +110,6 @@ export class RoomsController { { header: '宿舍类型', key: 'roomType', width: 12 }, { header: '额定人数', key: 'capacity', width: 10 }, { header: '当前入住', key: 'currentCount', width: 10 }, - { header: '性别', key: 'gender', width: 8 }, { header: '状态', key: 'status', width: 10 }, { header: '租赁类型', key: 'rentalCategory', width: 12 }, { header: '月租金', key: 'monthlyRate', width: 10 }, @@ -134,7 +130,6 @@ export class RoomsController { roomType: r.roomType || '', capacity: r.capacity, currentCount: r.currentCount, - gender: r.gender || '', status: statusMap[r.status] || r.status, rentalCategory: r.rentalCategory === 'long' ? '长租' : '短租', monthlyRate: r.monthlyRate ?? '', @@ -339,7 +334,6 @@ export class RoomsController { roomType?: string; rentalCategory?: string; monthlyRate?: number; - gender?: '男' | '女'; }[] = []; ws.eachRow((row, idx) => { if (idx === 1) return; @@ -352,13 +346,6 @@ export class RoomsController { : 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, @@ -367,7 +354,6 @@ export class RoomsController { roomType: String(row.getCell(5).value || '').trim() || undefined, rentalCategory, monthlyRate, - gender, }); }); const result = await this.service.batchImport(rows); diff --git a/apps/server/src/rooms/rooms.gender.spec.ts b/apps/server/src/rooms/rooms.gender.spec.ts deleted file mode 100644 index 6cc5cfe..0000000 --- a/apps/server/src/rooms/rooms.gender.spec.ts +++ /dev/null @@ -1,43 +0,0 @@ -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(); - }); -}); diff --git a/apps/server/src/rooms/rooms.service.ts b/apps/server/src/rooms/rooms.service.ts index 8b23d67..42c2f65 100644 --- a/apps/server/src/rooms/rooms.service.ts +++ b/apps/server/src/rooms/rooms.service.ts @@ -114,13 +114,7 @@ export class RoomsService { } async update(id: number, dto: UpdateRoomDto) { - 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.findOne(id); await this.repo.update(id, dto); return this.repo.findOne({ where: { id } }); } @@ -302,7 +296,6 @@ export class RoomsService { roomType?: string; rentalCategory?: string; monthlyRate?: number; - gender?: '男' | '女'; }[], ) { let imported = 0; @@ -328,7 +321,6 @@ export class RoomsService { roomType: row.roomType || parsed.roomType || undefined, rentalCategory: row.rentalCategory || undefined, monthlyRate: row.monthlyRate ?? undefined, - gender: row.gender ?? undefined, }), ); imported++;