fix(occupancies): assign room resources during transfers
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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: '调整宿舍',
|
||||
});
|
||||
});
|
||||
});
|
||||
21
apps/admin/src/pages/Occupancies/occupancy-form.ts
Normal file
21
apps/admin/src/pages/Occupancies/occupancy-form.ts
Normal 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,
|
||||
});
|
||||
28
apps/server/src/occupancies/dto/occupancy.dto.spec.ts
Normal file
28
apps/server/src/occupancies/dto/occupancy.dto.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user