From 2874ab7bee1c9e2768acf00edbfca208ad859854 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Mon, 13 Jul 2026 12:01:48 +0800 Subject: [PATCH] fix(occupancies): assign room resources during transfers --- apps/admin/src/pages/Occupancies/index.tsx | 81 +++++++++++++++++-- .../occupancy-form.integration.test.ts | 27 +++++++ .../src/pages/Occupancies/occupancy-form.ts | 21 +++++ .../src/occupancies/dto/occupancy.dto.spec.ts | 28 +++++++ .../src/occupancies/dto/occupancy.dto.ts | 6 +- .../occupancies/occupancies.service.spec.ts | 6 +- 6 files changed, 156 insertions(+), 13 deletions(-) create mode 100644 apps/admin/src/pages/Occupancies/occupancy-form.integration.test.ts create mode 100644 apps/admin/src/pages/Occupancies/occupancy-form.ts create mode 100644 apps/server/src/occupancies/dto/occupancy.dto.spec.ts diff --git a/apps/admin/src/pages/Occupancies/index.tsx b/apps/admin/src/pages/Occupancies/index.tsx index 5344a0e..6ea930e 100644 --- a/apps/admin/src/pages/Occupancies/index.tsx +++ b/apps/admin/src/pages/Occupancies/index.tsx @@ -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([]); const [availableLockers, setAvailableLockers] = useState([]); + const [transferAvailableBeds, setTransferAvailableBeds] = useState([]); + const [transferAvailableLockers, setTransferAvailableLockers] = useState([]); 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(`/rooms/${roomId}/beds/available`), + api.get(`/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={} 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 = () => { }))} /> + + ({ + value: locker.id, + label: locker.lockerNumber, + }))} + notFoundContent="目标宿舍暂无可用柜子" + /> + diff --git a/apps/admin/src/pages/Occupancies/occupancy-form.integration.test.ts b/apps/admin/src/pages/Occupancies/occupancy-form.integration.test.ts new file mode 100644 index 0000000..4bd4b2a --- /dev/null +++ b/apps/admin/src/pages/Occupancies/occupancy-form.integration.test.ts @@ -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: '调整宿舍', + }); + }); +}); diff --git a/apps/admin/src/pages/Occupancies/occupancy-form.ts b/apps/admin/src/pages/Occupancies/occupancy-form.ts new file mode 100644 index 0000000..e25171b --- /dev/null +++ b/apps/admin/src/pages/Occupancies/occupancy-form.ts @@ -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, +}); diff --git a/apps/server/src/occupancies/dto/occupancy.dto.spec.ts b/apps/server/src/occupancies/dto/occupancy.dto.spec.ts new file mode 100644 index 0000000..5e07f45 --- /dev/null +++ b/apps/server/src/occupancies/dto/occupancy.dto.spec.ts @@ -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); + }); +}); diff --git a/apps/server/src/occupancies/dto/occupancy.dto.ts b/apps/server/src/occupancies/dto/occupancy.dto.ts index a3e22db..8aafeec 100644 --- a/apps/server/src/occupancies/dto/occupancy.dto.ts +++ b/apps/server/src/occupancies/dto/occupancy.dto.ts @@ -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() diff --git a/apps/server/src/occupancies/occupancies.service.spec.ts b/apps/server/src/occupancies/occupancies.service.spec.ts index ede4aa7..982c9f0 100644 --- a/apps/server/src/occupancies/occupancies.service.spec.ts +++ b/apps/server/src/occupancies/occupancies.service.spec.ts @@ -28,7 +28,10 @@ describe('OccupanciesService — responsible organization', () => { roomRepo, studentRepo, {} as Repository, - {} as Repository, + { + findOne: jest.fn().mockResolvedValue({ id: 4, roomId: 2, status: 'available' }), + update: jest.fn(), + } as any as Repository, {} as Repository, {} as Repository, {} as DataSource, @@ -38,6 +41,7 @@ describe('OccupanciesService — responsible organization', () => { studentId: 3, roomId: 2, checkInDate: '2026-07-10', + bedId: 4, }); expect(occupancyRepo.create).toHaveBeenCalledWith(