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 { maskPhone, maskIdNumber } from '../../utils/sensitive';
|
||||||
import PermissionButton from '../../components/PermissionButton';
|
import PermissionButton from '../../components/PermissionButton';
|
||||||
import { message } from '../../ui/app-message';
|
import { message } from '../../ui/app-message';
|
||||||
|
import { buildTransferPayload } from './occupancy-form';
|
||||||
|
|
||||||
const { RangePicker } = DatePicker;
|
const { RangePicker } = DatePicker;
|
||||||
|
|
||||||
@@ -59,6 +60,8 @@ const OccupanciesPage: React.FC = () => {
|
|||||||
const [batchCheckOutForm] = Form.useForm();
|
const [batchCheckOutForm] = Form.useForm();
|
||||||
const [availableBeds, setAvailableBeds] = useState<any[]>([]);
|
const [availableBeds, setAvailableBeds] = useState<any[]>([]);
|
||||||
const [availableLockers, setAvailableLockers] = useState<any[]>([]);
|
const [availableLockers, setAvailableLockers] = useState<any[]>([]);
|
||||||
|
const [transferAvailableBeds, setTransferAvailableBeds] = useState<any[]>([]);
|
||||||
|
const [transferAvailableLockers, setTransferAvailableLockers] = useState<any[]>([]);
|
||||||
|
|
||||||
const fetchData = useCallback(async () => {
|
const fetchData = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -110,6 +113,30 @@ const OccupanciesPage: React.FC = () => {
|
|||||||
} catch (e) { console.error(e); }
|
} 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(() => {
|
const filteredData = useMemo(() => {
|
||||||
if (!searchText) return data;
|
if (!searchText) return data;
|
||||||
const keyword = searchText.toLowerCase();
|
const keyword = searchText.toLowerCase();
|
||||||
@@ -170,13 +197,10 @@ const OccupanciesPage: React.FC = () => {
|
|||||||
const values = await transferForm.validateFields();
|
const values = await transferForm.validateFields();
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
await api.put(`/occupancies/${transferModal.id}/transfer`, {
|
await api.put(
|
||||||
newRoomId: values.newRoomId,
|
`/occupancies/${transferModal.id}/transfer`,
|
||||||
transferDate: values.transferDate.format('YYYY-MM-DD'),
|
buildTransferPayload(values),
|
||||||
oldBillingEndDate: values.oldBillingEndDate?.format('YYYY-MM-DD'),
|
);
|
||||||
newBillingStartDate: values.newBillingStartDate?.format('YYYY-MM-DD'),
|
|
||||||
reason: values.reason,
|
|
||||||
});
|
|
||||||
message.success('换房成功');
|
message.success('换房成功');
|
||||||
setTransferModal(null);
|
setTransferModal(null);
|
||||||
transferForm.resetFields();
|
transferForm.resetFields();
|
||||||
@@ -261,6 +285,9 @@ const OccupanciesPage: React.FC = () => {
|
|||||||
size="small"
|
size="small"
|
||||||
icon={<SwapOutlined />}
|
icon={<SwapOutlined />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
|
setTransferAvailableBeds([]);
|
||||||
|
setTransferAvailableLockers([]);
|
||||||
|
transferForm.resetFields();
|
||||||
setTransferModal(record);
|
setTransferModal(record);
|
||||||
transferForm.setFieldsValue({ transferDate: dayjs() });
|
transferForm.setFieldsValue({ transferDate: dayjs() });
|
||||||
}}
|
}}
|
||||||
@@ -708,7 +735,12 @@ const OccupanciesPage: React.FC = () => {
|
|||||||
title={`换房 - ${transferModal?.student?.name}`}
|
title={`换房 - ${transferModal?.student?.name}`}
|
||||||
open={!!transferModal}
|
open={!!transferModal}
|
||||||
onOk={handleTransfer}
|
onOk={handleTransfer}
|
||||||
onCancel={() => setTransferModal(null)}
|
onCancel={() => {
|
||||||
|
setTransferModal(null);
|
||||||
|
transferForm.resetFields();
|
||||||
|
setTransferAvailableBeds([]);
|
||||||
|
setTransferAvailableLockers([]);
|
||||||
|
}}
|
||||||
okText="确认换房"
|
okText="确认换房"
|
||||||
confirmLoading={saving}
|
confirmLoading={saving}
|
||||||
width={500}
|
width={500}
|
||||||
@@ -719,6 +751,7 @@ const OccupanciesPage: React.FC = () => {
|
|||||||
showSearch
|
showSearch
|
||||||
optionFilterProp="label"
|
optionFilterProp="label"
|
||||||
placeholder="选择目标宿舍"
|
placeholder="选择目标宿舍"
|
||||||
|
onChange={handleTransferRoomChange}
|
||||||
options={rooms
|
options={rooms
|
||||||
.filter((r: any) => r.id !== transferModal?.roomId)
|
.filter((r: any) => r.id !== transferModal?.roomId)
|
||||||
.map((r: any) => ({
|
.map((r: any) => ({
|
||||||
@@ -728,6 +761,38 @@ const OccupanciesPage: React.FC = () => {
|
|||||||
}))}
|
}))}
|
||||||
/>
|
/>
|
||||||
</Form.Item>
|
</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 }]}>
|
<Form.Item name="transferDate" label="换房日期" rules={[{ required: true }]}>
|
||||||
<DatePicker style={{ width: '100%' }} placeholder="选择换房日期" format="YYYY-MM-DD" />
|
<DatePicker style={{ width: '100%' }} placeholder="选择换房日期" format="YYYY-MM-DD" />
|
||||||
</Form.Item>
|
</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()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
responsibleOrganizationId?: number;
|
responsibleOrganizationId?: number;
|
||||||
@IsOptional()
|
|
||||||
@IsInt()
|
@IsInt()
|
||||||
bedId?: number; // 后续改 required
|
bedId: number;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
@@ -58,9 +57,8 @@ export class TransferRoomDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
oldBillingEndDate?: string; // 旧房计费截止日,默认=transferDate
|
oldBillingEndDate?: string; // 旧房计费截止日,默认=transferDate
|
||||||
|
|
||||||
@IsOptional()
|
|
||||||
@IsInt()
|
@IsInt()
|
||||||
newBedId?: number;
|
newBedId: number;
|
||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsInt()
|
@IsInt()
|
||||||
|
|||||||
@@ -28,7 +28,10 @@ describe('OccupanciesService — responsible organization', () => {
|
|||||||
roomRepo,
|
roomRepo,
|
||||||
studentRepo,
|
studentRepo,
|
||||||
{} as Repository<Deposit>,
|
{} 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<Locker>,
|
||||||
{} as Repository<any>,
|
{} as Repository<any>,
|
||||||
{} as DataSource,
|
{} as DataSource,
|
||||||
@@ -38,6 +41,7 @@ describe('OccupanciesService — responsible organization', () => {
|
|||||||
studentId: 3,
|
studentId: 3,
|
||||||
roomId: 2,
|
roomId: 2,
|
||||||
checkInDate: '2026-07-10',
|
checkInDate: '2026-07-10',
|
||||||
|
bedId: 4,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(occupancyRepo.create).toHaveBeenCalledWith(
|
expect(occupancyRepo.create).toHaveBeenCalledWith(
|
||||||
|
|||||||
Reference in New Issue
Block a user