fix(occupancies): assign room resources during transfers

This commit is contained in:
2026-07-13 12:01:48 +08:00
parent 8cadf8970e
commit 2874ab7bee
6 changed files with 156 additions and 13 deletions

View File

@@ -32,6 +32,7 @@ import { downloadBlob } from '../../utils/download';
import { maskPhone, maskIdNumber } from '../../utils/sensitive';
import PermissionButton from '../../components/PermissionButton';
import { message } from '../../ui/app-message';
import { buildTransferPayload } from './occupancy-form';
const { RangePicker } = DatePicker;
@@ -59,6 +60,8 @@ const OccupanciesPage: React.FC = () => {
const [batchCheckOutForm] = Form.useForm();
const [availableBeds, setAvailableBeds] = useState<any[]>([]);
const [availableLockers, setAvailableLockers] = useState<any[]>([]);
const [transferAvailableBeds, setTransferAvailableBeds] = useState<any[]>([]);
const [transferAvailableLockers, setTransferAvailableLockers] = useState<any[]>([]);
const fetchData = useCallback(async () => {
setLoading(true);
@@ -110,6 +113,30 @@ const OccupanciesPage: React.FC = () => {
} catch (e) { console.error(e); }
};
const handleTransferRoomChange = async (roomId: number) => {
transferForm.setFieldValue('newBedId', undefined);
transferForm.setFieldValue('newLockerId', undefined);
if (!roomId) {
setTransferAvailableBeds([]);
setTransferAvailableLockers([]);
return;
}
try {
const [beds, lockers] = await Promise.all([
api.get<any[]>(`/rooms/${roomId}/beds/available`),
api.get<any[]>(`/rooms/${roomId}/lockers/available`),
]);
setTransferAvailableBeds(beds);
setTransferAvailableLockers(lockers);
if (beds.length === 1) transferForm.setFieldValue('newBedId', beds[0].id);
} catch (e) {
console.error(e);
setTransferAvailableBeds([]);
setTransferAvailableLockers([]);
message.error('目标宿舍床位和柜子加载失败');
}
};
const filteredData = useMemo(() => {
if (!searchText) return data;
const keyword = searchText.toLowerCase();
@@ -170,13 +197,10 @@ const OccupanciesPage: React.FC = () => {
const values = await transferForm.validateFields();
setSaving(true);
try {
await api.put(`/occupancies/${transferModal.id}/transfer`, {
newRoomId: values.newRoomId,
transferDate: values.transferDate.format('YYYY-MM-DD'),
oldBillingEndDate: values.oldBillingEndDate?.format('YYYY-MM-DD'),
newBillingStartDate: values.newBillingStartDate?.format('YYYY-MM-DD'),
reason: values.reason,
});
await api.put(
`/occupancies/${transferModal.id}/transfer`,
buildTransferPayload(values),
);
message.success('换房成功');
setTransferModal(null);
transferForm.resetFields();
@@ -261,6 +285,9 @@ const OccupanciesPage: React.FC = () => {
size="small"
icon={<SwapOutlined />}
onClick={() => {
setTransferAvailableBeds([]);
setTransferAvailableLockers([]);
transferForm.resetFields();
setTransferModal(record);
transferForm.setFieldsValue({ transferDate: dayjs() });
}}
@@ -708,7 +735,12 @@ const OccupanciesPage: React.FC = () => {
title={`换房 - ${transferModal?.student?.name}`}
open={!!transferModal}
onOk={handleTransfer}
onCancel={() => setTransferModal(null)}
onCancel={() => {
setTransferModal(null);
transferForm.resetFields();
setTransferAvailableBeds([]);
setTransferAvailableLockers([]);
}}
okText="确认换房"
confirmLoading={saving}
width={500}
@@ -719,6 +751,7 @@ const OccupanciesPage: React.FC = () => {
showSearch
optionFilterProp="label"
placeholder="选择目标宿舍"
onChange={handleTransferRoomChange}
options={rooms
.filter((r: any) => r.id !== transferModal?.roomId)
.map((r: any) => ({
@@ -728,6 +761,38 @@ const OccupanciesPage: React.FC = () => {
}))}
/>
</Form.Item>
<Form.Item
name="newBedId"
label="目标床位"
rules={[{ required: true, message: '请选择目标床位' }]}
>
<Select
placeholder="请先选择目标宿舍"
disabled={transferAvailableBeds.length === 0}
options={transferAvailableBeds.map((bed) => ({
value: bed.id,
label: bed.bedNumber,
}))}
notFoundContent="目标宿舍暂无可用床位"
/>
</Form.Item>
{transferAvailableBeds.length > 0 && (
<div style={{ marginTop: -16, marginBottom: 16, color: '#888', fontSize: 12 }}>
{transferAvailableBeds.length}
</div>
)}
<Form.Item name="newLockerId" label="目标柜子(可选)">
<Select
allowClear
placeholder="可选分配目标宿舍柜子"
disabled={transferAvailableLockers.length === 0}
options={transferAvailableLockers.map((locker) => ({
value: locker.id,
label: locker.lockerNumber,
}))}
notFoundContent="目标宿舍暂无可用柜子"
/>
</Form.Item>
<Form.Item name="transferDate" label="换房日期" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} placeholder="选择换房日期" format="YYYY-MM-DD" />
</Form.Item>

View File

@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import dayjs from 'dayjs';
import { buildTransferPayload } from './occupancy-form';
describe('occupancy transfer form', () => {
it('submits the target room resources with the transfer dates', () => {
expect(
buildTransferPayload({
newRoomId: 5,
newBedId: 12,
newLockerId: 18,
transferDate: dayjs('2026-07-13'),
oldBillingEndDate: dayjs('2026-07-13'),
newBillingStartDate: dayjs('2026-07-14'),
reason: '调整宿舍',
}),
).toEqual({
newRoomId: 5,
newBedId: 12,
newLockerId: 18,
transferDate: '2026-07-13',
oldBillingEndDate: '2026-07-13',
newBillingStartDate: '2026-07-14',
reason: '调整宿舍',
});
});
});

View File

@@ -0,0 +1,21 @@
import type { Dayjs } from 'dayjs';
export interface TransferFormValues {
newRoomId: number;
newBedId: number;
newLockerId?: number;
transferDate: Dayjs;
oldBillingEndDate?: Dayjs;
newBillingStartDate?: Dayjs;
reason?: string;
}
export const buildTransferPayload = (values: TransferFormValues) => ({
newRoomId: values.newRoomId,
newBedId: values.newBedId,
newLockerId: values.newLockerId || undefined,
transferDate: values.transferDate.format('YYYY-MM-DD'),
oldBillingEndDate: values.oldBillingEndDate?.format('YYYY-MM-DD'),
newBillingStartDate: values.newBillingStartDate?.format('YYYY-MM-DD'),
reason: values.reason,
});

View File

@@ -0,0 +1,28 @@
import { validate } from 'class-validator';
import { CheckInDto, TransferRoomDto } from './occupancy.dto';
describe('manual occupancy DTO bed requirements', () => {
it('requires a bed for manual check-in', async () => {
const dto = Object.assign(new CheckInDto(), {
studentId: 1,
roomId: 2,
checkInDate: '2026-07-13',
});
const errors = await validate(dto);
expect(errors.some((error) => error.property === 'bedId')).toBe(true);
});
it('requires a new bed for a room transfer while keeping the locker optional', async () => {
const dto = Object.assign(new TransferRoomDto(), {
newRoomId: 3,
transferDate: '2026-07-13',
});
const errors = await validate(dto);
expect(errors.some((error) => error.property === 'newBedId')).toBe(true);
expect(errors.some((error) => error.property === 'newLockerId')).toBe(false);
});
});

View File

@@ -25,9 +25,8 @@ export class CheckInDto {
@IsOptional()
@IsInt()
responsibleOrganizationId?: number;
@IsOptional()
@IsInt()
bedId?: number; // 后续改 required
bedId: number;
@IsOptional()
@IsInt()
@@ -58,9 +57,8 @@ export class TransferRoomDto {
@IsString()
oldBillingEndDate?: string; // 旧房计费截止日,默认=transferDate
@IsOptional()
@IsInt()
newBedId?: number;
newBedId: number;
@IsOptional()
@IsInt()

View File

@@ -28,7 +28,10 @@ describe('OccupanciesService — responsible organization', () => {
roomRepo,
studentRepo,
{} as Repository<Deposit>,
{} as Repository<Bed>,
{
findOne: jest.fn().mockResolvedValue({ id: 4, roomId: 2, status: 'available' }),
update: jest.fn(),
} as any as Repository<Bed>,
{} as Repository<Locker>,
{} as Repository<any>,
{} as DataSource,
@@ -38,6 +41,7 @@ describe('OccupanciesService — responsible organization', () => {
studentId: 3,
roomId: 2,
checkInDate: '2026-07-10',
bedId: 4,
});
expect(occupancyRepo.create).toHaveBeenCalledWith(