forked from wangziqi/gongxue-base
refactor(rooms): remove dorm gender restrictions
This commit is contained in:
@@ -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 ? <Tag color={v === '男' ? 'blue' : 'pink'}>{v}生宿舍</Tag> : '未指定',
|
||||
},
|
||||
{
|
||||
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="留空自动解析"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="gender" label="宿舍性别">
|
||||
<Select
|
||||
options={[
|
||||
{ value: '男', label: '男生宿舍' },
|
||||
{ value: '女', label: '女生宿舍' },
|
||||
{ value: '__unset__', label: '未指定(首位入住者确定)' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="rentalCategory" label="租赁类别">
|
||||
<Select
|
||||
allowClear
|
||||
@@ -632,7 +612,6 @@ const RoomsPage: React.FC = () => {
|
||||
<div><strong>楼栋:</strong>{drawerRoom.building || '-'}</div>
|
||||
<div><strong>楼层:</strong>{drawerRoom.floor ?? '-'}</div>
|
||||
<div><strong>类型:</strong>{drawerRoom.roomType || '-'}</div>
|
||||
<div><strong>宿舍性别:</strong>{drawerRoom.gender ? `${drawerRoom.gender}生宿舍` : '未指定'}</div>
|
||||
<div><strong>额定人数:</strong>{drawerRoom.capacity}</div>
|
||||
<div><strong>租赁类别:</strong>{drawerRoom.rentalCategory === 'long' ? '长租' : '短租'}</div>
|
||||
<div><strong>月租金:</strong>{drawerRoom.monthlyRate ? `¥${drawerRoom.monthlyRate}` : '-'}</div>
|
||||
|
||||
@@ -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<void>;
|
||||
};
|
||||
|
||||
await service.removeUnusedRoomColumns();
|
||||
|
||||
expect(runner.dropColumn).toHaveBeenCalledWith('rooms', 'gender');
|
||||
expect(runner.release).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
const runner = this.dataSource.createQueryRunner();
|
||||
await runner.connect();
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -16,7 +16,7 @@ describe('OccupanciesService — responsible organization', () => {
|
||||
save: jest.fn(async (value) => ({ ...value, id: 10 })),
|
||||
} as any as Repository<Occupancy>;
|
||||
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<Room>;
|
||||
const studentRepo = {
|
||||
|
||||
@@ -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' });
|
||||
|
||||
@@ -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 = <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, 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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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++;
|
||||
|
||||
Reference in New Issue
Block a user