From 8cadf8970e1579aa76ca4e4790800864439c1a4b Mon Sep 17 00:00:00 2001 From: wangziqi Date: Mon, 13 Jul 2026 12:01:38 +0800 Subject: [PATCH 01/13] fix(admin): restore teacher profile form values --- apps/admin/src/pages/Users/index.tsx | 8 ++++-- .../user-profile-form.integration.test.ts | 27 +++++++++++++++++++ .../src/pages/Users/user-profile-form.ts | 16 +++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) create mode 100644 apps/admin/src/pages/Users/user-profile-form.integration.test.ts create mode 100644 apps/admin/src/pages/Users/user-profile-form.ts diff --git a/apps/admin/src/pages/Users/index.tsx b/apps/admin/src/pages/Users/index.tsx index 739285f..b6684d1 100644 --- a/apps/admin/src/pages/Users/index.tsx +++ b/apps/admin/src/pages/Users/index.tsx @@ -15,6 +15,10 @@ import dayjs from 'dayjs'; import api from '../../api'; import PermissionButton from '../../components/PermissionButton'; import { message } from '../../ui/app-message'; +import { + userProfileResponseToFormValues, + type UserProfileResponse, +} from './user-profile-form'; const UsersPage: React.FC = () => { const [data, setData] = useState([]); @@ -36,8 +40,8 @@ const UsersPage: React.FC = () => { const handleOpenProfile = async (record: any) => { setProfileUser(record); try { - const res: any = await api.get(`/rbac/users/${record.id}/profile`); - profileForm.setFieldsValue(res); + const res = await api.get(`/rbac/users/${record.id}/profile`); + profileForm.setFieldsValue(userProfileResponseToFormValues(res)); } catch { profileForm.setFieldsValue({}); } diff --git a/apps/admin/src/pages/Users/user-profile-form.integration.test.ts b/apps/admin/src/pages/Users/user-profile-form.integration.test.ts new file mode 100644 index 0000000..9455a8d --- /dev/null +++ b/apps/admin/src/pages/Users/user-profile-form.integration.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vitest'; +import { userProfileResponseToFormValues } from './user-profile-form'; + +describe('user profile form mapping', () => { + it('unwraps the nested profile returned by the user profile endpoint', () => { + expect( + userProfileResponseToFormValues({ + id: 9, + username: 'teacher01', + name: '测试教师', + profile: { + joinedAt: '2026-07-01', + qualifications: '教师资格证', + subjects: ['语文', '历史'], + }, + }), + ).toEqual({ + joinedAt: '2026-07-01', + qualifications: '教师资格证', + subjects: ['语文', '历史'], + }); + }); + + it('returns empty form values when the user has no profile', () => { + expect(userProfileResponseToFormValues({ profile: null })).toEqual({}); + }); +}); diff --git a/apps/admin/src/pages/Users/user-profile-form.ts b/apps/admin/src/pages/Users/user-profile-form.ts new file mode 100644 index 0000000..8a60032 --- /dev/null +++ b/apps/admin/src/pages/Users/user-profile-form.ts @@ -0,0 +1,16 @@ +export interface UserProfileFormValues { + joinedAt?: string; + qualifications?: string; + subjects?: string[]; +} + +export interface UserProfileResponse { + id?: number; + username?: string; + name?: string; + profile?: UserProfileFormValues | null; +} + +export const userProfileResponseToFormValues = ( + response: UserProfileResponse, +): UserProfileFormValues => response.profile || {}; -- 2.49.1 From 2874ab7bee1c9e2768acf00edbfca208ad859854 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Mon, 13 Jul 2026 12:01:48 +0800 Subject: [PATCH 02/13] 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( -- 2.49.1 From dfd3cf67722546357d9256cde5f75a79c271a9f1 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Mon, 13 Jul 2026 12:04:47 +0800 Subject: [PATCH 03/13] fix(archive): align aggregate result field --- .../src/archive/archive.service.spec.ts | 37 +++++++++++++++++++ apps/server/src/archive/archive.service.ts | 2 +- 2 files changed, 38 insertions(+), 1 deletion(-) create mode 100644 apps/server/src/archive/archive.service.spec.ts diff --git a/apps/server/src/archive/archive.service.spec.ts b/apps/server/src/archive/archive.service.spec.ts new file mode 100644 index 0000000..4fe9957 --- /dev/null +++ b/apps/server/src/archive/archive.service.spec.ts @@ -0,0 +1,37 @@ +import { ArchiveService } from './archive.service'; + +describe('ArchiveService.getProfile', () => { + it('returns the admission archive under the public result field', async () => { + const student = { id: 7, name: '测试学生' }; + const result = { + id: 3, + studentId: 7, + cultureFinalScore: 450, + admittedCollege: '测试学院', + }; + + const studentRepo = { findOne: jest.fn().mockResolvedValue(student) }; + const profileRepo = { findOne: jest.fn().mockResolvedValue(null) }; + const enrollmentRepo = { find: jest.fn().mockResolvedValue([]) }; + const examScoreRepo = { find: jest.fn().mockResolvedValue([]) }; + const learningRecordRepo = { find: jest.fn().mockResolvedValue([]) }; + const resultRepo = { findOne: jest.fn().mockResolvedValue(result) }; + const attachmentRepo = { find: jest.fn().mockResolvedValue([]) }; + + const service = new ArchiveService( + studentRepo as never, + profileRepo as never, + enrollmentRepo as never, + examScoreRepo as never, + learningRecordRepo as never, + resultRepo as never, + attachmentRepo as never, + {} as never, + ); + + const response = await service.getProfile(7); + + expect(response).toMatchObject({ student, result }); + expect(response).not.toHaveProperty('resultArchive'); + }); +}); diff --git a/apps/server/src/archive/archive.service.ts b/apps/server/src/archive/archive.service.ts index 93a98f3..69c788a 100644 --- a/apps/server/src/archive/archive.service.ts +++ b/apps/server/src/archive/archive.service.ts @@ -76,7 +76,7 @@ export class ArchiveService { enrollments, examScores, learningRecords, - resultArchive, + result: resultArchive, attachments, }; } -- 2.49.1 From 6396d3e93474eedf7479301a808a06be972db4ed Mon Sep 17 00:00:00 2001 From: wangziqi Date: Mon, 13 Jul 2026 12:09:53 +0800 Subject: [PATCH 04/13] fix(schedules): support editable schedule notes --- apps/admin/src/pages/Schedules/index.tsx | 13 +++++++++ .../schedule-form.integration.test.ts | 21 ++++++++++++++ .../src/pages/Schedules/schedule-form.ts | 4 +++ .../src/schedules/dto/schedule.dto.spec.ts | 29 +++++++++++++++++++ apps/server/src/schedules/dto/schedule.dto.ts | 3 ++ 5 files changed, 70 insertions(+) create mode 100644 apps/server/src/schedules/dto/schedule.dto.spec.ts diff --git a/apps/admin/src/pages/Schedules/index.tsx b/apps/admin/src/pages/Schedules/index.tsx index 42547cf..c37e756 100644 --- a/apps/admin/src/pages/Schedules/index.tsx +++ b/apps/admin/src/pages/Schedules/index.tsx @@ -947,6 +947,19 @@ const SchedulesPage: React.FC = () => { /> + + + + { endTime: '18:00', startDate: '2026-07-01', endDate: '2026-07-31', + notes: '需要投影设备', }); expect(values.classroomId).toBe(1); expect(values.weekDay).toBe(5); + expect(values.notes).toBe('需要投影设备'); expect(values.timeRange.map((item) => item.format('HH:mm'))).toEqual(['14:00', '18:00']); expect(values.dateRange.map((item) => item.format('YYYY-MM-DD'))).toEqual([ '2026-07-01', @@ -36,6 +38,7 @@ describe('schedule edit form mapping', () => { teacherId: 4, timeRange: [dayjs('2026-01-01 13:30'), dayjs('2026-01-01 17:20')], dateRange: [dayjs('2026-08-01'), dayjs('2026-08-31')], + notes: ' 临时调整教室 ', }), ).toEqual({ classId: 1, @@ -47,6 +50,24 @@ describe('schedule edit form mapping', () => { endTime: '17:20', startDate: '2026-08-01', endDate: '2026-08-31', + notes: '临时调整教室', }); }); }); + + +describe('schedule notes normalization', () => { + it('omits whitespace-only notes from the payload', () => { + expect( + buildSchedulePayload({ + classId: 1, + classroomId: 2, + weekDay: 6, + subject: '作文', + timeRange: [dayjs('2026-01-01 13:30'), dayjs('2026-01-01 17:20')], + dateRange: [dayjs('2026-08-01'), dayjs('2026-08-31')], + notes: ' ', + }).notes, + ).toBeUndefined(); + }); +}); diff --git a/apps/admin/src/pages/Schedules/schedule-form.ts b/apps/admin/src/pages/Schedules/schedule-form.ts index 42d1d7f..dc4560c 100644 --- a/apps/admin/src/pages/Schedules/schedule-form.ts +++ b/apps/admin/src/pages/Schedules/schedule-form.ts @@ -6,6 +6,7 @@ export interface ScheduleFormValues { weekDay: number; subject: string; teacherId?: number; + notes?: string; timeRange: [Dayjs, Dayjs]; dateRange: [Dayjs, Dayjs]; } @@ -17,6 +18,7 @@ export interface EditableSchedule { weekDay: number; subject: string; teacherId: number | null; + notes?: string | null; startTime: string; endTime: string; startDate: string; @@ -29,6 +31,7 @@ export const scheduleToFormValues = (schedule: EditableSchedule): ScheduleFormVa weekDay: schedule.weekDay, subject: schedule.subject, teacherId: schedule.teacherId ?? undefined, + notes: schedule.notes ?? undefined, timeRange: [dayjs(`2000-01-01 ${schedule.startTime}`), dayjs(`2000-01-01 ${schedule.endTime}`)], dateRange: [dayjs(schedule.startDate), dayjs(schedule.endDate)], }); @@ -39,6 +42,7 @@ export const buildSchedulePayload = (values: ScheduleFormValues) => ({ weekDay: values.weekDay, subject: values.subject, teacherId: values.teacherId, + notes: values.notes?.trim() || undefined, startTime: values.timeRange[0].format('HH:mm'), endTime: values.timeRange[1].format('HH:mm'), startDate: values.dateRange[0].format('YYYY-MM-DD'), diff --git a/apps/server/src/schedules/dto/schedule.dto.spec.ts b/apps/server/src/schedules/dto/schedule.dto.spec.ts new file mode 100644 index 0000000..5e85269 --- /dev/null +++ b/apps/server/src/schedules/dto/schedule.dto.spec.ts @@ -0,0 +1,29 @@ +import 'reflect-metadata'; +import { validate } from 'class-validator'; +import { CreateScheduleDto, UpdateScheduleDto } from './schedule.dto'; + +const createSchedule = (notes: string) => + Object.assign(new CreateScheduleDto(), { + classId: 1, + classroomId: 2, + weekDay: 1, + startTime: '09:00', + endTime: '10:00', + startDate: '2026-07-01', + endDate: '2026-07-31', + subject: '语文', + notes, + }); + +describe('schedule notes validation', () => { + it('rejects notes longer than 500 characters when creating', async () => { + const errors = await validate(createSchedule('a'.repeat(501))); + expect(errors.some((error) => error.property === 'notes')).toBe(true); + }); + + it('rejects notes longer than 500 characters when updating', async () => { + const dto = Object.assign(new UpdateScheduleDto(), { notes: 'a'.repeat(501) }); + const errors = await validate(dto); + expect(errors.some((error) => error.property === 'notes')).toBe(true); + }); +}); diff --git a/apps/server/src/schedules/dto/schedule.dto.ts b/apps/server/src/schedules/dto/schedule.dto.ts index 3d6869f..016560a 100644 --- a/apps/server/src/schedules/dto/schedule.dto.ts +++ b/apps/server/src/schedules/dto/schedule.dto.ts @@ -7,6 +7,7 @@ import { Matches, Min, Max, + MaxLength, } from 'class-validator'; import { Type } from 'class-transformer'; @@ -59,6 +60,7 @@ export class CreateScheduleDto { @IsOptional() @IsString() + @MaxLength(500) notes?: string; @IsOptional() @@ -115,6 +117,7 @@ export class UpdateScheduleDto { @IsOptional() @IsString() + @MaxLength(500) notes?: string; } -- 2.49.1 From 0377acd33b4d40d24ff52076503d1b92ae604ae2 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Mon, 13 Jul 2026 12:16:39 +0800 Subject: [PATCH 05/13] refactor: remove residual campus and department fields --- .../archive/archive-report.service.spec.ts | 31 +++++++++++++++++++ .../src/archive/archive-report.service.ts | 1 - .../src/archive/dto/archive.dto.spec.ts | 18 +++++++++++ apps/server/src/archive/dto/archive.dto.ts | 1 - .../src/entities/student-profile.entity.ts | 3 -- .../src/schedules/dto/schedule.dto.spec.ts | 18 +++++++++++ apps/server/src/schedules/dto/schedule.dto.ts | 3 -- 7 files changed, 67 insertions(+), 8 deletions(-) create mode 100644 apps/server/src/archive/archive-report.service.spec.ts create mode 100644 apps/server/src/archive/dto/archive.dto.spec.ts diff --git a/apps/server/src/archive/archive-report.service.spec.ts b/apps/server/src/archive/archive-report.service.spec.ts new file mode 100644 index 0000000..457b337 --- /dev/null +++ b/apps/server/src/archive/archive-report.service.spec.ts @@ -0,0 +1,31 @@ +import { ArchiveReportService } from './archive-report.service'; + +describe('ArchiveReportService retired profile fields', () => { + it('does not render the retired campus field in a student report', async () => { + const service = new ArchiveReportService( + { findOne: jest.fn().mockResolvedValue({ campusLocation: '旧校区', grade: '高三' }) } as never, + { find: jest.fn().mockResolvedValue([]) } as never, + { find: jest.fn().mockResolvedValue([]) } as never, + { find: jest.fn().mockResolvedValue([]) } as never, + { findOne: jest.fn().mockResolvedValue(null) } as never, + { find: jest.fn().mockResolvedValue([]) } as never, + { + findOne: jest.fn().mockResolvedValue({ + id: 1, + name: '测试学生', + gender: '男', + phone: '', + ethnicity: '', + emergencyContact: '', + emergencyPhone: '', + }), + } as never, + ); + + const html = await service.generateReportHtml(1); + + expect(html).not.toContain('旧校区'); + expect(html).not.toContain('校区'); + expect(html).toContain('高三'); + }); +}); diff --git a/apps/server/src/archive/archive-report.service.ts b/apps/server/src/archive/archive-report.service.ts index 5e58672..036af73 100644 --- a/apps/server/src/archive/archive-report.service.ts +++ b/apps/server/src/archive/archive-report.service.ts @@ -308,7 +308,6 @@ ${this.buildLearningAndResult(learnings, result, now)}
民族${this.esc(student.ethnicity || '-')}
紧急联系人${this.esc(student.emergencyContact || '-')}
紧急电话${this.esc(student.emergencyPhone || '-')}
-
校区${this.esc(profile?.campusLocation || '-')}
年级${this.esc(profile?.grade || '-')}
`; diff --git a/apps/server/src/archive/dto/archive.dto.spec.ts b/apps/server/src/archive/dto/archive.dto.spec.ts new file mode 100644 index 0000000..4094440 --- /dev/null +++ b/apps/server/src/archive/dto/archive.dto.spec.ts @@ -0,0 +1,18 @@ +import 'reflect-metadata'; +import { plainToInstance } from 'class-transformer'; +import { validate } from 'class-validator'; +import { UpsertProfileDto } from './archive.dto'; + +describe('UpsertProfileDto retired fields', () => { + it('removes the retired campusLocation field under whitelist validation', async () => { + const dto = plainToInstance(UpsertProfileDto, { + grade: '高三', + campusLocation: '旧校区', + }); + + await validate(dto, { whitelist: true }); + + expect(dto).toMatchObject({ grade: '高三' }); + expect(dto).not.toHaveProperty('campusLocation'); + }); +}); diff --git a/apps/server/src/archive/dto/archive.dto.ts b/apps/server/src/archive/dto/archive.dto.ts index 404cb77..9d7c6a9 100644 --- a/apps/server/src/archive/dto/archive.dto.ts +++ b/apps/server/src/archive/dto/archive.dto.ts @@ -5,7 +5,6 @@ export class UpsertProfileDto { @IsOptional() @IsString() targetMajor?: string; @IsOptional() @IsString() subjectDirection?: string; @IsOptional() @IsString() grade?: string; - @IsOptional() @IsString() campusLocation?: string; @IsOptional() @IsDateString() profileDate?: string; @IsOptional() @IsString() notes?: string; } diff --git a/apps/server/src/entities/student-profile.entity.ts b/apps/server/src/entities/student-profile.entity.ts index 9a83f78..bac56a9 100644 --- a/apps/server/src/entities/student-profile.entity.ts +++ b/apps/server/src/entities/student-profile.entity.ts @@ -33,9 +33,6 @@ export class StudentProfile { @Column({ length: 20, nullable: true }) grade: string; - @Column({ name: 'campus_location', length: 100, nullable: true }) - campusLocation: string; - @Column({ name: 'profile_date', type: 'date', nullable: true }) profileDate: string; diff --git a/apps/server/src/schedules/dto/schedule.dto.spec.ts b/apps/server/src/schedules/dto/schedule.dto.spec.ts index 5e85269..62fc926 100644 --- a/apps/server/src/schedules/dto/schedule.dto.spec.ts +++ b/apps/server/src/schedules/dto/schedule.dto.spec.ts @@ -27,3 +27,21 @@ describe('schedule notes validation', () => { expect(errors.some((error) => error.property === 'notes')).toBe(true); }); }); + +it('removes the retired departmentId field from create requests', async () => { + const dto = Object.assign(new CreateScheduleDto(), { + classId: 1, + classroomId: 2, + weekDay: 1, + startTime: '09:00', + endTime: '10:00', + startDate: '2026-07-01', + endDate: '2026-07-31', + subject: '语文', + departmentId: 99, + }); + + await validate(dto, { whitelist: true }); + + expect(dto).not.toHaveProperty('departmentId'); +}); diff --git a/apps/server/src/schedules/dto/schedule.dto.ts b/apps/server/src/schedules/dto/schedule.dto.ts index 016560a..b193c59 100644 --- a/apps/server/src/schedules/dto/schedule.dto.ts +++ b/apps/server/src/schedules/dto/schedule.dto.ts @@ -63,9 +63,6 @@ export class CreateScheduleDto { @MaxLength(500) notes?: string; - @IsOptional() - @IsInt() - departmentId?: number; } export class UpdateScheduleDto { -- 2.49.1 From effa34434be28a1d316a6868643b9bce17bd586e Mon Sep 17 00:00:00 2001 From: wangziqi Date: Mon, 13 Jul 2026 12:20:13 +0800 Subject: [PATCH 06/13] refactor(students): remove obsolete class id input --- .../src/students/dto/student.dto.spec.ts | 18 ++++++++++++++++++ apps/server/src/students/dto/student.dto.ts | 3 --- 2 files changed, 18 insertions(+), 3 deletions(-) create mode 100644 apps/server/src/students/dto/student.dto.spec.ts diff --git a/apps/server/src/students/dto/student.dto.spec.ts b/apps/server/src/students/dto/student.dto.spec.ts new file mode 100644 index 0000000..916514d --- /dev/null +++ b/apps/server/src/students/dto/student.dto.spec.ts @@ -0,0 +1,18 @@ +import 'reflect-metadata'; +import { validate } from 'class-validator'; +import { CreateStudentDto } from './student.dto'; + +describe('CreateStudentDto relationship boundaries', () => { + it('removes classId because class membership is managed by the classes API', async () => { + const dto = Object.assign(new CreateStudentDto(), { + name: '测试学生', + organizationId: 1, + classId: 9, + }); + + await validate(dto, { whitelist: true }); + + expect(dto).toMatchObject({ name: '测试学生', organizationId: 1 }); + expect(dto).not.toHaveProperty('classId'); + }); +}); diff --git a/apps/server/src/students/dto/student.dto.ts b/apps/server/src/students/dto/student.dto.ts index 40c4bdd..bc08e95 100644 --- a/apps/server/src/students/dto/student.dto.ts +++ b/apps/server/src/students/dto/student.dto.ts @@ -38,9 +38,6 @@ export class CreateStudentDto { @IsOptional() @IsString() supervisor?: string; - @IsOptional() - @IsInt() - classId?: number; } export class UpdateStudentDto { -- 2.49.1 From 0515e6ed276a9525679d7188018e8ddaea4bd8b5 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Mon, 13 Jul 2026 14:01:45 +0800 Subject: [PATCH 07/13] fix(integration): simplify manual DingTalk sync setup --- .../src/pages/IntegrationConfig/index.tsx | 190 ++++++++++-------- ...ntegration-config-form.integration.test.ts | 26 +++ .../integration-config-form.ts | 13 ++ .../src/integration/config/dto/config.dto.ts | 6 +- .../config/integration-config.service.spec.ts | 49 +++++ .../config/integration-config.service.ts | 7 +- 6 files changed, 203 insertions(+), 88 deletions(-) create mode 100644 apps/admin/src/pages/IntegrationConfig/integration-config-form.integration.test.ts create mode 100644 apps/admin/src/pages/IntegrationConfig/integration-config-form.ts create mode 100644 apps/server/src/integration/config/integration-config.service.spec.ts diff --git a/apps/admin/src/pages/IntegrationConfig/index.tsx b/apps/admin/src/pages/IntegrationConfig/index.tsx index e553f6f..7226466 100644 --- a/apps/admin/src/pages/IntegrationConfig/index.tsx +++ b/apps/admin/src/pages/IntegrationConfig/index.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useState, useMemo, useCallback } from 'react'; import { - Card, Form, Input, Button, Space, Spin, Switch, Alert, Descriptions, Tag, - Tabs, Drawer, Tree, Select, TreeSelect, Modal, DatePicker, InputNumber, + Card, Form, Input, Button, Space, Spin, Alert, Descriptions, Tag, Divider, + Drawer, Tree, Select, TreeSelect, Modal, DatePicker, InputNumber, Row, Col, List, } from 'antd'; import { @@ -15,12 +15,15 @@ import api from '../../api'; import { message } from '../../ui/app-message'; import { usePermission } from '../../hooks/usePermission'; import PermissionButton from '../../components/PermissionButton'; +import { + buildDingTalkConfigPayload, + isAppSecretRequired, + type DingTalkConfigFormValues, +} from './integration-config-form'; interface DingTalkConfig { agentId: string; - appSecret: string; corpId: string; - startEnable: boolean; } interface DingOrgTreeNodeExt { @@ -94,9 +97,9 @@ const IntegrationConfigPage: React.FC = () => { const [testing, setTesting] = useState(false); const [config, setConfig] = useState(null); const [verified, setVerified] = useState(null); - const [form] = Form.useForm(); + const [form] = Form.useForm(); - // ── Sync Users Tab ── + // ── Manual organization sync ── const [syncRootDeptId, setSyncRootDeptId] = useState(undefined); const [orgTree, setOrgTree] = useState([]); const [drawerOpen, setDrawerOpen] = useState(false); @@ -137,9 +140,10 @@ const IntegrationConfigPage: React.FC = () => { const handleSave = async () => { const values = await form.validateFields(); + const payload = buildDingTalkConfigPayload(values); setSaving(true); try { - await api.post('/integration/config', { type: 'DINGTALK', config: values }); + await api.post('/integration/config', { type: 'DINGTALK', config: payload }); message.success('配置已保存'); await fetchConfig(); } catch (e: unknown) { @@ -152,11 +156,12 @@ const IntegrationConfigPage: React.FC = () => { const handleTest = async () => { const values = await form.validateFields(); + const payload = buildDingTalkConfigPayload(values); setTesting(true); try { const res = await api.post<{ success: boolean; message: string }>('/integration/config/test', { type: 'DINGTALK', - config: values, + config: payload, }); setVerified(res.success); message.success(res.message); @@ -345,12 +350,8 @@ const IntegrationConfigPage: React.FC = () => { } }; - const syncTabItems = config && hasAllPermissions('sync:read', 'class:view', 'class:edit') - ? [ - { - key: 'sync-users', - label: '同步用户', - children: ( + const syncPanel = config && hasAllPermissions('sync:read', 'class:view', 'class:edit') + ? (
{ />
- ), - }, - ] - : []; - - const tabItems = [ - { - key: 'config', - label: '配置', - children: ( - - {config && ( - - {config.corpId || '-'} - {config.agentId || '-'} - - - {config.startEnable ? '已启用' : '未启用'} - - - - )} - - - -
- - - - - - - - - - - - - - } loading={saving} onClick={handleSave}> - 保存配置 - - - -
-
- ), - }, - ...syncTabItems, - ]; + ) + : null; return ( - - {verified === true && } color="success">已连接} - {verified === false && } color="error">未连接} - - }> - + + {verified === true && ( + } color="success"> + 已连接 + + )} + {verified === false && ( + } color="error"> + 未连接 + + )} + + } + > + + {config && ( + + {config.corpId || '-'} + {config.agentId || '-'} + 手动触发 + + )} + + + +
+ + + + + + + + + + + } + loading={saving} + onClick={handleSave} + > + 保存配置 + + + +
+ + {syncPanel && ( + <> + 组织用户导入 + {syncPanel} + + )} +
); }; diff --git a/apps/admin/src/pages/IntegrationConfig/integration-config-form.integration.test.ts b/apps/admin/src/pages/IntegrationConfig/integration-config-form.integration.test.ts new file mode 100644 index 0000000..5219ac7 --- /dev/null +++ b/apps/admin/src/pages/IntegrationConfig/integration-config-form.integration.test.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; +import { + buildDingTalkConfigPayload, + isAppSecretRequired, +} from './integration-config-form'; + +describe('DingTalk integration config form', () => { + it('requires AppSecret only for the first configuration', () => { + expect(isAppSecretRequired(false)).toBe(true); + expect(isAppSecretRequired(true)).toBe(false); + }); + + it('builds a manual-sync config without the retired startEnable flag', () => { + expect( + buildDingTalkConfigPayload({ + corpId: 'ding-corp', + agentId: 'app-key', + appSecret: '', + }), + ).toEqual({ + corpId: 'ding-corp', + agentId: 'app-key', + appSecret: undefined, + }); + }); +}); diff --git a/apps/admin/src/pages/IntegrationConfig/integration-config-form.ts b/apps/admin/src/pages/IntegrationConfig/integration-config-form.ts new file mode 100644 index 0000000..4e18c1a --- /dev/null +++ b/apps/admin/src/pages/IntegrationConfig/integration-config-form.ts @@ -0,0 +1,13 @@ +export interface DingTalkConfigFormValues { + agentId: string; + appSecret?: string; + corpId: string; +} + +export const isAppSecretRequired = (hasSavedConfig: boolean) => !hasSavedConfig; + +export const buildDingTalkConfigPayload = (values: DingTalkConfigFormValues) => ({ + corpId: values.corpId.trim(), + agentId: values.agentId.trim(), + appSecret: values.appSecret?.trim() || undefined, +}); diff --git a/apps/server/src/integration/config/dto/config.dto.ts b/apps/server/src/integration/config/dto/config.dto.ts index e0e35ee..d67e4cf 100644 --- a/apps/server/src/integration/config/dto/config.dto.ts +++ b/apps/server/src/integration/config/dto/config.dto.ts @@ -1,18 +1,16 @@ /** 钉钉配置 */ export interface DingTalkThirdConfig { agentId: string; // AppKey - appSecret: string; // AppSecret + appSecret?: string; // AppSecret;更新已有配置时可留空保留旧值 corpId: string; // CorpId - startEnable: boolean; // 是否启用同步 appId?: string; // 内部应用ID,用于消息推送(可选) } /** 企微配置 */ export interface WeComThirdConfig { agentId: string; - appSecret: string; + appSecret?: string; corpId: string; - startEnable: boolean; } /** 对外返回的配置(脱敏后,不含 appSecret) */ diff --git a/apps/server/src/integration/config/integration-config.service.spec.ts b/apps/server/src/integration/config/integration-config.service.spec.ts new file mode 100644 index 0000000..339524a --- /dev/null +++ b/apps/server/src/integration/config/integration-config.service.spec.ts @@ -0,0 +1,49 @@ +import { IntegrationConfigService } from './integration-config.service'; + +describe('IntegrationConfigService.testConnection', () => { + const originalFetch = global.fetch; + + afterEach(() => { + global.fetch = originalFetch; + jest.restoreAllMocks(); + }); + + it('uses the saved AppSecret when testing an existing configuration with a blank secret', async () => { + const configRepo = { + findOne: jest.fn().mockResolvedValue({ id: 1, type: 'THIRD' }), + }; + const detailRepo = { + findOne: jest.fn().mockResolvedValue({ + configId: 1, + type: 'DINGTALK_SYNC', + content: JSON.stringify({ + config: { + corpId: 'ding-corp', + agentId: 'saved-key', + appSecret: 'saved-secret', + }, + }), + }), + }; + global.fetch = jest.fn().mockResolvedValue({ + json: jest.fn().mockResolvedValue({ accessToken: 'token' }), + }) as never; + + const service = new IntegrationConfigService(configRepo as never, detailRepo as never); + + await expect( + service.testConnection('DINGTALK', { + corpId: 'ding-corp', + agentId: 'saved-key', + appSecret: '', + }), + ).resolves.toBe(true); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.dingtalk.com/v1.0/oauth2/accessToken', + expect.objectContaining({ + body: JSON.stringify({ appKey: 'saved-key', appSecret: 'saved-secret' }), + }), + ); + }); +}); diff --git a/apps/server/src/integration/config/integration-config.service.ts b/apps/server/src/integration/config/integration-config.service.ts index 56825a2..3216230 100644 --- a/apps/server/src/integration/config/integration-config.service.ts +++ b/apps/server/src/integration/config/integration-config.service.ts @@ -125,7 +125,12 @@ export class IntegrationConfigService { config: DingTalkThirdConfig | WeComThirdConfig, ): Promise { try { - const token = await this.getTokenForTest(type, config as unknown as Record); + const finalConfig = { ...config } as Record; + if (!finalConfig.appSecret) { + const savedConfig = await this.getRawConfig(type); + if (savedConfig?.appSecret) finalConfig.appSecret = savedConfig.appSecret; + } + const token = await this.getTokenForTest(type, finalConfig); return !!token; } catch (e) { this.logger.error(`连接测试失败: ${(e as Error).message}`); -- 2.49.1 From 04ef5c42d90cae50cbb3ba5838170b51e1a8fa13 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Mon, 13 Jul 2026 14:02:00 +0800 Subject: [PATCH 08/13] refactor(deposits): remove inline installment creation --- .../src/deposits/deposits.controller.ts | 4 ++-- apps/server/src/deposits/deposits.service.ts | 13 +------------ .../src/deposits/dto/deposit.dto.spec.ts | 19 +++++++++++++++++++ apps/server/src/deposits/dto/deposit.dto.ts | 19 +------------------ 4 files changed, 23 insertions(+), 32 deletions(-) create mode 100644 apps/server/src/deposits/dto/deposit.dto.spec.ts diff --git a/apps/server/src/deposits/deposits.controller.ts b/apps/server/src/deposits/deposits.controller.ts index 6e0a588..78657bd 100644 --- a/apps/server/src/deposits/deposits.controller.ts +++ b/apps/server/src/deposits/deposits.controller.ts @@ -16,7 +16,7 @@ import { Student } from '../entities/student.entity'; import { DepositsService } from './deposits.service'; import { NotificationsService } from '../notifications/notifications.service'; import { NotificationType } from '../entities/notification.entity'; -import { CreateDepositDto, RefundDepositDto, CreateDepositWithInstallmentsDto } from './dto/deposit.dto'; +import { CreateDepositDto, RefundDepositDto } from './dto/deposit.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { extractRequestInfo } from '../common/request-utils'; @@ -61,7 +61,7 @@ export class DepositsController { @Post() @RequirePermission('deposit:create') - async create(@Body() dto: CreateDepositDto | CreateDepositWithInstallmentsDto, @Request() req: any) { + async create(@Body() dto: CreateDepositDto, @Request() req: any) { const { ipAddress, userAgent } = extractRequestInfo(req); const result = await this.service.create(dto, req.user?.id); await this.logService.log({ diff --git a/apps/server/src/deposits/deposits.service.ts b/apps/server/src/deposits/deposits.service.ts index 0f93d3a..b1a8238 100644 --- a/apps/server/src/deposits/deposits.service.ts +++ b/apps/server/src/deposits/deposits.service.ts @@ -5,7 +5,7 @@ import { Deposit } from '../entities/deposit.entity'; import { Student } from '../entities/student.entity'; import { DepositInstallment } from '../entities/deposit-installment.entity'; -import { CreateDepositDto, RefundDepositDto, CreateDepositWithInstallmentsDto } from './dto/deposit.dto'; +import { CreateDepositDto, RefundDepositDto } from './dto/deposit.dto'; @Injectable() export class DepositsService { @@ -55,17 +55,6 @@ export class DepositsService { recordedBy: userId, }); - if (dto instanceof CreateDepositWithInstallmentsDto && dto.installments?.length) { - deposit.installments = dto.installments.map((i) => { - const inst = this.installmentRepo.create({ - amount: i.amount, - dueDate: i.dueDate, - status: 'pending', - }); - return inst; - }); - } - return this.repo.save(deposit); } diff --git a/apps/server/src/deposits/dto/deposit.dto.spec.ts b/apps/server/src/deposits/dto/deposit.dto.spec.ts new file mode 100644 index 0000000..6de3925 --- /dev/null +++ b/apps/server/src/deposits/dto/deposit.dto.spec.ts @@ -0,0 +1,19 @@ +import 'reflect-metadata'; +import { validate } from 'class-validator'; +import { CreateDepositDto } from './deposit.dto'; + +describe('CreateDepositDto boundaries', () => { + it('removes inline installments because they are managed after deposit creation', async () => { + const dto = Object.assign(new CreateDepositDto(), { + studentId: 1, + amount: 500, + paidDate: '2026-07-13', + installments: [{ amount: 250, dueDate: '2026-08-01' }], + }); + + await validate(dto, { whitelist: true }); + + expect(dto).toMatchObject({ studentId: 1, amount: 500, paidDate: '2026-07-13' }); + expect(dto).not.toHaveProperty('installments'); + }); +}); diff --git a/apps/server/src/deposits/dto/deposit.dto.ts b/apps/server/src/deposits/dto/deposit.dto.ts index 1b140b6..74ea313 100644 --- a/apps/server/src/deposits/dto/deposit.dto.ts +++ b/apps/server/src/deposits/dto/deposit.dto.ts @@ -1,5 +1,4 @@ -import { IsInt, IsNumber, IsString, IsOptional, ValidateNested } from 'class-validator'; -import { Type } from 'class-transformer'; +import { IsInt, IsNumber, IsString, IsOptional } from 'class-validator'; export class CreateDepositDto { @IsInt() @@ -16,16 +15,6 @@ export class CreateDepositDto { notes?: string; } -export class CreateInstallmentDto { - @IsNumber() - amount: number; - - @IsString() - dueDate: string; -} - - - export class RefundDepositDto { @IsString() refundDate: string; @@ -42,9 +31,3 @@ export class RefundDepositDto { @IsString() notes?: string; } -export class CreateDepositWithInstallmentsDto extends CreateDepositDto { - @IsOptional() - @ValidateNested({ each: true }) - @Type(() => CreateInstallmentDto) - installments?: CreateInstallmentDto[]; -} -- 2.49.1 From 7b08560aef20b8911df93ab06033265babdc64ec Mon Sep 17 00:00:00 2001 From: wangziqi Date: Mon, 13 Jul 2026 14:05:24 +0800 Subject: [PATCH 09/13] fix(deposits): show student numbers in lookup --- ...deposit-student-option.integration.test.ts | 20 +++++++++++++++++++ .../pages/Deposits/deposit-student-option.ts | 10 ++++++++++ apps/admin/src/pages/Deposits/index.tsx | 7 +++---- 3 files changed, 33 insertions(+), 4 deletions(-) create mode 100644 apps/admin/src/pages/Deposits/deposit-student-option.integration.test.ts create mode 100644 apps/admin/src/pages/Deposits/deposit-student-option.ts diff --git a/apps/admin/src/pages/Deposits/deposit-student-option.integration.test.ts b/apps/admin/src/pages/Deposits/deposit-student-option.integration.test.ts new file mode 100644 index 0000000..b876ebe --- /dev/null +++ b/apps/admin/src/pages/Deposits/deposit-student-option.integration.test.ts @@ -0,0 +1,20 @@ +import { describe, expect, it } from 'vitest'; +import { buildDepositStudentOption } from './deposit-student-option'; + +describe('deposit student option', () => { + it('uses the student number as the non-sensitive identifier', () => { + expect( + buildDepositStudentOption({ id: 23, name: '张三', studentNo: 'S2026001' }), + ).toEqual({ + value: 23, + label: '张三 (S2026001)', + }); + }); + + it('falls back to the internal id when the student number is missing', () => { + expect(buildDepositStudentOption({ id: 23, name: '张三', studentNo: null })).toEqual({ + value: 23, + label: '张三 (#23)', + }); + }); +}); diff --git a/apps/admin/src/pages/Deposits/deposit-student-option.ts b/apps/admin/src/pages/Deposits/deposit-student-option.ts new file mode 100644 index 0000000..732d6b2 --- /dev/null +++ b/apps/admin/src/pages/Deposits/deposit-student-option.ts @@ -0,0 +1,10 @@ +export interface DepositStudentLookup { + id: number; + name: string; + studentNo?: string | null; +} + +export const buildDepositStudentOption = (student: DepositStudentLookup) => ({ + value: student.id, + label: `${student.name} (${student.studentNo || `#${student.id}`})`, +}); diff --git a/apps/admin/src/pages/Deposits/index.tsx b/apps/admin/src/pages/Deposits/index.tsx index 16c29c0..3ddc854 100644 --- a/apps/admin/src/pages/Deposits/index.tsx +++ b/apps/admin/src/pages/Deposits/index.tsx @@ -19,6 +19,7 @@ import dayjs from 'dayjs'; import api from '../../api'; import PermissionButton from '../../components/PermissionButton'; import { message } from '../../ui/app-message'; +import { buildDepositStudentOption } from './deposit-student-option'; const statusMap: Record = { paid: { text: '已缴', color: 'green' }, @@ -90,10 +91,8 @@ const DepositsPage: React.FC = () => { const studentOptions = useMemo( () => students - .map((s: any) => ({ - value: s.id, - label: s.studentNo ? `${s.name} (${s.studentNo})` : s.name, - })), + .filter((s: any) => s.status === 'active') + .map(buildDepositStudentOption), [students], ); -- 2.49.1 From 1f32d1285b06f6f39577914fed87908e2648bfb6 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Mon, 13 Jul 2026 14:19:58 +0800 Subject: [PATCH 10/13] refactor(deposits): remove refund approval remnants --- apps/admin/src/pages/Deposits/index.tsx | 23 +--- .../server/src/dashboard/dashboard.service.ts | 3 +- ...database-migrations.deposit-refund.spec.ts | 101 ++++++++++++++++++ .../database/database-migrations.service.ts | 39 +++++++ .../src/deposits/deposits.refund.spec.ts | 38 +++++++ apps/server/src/deposits/deposits.service.ts | 5 +- apps/server/src/entities/deposit.entity.ts | 17 +-- 7 files changed, 186 insertions(+), 40 deletions(-) create mode 100644 apps/server/src/database/database-migrations.deposit-refund.spec.ts create mode 100644 apps/server/src/deposits/deposits.refund.spec.ts diff --git a/apps/admin/src/pages/Deposits/index.tsx b/apps/admin/src/pages/Deposits/index.tsx index 3ddc854..1d50ca3 100644 --- a/apps/admin/src/pages/Deposits/index.tsx +++ b/apps/admin/src/pages/Deposits/index.tsx @@ -28,13 +28,6 @@ const statusMap: Record = { deducted: { text: '已全扣', color: 'red' }, }; -const refundStatusMap: Record = { - pending: { text: '历史退款处理中', color: 'orange' }, - head_teacher_approved: { text: '历史退款处理中', color: 'blue' }, - finance_approved: { text: '已退款', color: 'green' }, - refunded: { text: '已退款', color: 'green' }, -}; - const installmentStatusMap: Record = { pending: { text: '待缴', color: 'orange' }, paid: { text: '已缴', color: 'green' }, @@ -187,12 +180,6 @@ const DepositsPage: React.FC = () => { dataIndex: 'status', render: (s: string) => {statusMap[s]?.text || s}, }, - { - title: '退款状态', - dataIndex: 'refundStatus', - render: (s: string) => - s ? {refundStatusMap[s]?.text || s} : '-', - }, { title: '退还金额', dataIndex: 'refundAmount', @@ -220,7 +207,7 @@ const DepositsPage: React.FC = () => { > 详情 - {record.status === 'paid' && !record.refundStatus && ( + {record.status === 'paid' && ( <> { {statusMap[detailModal.status]?.text || detailModal.status}

- {detailModal.refundStatus && ( -

- 退款状态:{' '} - - {refundStatusMap[detailModal.refundStatus]?.text || detailModal.refundStatus} - -

- )} {detailModal.notes &&

备注: {detailModal.notes}

}
diff --git a/apps/server/src/dashboard/dashboard.service.ts b/apps/server/src/dashboard/dashboard.service.ts index 96871af..aa7a0b4 100644 --- a/apps/server/src/dashboard/dashboard.service.ts +++ b/apps/server/src/dashboard/dashboard.service.ts @@ -119,8 +119,7 @@ export class DashboardService { const pendingQb = this.depositRepo .createQueryBuilder('d') .select('SUM(d.amount)', 'total') - .where('d.status = :paid', { paid: 'paid' }) - .andWhere('d.refundStatus IS NULL'); + .where('d.status = :paid', { paid: 'paid' }); const pendingResult = await pendingQb.getRawOne(); const pendingDeposits = parseFloat(pendingResult?.total || '0'); diff --git a/apps/server/src/database/database-migrations.deposit-refund.spec.ts b/apps/server/src/database/database-migrations.deposit-refund.spec.ts new file mode 100644 index 0000000..1b1ac7c --- /dev/null +++ b/apps/server/src/database/database-migrations.deposit-refund.spec.ts @@ -0,0 +1,101 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getDataSourceToken } from '@nestjs/typeorm'; +import { DatabaseMigrationsService } from './database-migrations.service'; + +function createRunner(columns: string[]) { + return { + connect: jest.fn(), + release: jest.fn(), + query: jest.fn().mockResolvedValue([]), + getTables: jest.fn().mockResolvedValue(columns.length ? [{ name: 'deposits' }] : []), + getTable: jest.fn().mockResolvedValue({ + name: 'deposits', + columns: columns.map((name) => ({ name })), + }), + renameColumn: jest.fn().mockResolvedValue(undefined), + dropColumn: jest.fn().mockResolvedValue(undefined), + }; +} + +async function createService(runner: ReturnType) { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DatabaseMigrationsService, + { + provide: getDataSourceToken(), + useValue: { + options: { type: 'better-sqlite3' }, + createQueryRunner: jest.fn().mockReturnValue(runner), + }, + }, + ], + }).compile(); + return module.get(DatabaseMigrationsService) as DatabaseMigrationsService & { + cleanupDepositRefundColumns(): Promise; + }; +} + +describe('DatabaseMigrationsService — deposit refund cleanup', () => { + it('renames refund audit fields and drops approval-flow remnants', async () => { + const runner = createRunner([ + 'id', + 'refund_status', + 'refund_requested_at', + 'refund_approved_by', + 'refund_approved_at', + 'refund_rejected_reason', + ]); + const service = await createService(runner); + + await service.cleanupDepositRefundColumns(); + + expect(runner.renameColumn).toHaveBeenCalledWith( + 'deposits', + 'refund_approved_by', + 'refunded_by', + ); + expect(runner.renameColumn).toHaveBeenCalledWith( + 'deposits', + 'refund_approved_at', + 'refunded_at', + ); + expect(runner.dropColumn).toHaveBeenCalledWith('deposits', 'refund_status'); + expect(runner.dropColumn).toHaveBeenCalledWith('deposits', 'refund_requested_at'); + expect(runner.dropColumn).toHaveBeenCalledWith('deposits', 'refund_rejected_reason'); + expect(runner.release).toHaveBeenCalled(); + }); + + it('merges legacy audit values before dropping duplicate legacy columns', async () => { + const runner = createRunner([ + 'id', + 'refund_approved_by', + 'refund_approved_at', + 'refunded_by', + 'refunded_at', + ]); + const service = await createService(runner); + + await service.cleanupDepositRefundColumns(); + + expect(runner.query).toHaveBeenCalledWith( + 'UPDATE deposits SET refunded_by = COALESCE(refunded_by, refund_approved_by)', + ); + expect(runner.query).toHaveBeenCalledWith( + 'UPDATE deposits SET refunded_at = COALESCE(refunded_at, refund_approved_at)', + ); + expect(runner.dropColumn).toHaveBeenCalledWith('deposits', 'refund_approved_by'); + expect(runner.dropColumn).toHaveBeenCalledWith('deposits', 'refund_approved_at'); + expect(runner.renameColumn).not.toHaveBeenCalled(); + }); + + it('does nothing when the deposits table is absent', async () => { + const runner = createRunner([]); + const service = await createService(runner); + + await service.cleanupDepositRefundColumns(); + + expect(runner.renameColumn).not.toHaveBeenCalled(); + expect(runner.dropColumn).not.toHaveBeenCalled(); + expect(runner.release).toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/database/database-migrations.service.ts b/apps/server/src/database/database-migrations.service.ts index b40d799..0254339 100644 --- a/apps/server/src/database/database-migrations.service.ts +++ b/apps/server/src/database/database-migrations.service.ts @@ -15,6 +15,7 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap { await this.normalizeClassDates(); await this.protectAttendanceHistory(); await this.removeUnusedClassroomColumns(); + await this.cleanupDepositRefundColumns(); } private async removeUnusedClassroomColumns(): Promise { @@ -36,6 +37,44 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap { } } + private async cleanupDepositRefundColumns(): Promise { + const runner = this.dataSource.createQueryRunner(); + await runner.connect(); + try { + const tables = await runner.getTables(['deposits']); + if (tables.length === 0) return; + + const table = await runner.getTable('deposits'); + const columnNames = new Set(table?.columns.map((column) => column.name) ?? []); + for (const [legacyName, currentName] of [ + ['refund_approved_by', 'refunded_by'], + ['refund_approved_at', 'refunded_at'], + ] as const) { + if (!columnNames.has(legacyName)) continue; + + if (columnNames.has(currentName)) { + await runner.query( + `UPDATE deposits SET ${currentName} = COALESCE(${currentName}, ${legacyName})`, + ); + await runner.dropColumn('deposits', legacyName); + } else { + await runner.renameColumn('deposits', legacyName, currentName); + columnNames.add(currentName); + } + columnNames.delete(legacyName); + } + + for (const columnName of ['refund_status', 'refund_requested_at', 'refund_rejected_reason']) { + if (columnNames.has(columnName)) { + await runner.dropColumn('deposits', columnName); + columnNames.delete(columnName); + } + } + } finally { + await runner.release(); + } + } + private async ensureAiConfigTable(): Promise { const runner = this.dataSource.createQueryRunner(); await runner.connect(); diff --git a/apps/server/src/deposits/deposits.refund.spec.ts b/apps/server/src/deposits/deposits.refund.spec.ts new file mode 100644 index 0000000..8400678 --- /dev/null +++ b/apps/server/src/deposits/deposits.refund.spec.ts @@ -0,0 +1,38 @@ +import { DepositsService } from './deposits.service'; +import { Deposit } from '../entities/deposit.entity'; + +describe('DepositsService — direct refund', () => { + it('stores the refund result on the main status and renamed audit fields', async () => { + const deposit = { + id: 1, + amount: 500, + status: 'paid', + } as Deposit; + const repo = { + findOne: jest.fn().mockResolvedValue(deposit), + save: jest.fn().mockImplementation(async (value: Deposit) => value), + }; + const service = new DepositsService(repo as never, {} as never, {} as never); + + const result = await service.refund( + 1, + { + refundDate: '2026-07-13', + deductionAmount: 100, + deductionReason: '物品损坏', + }, + 42, + ); + + expect(result).toMatchObject({ + refundDate: '2026-07-13', + refundAmount: 400, + deductionAmount: 100, + deductionReason: '物品损坏', + status: 'partial_refund', + refundedBy: 42, + }); + expect(result.refundedAt).toBeInstanceOf(Date); + expect(repo.save).toHaveBeenCalledWith(deposit); + }); +}); diff --git a/apps/server/src/deposits/deposits.service.ts b/apps/server/src/deposits/deposits.service.ts index b1a8238..b6597f9 100644 --- a/apps/server/src/deposits/deposits.service.ts +++ b/apps/server/src/deposits/deposits.service.ts @@ -103,9 +103,8 @@ export class DepositsService { deposit.status = deduction > 0 ? (refundAmount > 0 ? 'partial_refund' : 'deducted') : 'refunded'; if (dto.notes) deposit.notes = dto.notes; - deposit.refundStatus = 'refunded'; - deposit.refundApprovedBy = userId ?? null as unknown as number; - deposit.refundApprovedAt = new Date(); + deposit.refundedBy = userId ?? null; + deposit.refundedAt = new Date(); return this.repo.save(deposit); } diff --git a/apps/server/src/entities/deposit.entity.ts b/apps/server/src/entities/deposit.entity.ts index 258db25..f657010 100644 --- a/apps/server/src/entities/deposit.entity.ts +++ b/apps/server/src/entities/deposit.entity.ts @@ -46,20 +46,11 @@ export class Deposit { @Column({ name: 'recorded_by', nullable: true }) recordedBy: number; - @Column({ name: 'refund_status', length: 30, nullable: true }) - refundStatus: string; // pending | head_teacher_approved | finance_approved | refunded + @Column({ name: 'refunded_by', type: 'integer', nullable: true }) + refundedBy: number | null; - @Column({ name: 'refund_requested_at', type: 'datetime', nullable: true }) - refundRequestedAt: Date; - - @Column({ name: 'refund_approved_by', type: 'integer', nullable: true }) - refundApprovedBy: number; - - @Column({ name: 'refund_approved_at', type: 'datetime', nullable: true }) - refundApprovedAt: Date; - - @Column({ name: 'refund_rejected_reason', length: 500, nullable: true }) - refundRejectedReason: string; + @Column({ name: 'refunded_at', type: 'datetime', nullable: true }) + refundedAt: Date | null; @OneToMany(() => DepositInstallment, (i) => i.deposit, { cascade: true, eager: true }) installments: DepositInstallment[]; -- 2.49.1 From 0533c30ecec02ce7748dec714ea2ad3f18f115d9 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Mon, 13 Jul 2026 14:38:34 +0800 Subject: [PATCH 11/13] refactor(classes): preserve student membership history --- apps/admin/src/pages/Classes/detail.tsx | 13 ++- .../classes.batch-import-membership.spec.ts | 47 ++++++++ .../src/classes/classes.membership.spec.ts | 95 +++++++++++++++ apps/server/src/classes/classes.service.ts | 110 +++++++++++++----- .../database-migrations.class-student.spec.ts | 56 +++++++++ .../database/database-migrations.service.ts | 17 +++ .../src/entities/class-student.entity.ts | 7 +- 7 files changed, 303 insertions(+), 42 deletions(-) create mode 100644 apps/server/src/classes/classes.batch-import-membership.spec.ts create mode 100644 apps/server/src/classes/classes.membership.spec.ts create mode 100644 apps/server/src/database/database-migrations.class-student.spec.ts diff --git a/apps/admin/src/pages/Classes/detail.tsx b/apps/admin/src/pages/Classes/detail.tsx index 435b46b..d2fbaa7 100644 --- a/apps/admin/src/pages/Classes/detail.tsx +++ b/apps/admin/src/pages/Classes/detail.tsx @@ -19,6 +19,7 @@ interface ClassStudent { studentName: string; studentNo: string; joinDate: string; + leaveDate: string | null; status: string; } @@ -305,6 +306,7 @@ const ClassDetailPage: React.FC = () => { { title: '姓名', dataIndex: 'studentName' }, { title: '学号', dataIndex: 'studentNo' }, { title: '加入日期', dataIndex: 'joinDate' }, + { title: '离班日期', dataIndex: 'leaveDate', render: (v: string | null) => v || '-' }, { title: '状态', dataIndex: 'status', @@ -316,11 +318,12 @@ const ClassDetailPage: React.FC = () => { }, { title: '操作', - render: (_: unknown, r: ClassStudent) => ( - handleRemoveStudent(r.studentId)}> - 移除 - - ), + render: (_: unknown, r: ClassStudent) => + r.status === 'active' ? ( + handleRemoveStudent(r.studentId)}> + 移除 + + ) : null, }, ]; diff --git a/apps/server/src/classes/classes.batch-import-membership.spec.ts b/apps/server/src/classes/classes.batch-import-membership.spec.ts new file mode 100644 index 0000000..fea8627 --- /dev/null +++ b/apps/server/src/classes/classes.batch-import-membership.spec.ts @@ -0,0 +1,47 @@ +import { ClassesService } from './classes.service'; +import { ClassStudent } from '../entities'; + +describe('ClassesService — DingTalk class import membership lifecycle', () => { + it('reactivates left memberships and skips active memberships', async () => { + const left = { + classId: 3, + studentId: 8, + status: 'left', + joinDate: '2026-01-01', + leaveDate: '2026-02-01', + } as ClassStudent; + const active = { classId: 3, studentId: 9, status: 'active' } as ClassStudent; + const classStudentRepo = { + find: jest.fn().mockResolvedValue([left, active]), + create: jest.fn().mockImplementation((value: Partial) => value), + save: jest.fn().mockImplementation(async (value: ClassStudent[]) => value), + }; + const service = new ClassesService( + { findOne: jest.fn().mockResolvedValue({ id: 3 }) } as never, + classStudentRepo as never, + {} as never, + {} as never, + {} as never, + {} as never, + { create: jest.fn(), save: jest.fn() } as never, + { + find: jest.fn().mockResolvedValue([ + { dingUserId: 'd8', studentId: 8 }, + { dingUserId: 'd9', studentId: 9 }, + ]), + create: jest.fn(), + save: jest.fn(), + } as never, + ); + + const result = await service.batchImportStudents(3, [ + { dingUserId: 'd8', name: '学生8' }, + { dingUserId: 'd9', name: '学生9' }, + ]); + + expect(result).toEqual({ imported: 1, skipped: 1 }); + expect(left).toMatchObject({ status: 'active', leaveDate: null }); + expect(left.joinDate).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(classStudentRepo.save).toHaveBeenCalledWith([left]); + }); +}); diff --git a/apps/server/src/classes/classes.membership.spec.ts b/apps/server/src/classes/classes.membership.spec.ts new file mode 100644 index 0000000..9dc2167 --- /dev/null +++ b/apps/server/src/classes/classes.membership.spec.ts @@ -0,0 +1,95 @@ +import { BadRequestException, NotFoundException } from '@nestjs/common'; +import { ClassesService } from './classes.service'; +import { ClassStudent } from '../entities'; + +function createService( + classStudentRepo: Record, + classRepo: Record = { findOne: jest.fn().mockResolvedValue({ id: 3 }) }, + studentRepo: Record = { + find: jest.fn().mockResolvedValue([{ id: 8 }, { id: 9 }, { id: 10 }]), + }, +) { + return new ClassesService( + classRepo as never, + classStudentRepo as never, + {} as never, + {} as never, + {} as never, + {} as never, + studentRepo as never, + {} as never, + ); +} + +describe('ClassesService — student membership lifecycle', () => { + it('marks an active membership as left instead of deleting it', async () => { + const membership = { + classId: 3, + studentId: 8, + status: 'active', + leaveDate: null, + } as ClassStudent; + const repo = { + findOne: jest.fn().mockResolvedValue(membership), + save: jest.fn().mockImplementation(async (value: ClassStudent) => value), + }; + const service = createService(repo); + + await service.removeStudent(3, 8); + + expect(membership.status).toBe('left'); + expect(membership.leaveDate).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(repo.save).toHaveBeenCalledWith(membership); + }); + + it('rejects removing an already-left membership', async () => { + const repo = { + findOne: jest.fn().mockResolvedValue({ status: 'left' }), + save: jest.fn(), + }; + const service = createService(repo); + + await expect(service.removeStudent(3, 8)).rejects.toBeInstanceOf(BadRequestException); + expect(repo.save).not.toHaveBeenCalled(); + }); + + it('rejects removing a student without a membership', async () => { + const repo = { + findOne: jest.fn().mockResolvedValue(null), + save: jest.fn(), + }; + const service = createService(repo); + + await expect(service.removeStudent(3, 8)).rejects.toBeInstanceOf(NotFoundException); + }); + + it('reactivates left memberships, creates new ones, and skips active ones', async () => { + const left = { + id: 1, + classId: 3, + studentId: 8, + status: 'left', + joinDate: '2026-01-01', + leaveDate: '2026-02-01', + } as ClassStudent; + const active = { id: 2, classId: 3, studentId: 9, status: 'active' } as ClassStudent; + const repo = { + find: jest.fn().mockResolvedValue([left, active]), + create: jest.fn().mockImplementation((value: Partial) => value), + save: jest.fn().mockImplementation(async (value: ClassStudent[]) => value), + }; + const service = createService(repo); + + const result = await service.addStudents(3, [8, 9, 10, 10]); + + expect(result).toEqual({ added: 2, skipped: 1 }); + expect(left).toMatchObject({ status: 'active', leaveDate: null }); + expect(left.joinDate).toMatch(/^\d{4}-\d{2}-\d{2}$/); + expect(repo.create).toHaveBeenCalledWith( + expect.objectContaining({ classId: 3, studentId: 10, status: 'active' }), + ); + expect(repo.save).toHaveBeenCalledWith( + expect.arrayContaining([left, expect.objectContaining({ studentId: 10 })]), + ); + }); +}); diff --git a/apps/server/src/classes/classes.service.ts b/apps/server/src/classes/classes.service.ts index d61a45f..36451a6 100644 --- a/apps/server/src/classes/classes.service.ts +++ b/apps/server/src/classes/classes.service.ts @@ -227,34 +227,45 @@ export class ClassesService { } // 3. Fetch existing class-student links in one query - const allStudentIds = Array.from(dingToStudentId.values()); - const alreadyInClass = new Set(); - if (allStudentIds.length > 0) { - const existingClassStudents = await this.classStudentRepo.find({ - where: { classId, studentId: In(allStudentIds) }, - }); - for (const cs of existingClassStudents) { - alreadyInClass.add(cs.studentId); + const allStudentIds = Array.from(new Set(dingToStudentId.values())); + const existingClassStudents = + allStudentIds.length > 0 + ? await this.classStudentRepo.find({ + where: { classId, studentId: In(allStudentIds) }, + }) + : []; + const existingByStudentId = new Map( + existingClassStudents.map((classStudent) => [classStudent.studentId, classStudent]), + ); + const today = new Date().toISOString().slice(0, 10); + let skipped = 0; + const memberships = allStudentIds.flatMap((studentId) => { + const existing = existingByStudentId.get(studentId); + if (existing?.status === 'active') { + skipped++; + return []; } - } - - // 4. Batch insert new class-student records - const newClassStudents = allStudentIds - .filter((sid) => !alreadyInClass.has(sid)) - .map((studentId) => + if (existing) { + existing.status = 'active'; + existing.joinDate = today; + existing.leaveDate = null; + return [existing]; + } + return [ this.classStudentRepo.create({ classId, studentId, status: 'active', - joinDate: new Date().toISOString().slice(0, 10), + joinDate: today, }), - ); + ]; + }); - if (newClassStudents.length > 0) { - await this.classStudentRepo.save(newClassStudents); + if (memberships.length > 0) { + await this.classStudentRepo.save(memberships); } - return { imported: newClassStudents.length, skipped: alreadyInClass.size }; + return { imported: memberships.length, skipped }; } async update(id: number, dto: UpdateClassDto) { const cls = await this.classRepo.findOne({ where: { id } }); @@ -315,26 +326,61 @@ export class ClassesService { } async addStudents(classId: number, studentIds: number[]) { + const uniqueStudentIds = [...new Set(studentIds)]; + if (uniqueStudentIds.length === 0) return { added: 0, skipped: 0 }; + + const cls = await this.classRepo.findOne({ where: { id: classId } }); + if (!cls) throw new NotFoundException('班级不存在'); + + const students = await this.studentRepo.find({ where: { id: In(uniqueStudentIds) } }); + if (students.length !== uniqueStudentIds.length) { + throw new NotFoundException('部分学生不存在'); + } + const existing = await this.classStudentRepo.find({ - where: { classId, studentId: In(studentIds) }, + where: { classId, studentId: In(uniqueStudentIds) }, }); - const existingIds = new Set(existing.map((e) => e.studentId)); - const newIds = studentIds.filter((id) => !existingIds.has(id)); - - const entries = newIds.map((sid) => - this.classStudentRepo.create({ - classId, - studentId: sid, - joinDate: new Date().toISOString().split('T')[0], - }), + const existingByStudentId = new Map( + existing.map((classStudent) => [classStudent.studentId, classStudent]), ); - if (entries.length) await this.classStudentRepo.save(entries); + const today = new Date().toISOString().split('T')[0]; + let skipped = 0; + const memberships = uniqueStudentIds.flatMap((studentId) => { + const current = existingByStudentId.get(studentId); + if (current?.status === 'active') { + skipped++; + return []; + } + if (current) { + current.status = 'active'; + current.joinDate = today; + current.leaveDate = null; + return [current]; + } + return [ + this.classStudentRepo.create({ + classId, + studentId, + status: 'active', + joinDate: today, + }), + ]; + }); + if (memberships.length) await this.classStudentRepo.save(memberships); - return { added: entries.length, skipped: studentIds.length - entries.length }; + return { added: memberships.length, skipped }; } async removeStudent(classId: number, studentId: number) { - await this.classStudentRepo.delete({ classId, studentId }); + const membership = await this.classStudentRepo.findOne({ + where: { classId, studentId }, + }); + if (!membership) throw new NotFoundException('学生不在该班级'); + if (membership.status !== 'active') throw new BadRequestException('学生已离班'); + + membership.status = 'left'; + membership.leaveDate = new Date().toISOString().split('T')[0]; + await this.classStudentRepo.save(membership); return { success: true }; } diff --git a/apps/server/src/database/database-migrations.class-student.spec.ts b/apps/server/src/database/database-migrations.class-student.spec.ts new file mode 100644 index 0000000..6088762 --- /dev/null +++ b/apps/server/src/database/database-migrations.class-student.spec.ts @@ -0,0 +1,56 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getDataSourceToken } from '@nestjs/typeorm'; +import { DatabaseMigrationsService } from './database-migrations.service'; + +function createRunner(tableExists: boolean, columns: string[] = []) { + return { + connect: jest.fn(), + release: jest.fn(), + getTables: jest.fn().mockResolvedValue(tableExists ? [{ name: 'class_student' }] : []), + getTable: jest.fn().mockResolvedValue({ + name: 'class_student', + columns: columns.map((name) => ({ name })), + }), + dropColumn: jest.fn().mockResolvedValue(undefined), + }; +} + +async function createService(runner: ReturnType) { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DatabaseMigrationsService, + { + provide: getDataSourceToken(), + useValue: { + options: { type: 'better-sqlite3' }, + createQueryRunner: jest.fn().mockReturnValue(runner), + }, + }, + ], + }).compile(); + return module.get(DatabaseMigrationsService) as DatabaseMigrationsService & { + removeUnusedClassStudentColumns(): Promise; + }; +} + +describe('DatabaseMigrationsService — class student cleanup', () => { + it('drops the unused enrollment_id column', async () => { + const runner = createRunner(true, ['id', 'enrollment_id']); + const service = await createService(runner); + + await service.removeUnusedClassStudentColumns(); + + expect(runner.dropColumn).toHaveBeenCalledWith('class_student', 'enrollment_id'); + expect(runner.release).toHaveBeenCalled(); + }); + + it('does nothing when the table is absent', async () => { + const runner = createRunner(false); + const service = await createService(runner); + + await service.removeUnusedClassStudentColumns(); + + expect(runner.dropColumn).not.toHaveBeenCalled(); + expect(runner.release).toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/database/database-migrations.service.ts b/apps/server/src/database/database-migrations.service.ts index 0254339..d9c8587 100644 --- a/apps/server/src/database/database-migrations.service.ts +++ b/apps/server/src/database/database-migrations.service.ts @@ -16,6 +16,7 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap { await this.protectAttendanceHistory(); await this.removeUnusedClassroomColumns(); await this.cleanupDepositRefundColumns(); + await this.removeUnusedClassStudentColumns(); } private async removeUnusedClassroomColumns(): Promise { @@ -75,6 +76,22 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap { } } + private async removeUnusedClassStudentColumns(): Promise { + const runner = this.dataSource.createQueryRunner(); + await runner.connect(); + try { + const tables = await runner.getTables(['class_student']); + if (tables.length === 0) return; + + const table = await runner.getTable('class_student'); + if (table?.columns.some((column) => column.name === 'enrollment_id')) { + await runner.dropColumn('class_student', 'enrollment_id'); + } + } finally { + await runner.release(); + } + } + private async ensureAiConfigTable(): Promise { const runner = this.dataSource.createQueryRunner(); await runner.connect(); diff --git a/apps/server/src/entities/class-student.entity.ts b/apps/server/src/entities/class-student.entity.ts index 4b9906c..7a6ca73 100644 --- a/apps/server/src/entities/class-student.entity.ts +++ b/apps/server/src/entities/class-student.entity.ts @@ -30,14 +30,11 @@ export class ClassStudent { @JoinColumn({ name: 'student_id' }) student: Student; - @Column({ name: 'enrollment_id', type: 'integer', nullable: true }) - enrollmentId: number; - @Column({ name: 'join_date', type: 'date', nullable: true }) - joinDate: string; + joinDate: string | null; @Column({ name: 'leave_date', type: 'date', nullable: true }) - leaveDate: string; + leaveDate: string | null; @Column({ name: 'status', length: 10, default: 'active' }) status: string; -- 2.49.1 From aa1ed7db567b9e2d3b5dc0c984ec3fca21a46769 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Mon, 13 Jul 2026 15:12:36 +0800 Subject: [PATCH 12/13] refactor: resolve remaining field audit issues --- .../src/pages/ClassroomRentals/index.tsx | 87 +++++-- apps/admin/src/pages/Classrooms/index.tsx | 20 +- apps/admin/src/pages/Rooms/index.tsx | 24 +- apps/server/package.json | 1 + apps/server/src/archive/archive.controller.ts | 20 +- apps/server/src/archive/archive.service.ts | 38 ++- .../src/archive/dto/archive.dto.spec.ts | 41 +++- apps/server/src/archive/dto/archive.dto.ts | 7 + .../classroom-rentals.controller.ts | 36 +++ .../classroom-rentals.service.spec.ts | 70 ++++-- .../classroom-rentals.service.ts | 96 ++++++-- .../src/classroom-rentals/dto/rental.dto.ts | 6 +- .../src/classrooms/classrooms.service.ts | 217 ++++++++++++++---- .../src/classrooms/classrooms.status.spec.ts | 39 ++++ .../src/classrooms/dto/classroom.dto.ts | 8 +- .../server/src/dashboard/dashboard.service.ts | 12 +- ...tabase-migrations.classroom-status.spec.ts | 36 +++ .../database/database-migrations.service.ts | 47 ++-- .../src/entities/classroom-rental.entity.ts | 10 +- apps/server/src/entities/classroom.entity.ts | 14 +- apps/server/src/entities/index.ts | 4 +- apps/server/src/entities/room.entity.ts | 13 +- .../integration/config/dto/config.dto.spec.ts | 48 ++++ .../src/integration/config/dto/config.dto.ts | 72 ++++-- .../config/integration-config.controller.ts | 6 +- .../config/integration-config.service.ts | 18 +- .../src/occupancies/occupancies.service.ts | 33 +-- apps/server/src/rooms/dto/room.dto.spec.ts | 21 ++ apps/server/src/rooms/dto/room.dto.ts | 11 +- apps/server/src/rooms/rooms.controller.ts | 22 +- apps/server/src/rooms/rooms.gender.spec.ts | 43 ++++ apps/server/src/rooms/rooms.service.ts | 58 +++-- .../server/src/schedules/schedules.service.ts | 17 +- package-lock.json | 21 ++ 34 files changed, 953 insertions(+), 263 deletions(-) create mode 100644 apps/server/src/classrooms/classrooms.status.spec.ts create mode 100644 apps/server/src/database/database-migrations.classroom-status.spec.ts create mode 100644 apps/server/src/integration/config/dto/config.dto.spec.ts create mode 100644 apps/server/src/rooms/dto/room.dto.spec.ts create mode 100644 apps/server/src/rooms/rooms.gender.spec.ts diff --git a/apps/admin/src/pages/ClassroomRentals/index.tsx b/apps/admin/src/pages/ClassroomRentals/index.tsx index 08d7451..5892fad 100644 --- a/apps/admin/src/pages/ClassroomRentals/index.tsx +++ b/apps/admin/src/pages/ClassroomRentals/index.tsx @@ -15,7 +15,7 @@ import { Tooltip, Empty, } from 'antd'; -import { PlusOutlined, UploadOutlined, DeleteOutlined, FileTextOutlined } from '@ant-design/icons'; +import { PlusOutlined, UploadOutlined, DeleteOutlined, FileTextOutlined, StopOutlined, CheckOutlined } from '@ant-design/icons'; import dayjs, { Dayjs } from 'dayjs'; import api from '../../api'; import { downloadBlob } from '../../utils/download'; @@ -39,6 +39,7 @@ const ClassroomRentalsPage: React.FC = () => { const [editing, setEditing] = useState(null); const [form] = Form.useForm(); const [filterMonth, setFilterMonth] = useState(null); + const [filterStatus, setFilterStatus] = useState(); const [searchText, setSearchText] = useState(''); const [saving, setSaving] = useState(false); const [unavailableDates, setUnavailableDates] = useState>(new Set()); @@ -48,20 +49,22 @@ const ClassroomRentalsPage: React.FC = () => { const selectedClassroomId = Form.useWatch('classroomId', form); const filteredData = useMemo(() => { - if (!searchText) return data; - const s = searchText.toLowerCase(); return data.filter((r: any) => { + if (filterStatus && r.effectiveStatus !== filterStatus) return false; + if (!searchText) return true; + const s = searchText.toLowerCase(); const matchClassroom = r.classroom?.name?.toLowerCase().includes(s); const matchOrganization = r.lesseeOrganization?.name?.toLowerCase().includes(s); return matchClassroom || matchOrganization; }); - }, [data, searchText]); + }, [data, searchText, filterStatus]); const fetchData = async () => { setLoading(true); try { const params: any = {}; if (filterMonth) params.month = filterMonth.format('YYYY-MM'); + params.includeEnded = true; const res: any = await api.get('/classroom-rentals', { params }); setData(res); } catch (e: any) { @@ -215,6 +218,16 @@ const ClassroomRentalsPage: React.FC = () => { } }; + const handleRentalAction = async (id: number, action: 'cancel' | 'end') => { + try { + await api.put(`/classroom-rentals/${id}/${action}`); + message.success(action === 'cancel' ? '租赁已取消' : '租赁已结束'); + fetchData(); + } catch (e: any) { + message.error(e?.message || '操作失败'); + } + }; + const handleDownloadContract = async (id: number, filename?: string) => { try { await downloadBlob(`/classroom-rentals/${id}/contract`, filename || `contract-${id}.pdf`); @@ -308,6 +321,19 @@ const ClassroomRentalsPage: React.FC = () => { width: 100, render: (v: any) => (v ? `¥${v}` : '-'), }, + { + title: '状态', + dataIndex: 'effectiveStatus', + width: 90, + render: (status: string) => { + const config: Record = { + active: { text: '进行中', color: 'green' }, + ended: { text: '已结束', color: 'default' }, + cancelled: { text: '已取消', color: 'red' }, + }; + return {config[status]?.text || status}; + }, + }, { title: '合同', width: 120, @@ -364,21 +390,30 @@ const ClassroomRentalsPage: React.FC = () => { width: 150, render: (_: any, record: any) => ( - openEdit(record)} - > - 编辑 - - handleDelete(record.id)} - > - - 删除 - - + {record.effectiveStatus === 'active' && ( + <> + openEdit(record)}> + 编辑 + + handleRentalAction(record.id, 'cancel')}> + }> + 取消 + + + {!dayjs(record.startDate).isAfter(dayjs(), 'day') && ( + handleRentalAction(record.id, 'end')}> + }> + 结束 + + + )} + + )} + {record.effectiveStatus !== 'active' && ( + handleDelete(record.id)}> + 删除 + + )} ), }, @@ -415,6 +450,18 @@ const ClassroomRentalsPage: React.FC = () => { allowClear format="YYYY-MM" /> + + +
+ )} diff --git a/apps/admin/src/pages/Rooms/index.tsx b/apps/admin/src/pages/Rooms/index.tsx index 8afe184..c7cd535 100644 --- a/apps/admin/src/pages/Rooms/index.tsx +++ b/apps/admin/src/pages/Rooms/index.tsx @@ -159,13 +159,17 @@ const RoomsPage: React.FC = () => { const handleSave = async () => { const values = await form.validateFields(); + const payload = { + ...values, + gender: values.gender === '__unset__' ? null : values.gender, + }; setSaving(true); try { if (editing) { - await api.put(`/rooms/${editing.id}`, values); + await api.put(`/rooms/${editing.id}`, payload); message.success('更新成功'); } else { - await api.post('/rooms', values); + await api.post('/rooms', payload); message.success('创建成功'); } setModalOpen(false); @@ -341,7 +345,8 @@ const RoomsPage: React.FC = () => { title: '性别', dataIndex: 'gender', width: 80, - render: (v: any) => (v ? {v} : '-'), + render: (v: string | null) => + v ? {v}生宿舍 : '未指定', }, { title: '状态', @@ -386,7 +391,7 @@ const RoomsPage: React.FC = () => { onClick={() => { const rec = record as { id: number }; setEditing(rec); - form.setFieldsValue(rec); + form.setFieldsValue({ ...rec, gender: (record as any).gender ?? '__unset__' }); setModalOpen(true); }} > @@ -468,6 +473,7 @@ const RoomsPage: React.FC = () => { onClick={() => { setEditing(null); form.resetFields(); + form.setFieldsValue({ gender: '__unset__' }); setModalOpen(true); }} > @@ -571,6 +577,15 @@ const RoomsPage: React.FC = () => { placeholder="留空自动解析" /> + + {
楼栋:{drawerRoom.building || '-'}
楼层:{drawerRoom.floor ?? '-'}
类型:{drawerRoom.roomType || '-'}
+
宿舍性别:{drawerRoom.gender ? `${drawerRoom.gender}生宿舍` : '未指定'}
额定人数:{drawerRoom.capacity}
租赁类别:{drawerRoom.rentalCategory === 'long' ? '长租' : '短租'}
月租金:{drawerRoom.monthlyRate ? `¥${drawerRoom.monthlyRate}` : '-'}
diff --git a/apps/server/package.json b/apps/server/package.json index 095d893..b909f7c 100644 --- a/apps/server/package.json +++ b/apps/server/package.json @@ -28,6 +28,7 @@ "@nestjs/core": "^11.0.1", "@nestjs/event-emitter": "^3.1.0", "@nestjs/jwt": "^11.0.2", + "@nestjs/mapped-types": "^2.1.1", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.1.19", "@nestjs/schedule": "^6.1.3", diff --git a/apps/server/src/archive/archive.controller.ts b/apps/server/src/archive/archive.controller.ts index 86642bc..3c626af 100644 --- a/apps/server/src/archive/archive.controller.ts +++ b/apps/server/src/archive/archive.controller.ts @@ -20,8 +20,11 @@ import { ArchiveService } from './archive.service'; import { UpsertProfileDto, CreateEnrollmentDto, + UpdateEnrollmentDto, CreateExamScoreDto, + UpdateExamScoreDto, CreateLearningRecordDto, + UpdateLearningRecordDto, UpsertResultDto, } from './dto/archive.dto'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; @@ -110,7 +113,7 @@ export class ArchiveController { @RequirePermission('student:edit') async updateEnrollment( @Param('id') id: string, - @Body() dto: Partial, + @Body() dto: UpdateEnrollmentDto, @Request() req: AuthenticatedRequest, ) { const { ipAddress, userAgent } = extractRequestInfo(req); @@ -174,7 +177,7 @@ export class ArchiveController { @RequirePermission('student:edit') async updateExamScore( @Param('id') id: string, - @Body() dto: Partial, + @Body() dto: UpdateExamScoreDto, @Request() req: AuthenticatedRequest, ) { const { ipAddress, userAgent } = extractRequestInfo(req); @@ -238,7 +241,7 @@ export class ArchiveController { @RequirePermission('student:edit') async updateLearningRecord( @Param('id') id: string, - @Body() dto: Partial, + @Body() dto: UpdateLearningRecordDto, @Request() req: AuthenticatedRequest, ) { const { ipAddress, userAgent } = extractRequestInfo(req); @@ -330,12 +333,12 @@ export class ArchiveController { @Param('id') id: string, @Res() res: Response, ) { - const { fullPath, fileName, mimeType } = await this.archiveService.getAttachmentFile(+studentId, +id); - res.setHeader('Content-Type', mimeType); - res.setHeader( - 'Content-Disposition', - `inline; filename="${encodeURIComponent(fileName)}"`, + const { fullPath, fileName, mimeType } = await this.archiveService.getAttachmentFile( + +studentId, + +id, ); + res.setHeader('Content-Type', mimeType); + res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(fileName)}"`); const stream = fs.createReadStream(fullPath); stream.pipe(res); } @@ -358,7 +361,6 @@ export class ArchiveController { return result; } - @Get(':studentId/report-html') @RequirePermission('student:view') async generateReportHtml( diff --git a/apps/server/src/archive/archive.service.ts b/apps/server/src/archive/archive.service.ts index 69c788a..d5c17e5 100644 --- a/apps/server/src/archive/archive.service.ts +++ b/apps/server/src/archive/archive.service.ts @@ -15,8 +15,11 @@ import { ArchiveAttachment } from '../entities/archive-attachment.entity'; import { UpsertProfileDto, CreateEnrollmentDto, + UpdateEnrollmentDto, CreateExamScoreDto, + UpdateExamScoreDto, CreateLearningRecordDto, + UpdateLearningRecordDto, UpsertResultDto, } from './dto/archive.dto'; @@ -44,7 +47,9 @@ export class ArchiveService { ? path.resolve(process.cwd(), normalizedPath) : path.resolve(this.uploadDir, normalizedPath); const allowedRoots = [this.uploadDir, path.resolve(process.cwd(), 'uploads', 'archive')]; - if (!allowedRoots.some((root) => fullPath === root || fullPath.startsWith(`${root}${path.sep}`))) { + if ( + !allowedRoots.some((root) => fullPath === root || fullPath.startsWith(`${root}${path.sep}`)) + ) { throw new BadRequestException('路径非法'); } return fullPath; @@ -54,21 +59,15 @@ export class ArchiveService { const student = await this.studentRepo.findOne({ where: { id: studentId } }); if (!student) throw new NotFoundException('学生不存在'); - const [ - profileRaw, - enrollments, - examScores, - learningRecords, - resultArchive, - attachments, - ] = await Promise.all([ - this.profileRepo.findOne({ where: { studentId } }), - this.enrollmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }), - this.examScoreRepo.find({ where: { studentId }, order: { examDate: 'DESC' } }), - this.learningRecordRepo.find({ where: { studentId }, order: { recordDate: 'DESC' } }), - this.resultRepo.findOne({ where: { studentId } }), - this.attachmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }), - ]); + const [profileRaw, enrollments, examScores, learningRecords, resultArchive, attachments] = + await Promise.all([ + this.profileRepo.findOne({ where: { studentId } }), + this.enrollmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }), + this.examScoreRepo.find({ where: { studentId }, order: { examDate: 'DESC' } }), + this.learningRecordRepo.find({ where: { studentId }, order: { recordDate: 'DESC' } }), + this.resultRepo.findOne({ where: { studentId } }), + this.attachmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }), + ]); return { student, @@ -102,7 +101,7 @@ export class ArchiveService { return this.enrollmentRepo.save(entity); } - async updateEnrollment(id: number, dto: Partial) { + async updateEnrollment(id: number, dto: UpdateEnrollmentDto) { const entity = await this.enrollmentRepo.findOne({ where: { id } }); if (!entity) throw new NotFoundException('报名记录不存在'); Object.assign(entity, dto); @@ -124,7 +123,7 @@ export class ArchiveService { return this.examScoreRepo.save(entity); } - async updateExamScore(id: number, dto: Partial) { + async updateExamScore(id: number, dto: UpdateExamScoreDto) { const entity = await this.examScoreRepo.findOne({ where: { id } }); if (!entity) throw new NotFoundException('考试成绩不存在'); Object.assign(entity, dto); @@ -146,7 +145,7 @@ export class ArchiveService { return this.learningRecordRepo.save(entity); } - async updateLearningRecord(id: number, dto: Partial) { + async updateLearningRecord(id: number, dto: UpdateLearningRecordDto) { const entity = await this.learningRecordRepo.findOne({ where: { id } }); if (!entity) throw new NotFoundException('学习记录不存在'); Object.assign(entity, dto); @@ -225,4 +224,3 @@ export class ArchiveService { return { message: '已删除' }; } } - diff --git a/apps/server/src/archive/dto/archive.dto.spec.ts b/apps/server/src/archive/dto/archive.dto.spec.ts index 4094440..6a47aab 100644 --- a/apps/server/src/archive/dto/archive.dto.spec.ts +++ b/apps/server/src/archive/dto/archive.dto.spec.ts @@ -1,7 +1,19 @@ import 'reflect-metadata'; +import { ValidationPipe } from '@nestjs/common'; import { plainToInstance } from 'class-transformer'; import { validate } from 'class-validator'; -import { UpsertProfileDto } from './archive.dto'; +import { + UpdateEnrollmentDto, + UpdateExamScoreDto, + UpdateLearningRecordDto, + UpsertProfileDto, +} from './archive.dto'; + +const pipe = new ValidationPipe({ transform: true, whitelist: true }); + +async function transform(metatype: new () => T, value: unknown) { + return pipe.transform(value, { type: 'body', metatype }); +} describe('UpsertProfileDto retired fields', () => { it('removes the retired campusLocation field under whitelist validation', async () => { @@ -16,3 +28,30 @@ describe('UpsertProfileDto retired fields', () => { expect(dto).not.toHaveProperty('campusLocation'); }); }); + +describe('archive update DTOs', () => { + it('allows partial enrollment updates and strips unknown fields', async () => { + await expect( + transform(UpdateEnrollmentDto, { className: '新班级', ignored: 'value' }), + ).resolves.toEqual(expect.objectContaining({ className: '新班级' })); + const result = await transform(UpdateEnrollmentDto, { + className: '新班级', + ignored: 'value', + }); + expect(result).not.toHaveProperty('ignored'); + }); + + it('retains create DTO validation rules for exam scores', async () => { + await expect(transform(UpdateExamScoreDto, { score: '90' })).rejects.toThrow(); + await expect(transform(UpdateExamScoreDto, { score: 90 })).resolves.toMatchObject({ + score: 90, + }); + }); + + it('retains create DTO date validation for learning records', async () => { + await expect( + transform(UpdateLearningRecordDto, { recordDate: 'not-a-date' }), + ).rejects.toThrow(); + await expect(transform(UpdateLearningRecordDto, {})).resolves.toEqual({}); + }); +}); diff --git a/apps/server/src/archive/dto/archive.dto.ts b/apps/server/src/archive/dto/archive.dto.ts index 9d7c6a9..85fef07 100644 --- a/apps/server/src/archive/dto/archive.dto.ts +++ b/apps/server/src/archive/dto/archive.dto.ts @@ -1,3 +1,4 @@ +import { PartialType } from '@nestjs/mapped-types'; import { IsOptional, IsString, IsNumber, IsDateString } from 'class-validator'; export class UpsertProfileDto { @@ -20,6 +21,8 @@ export class CreateEnrollmentDto { @IsOptional() @IsString() status?: string; } +export class UpdateEnrollmentDto extends PartialType(CreateEnrollmentDto) {} + export class CreateExamScoreDto { @IsString() examType: string; @IsOptional() @IsString() examName?: string; @@ -31,6 +34,8 @@ export class CreateExamScoreDto { @IsOptional() @IsNumber() enrollmentId?: number; } +export class UpdateExamScoreDto extends PartialType(CreateExamScoreDto) {} + export class CreateLearningRecordDto { @IsDateString() recordDate: string; @IsString() recordType: string; @@ -39,6 +44,8 @@ export class CreateLearningRecordDto { @IsOptional() @IsString() nextStep?: string; } +export class UpdateLearningRecordDto extends PartialType(CreateLearningRecordDto) {} + export class UpsertResultDto { @IsOptional() @IsNumber() cultureFinalScore?: number; @IsOptional() @IsNumber() professionalFinalScore?: number; diff --git a/apps/server/src/classroom-rentals/classroom-rentals.controller.ts b/apps/server/src/classroom-rentals/classroom-rentals.controller.ts index f1dfc23..4577ec8 100644 --- a/apps/server/src/classroom-rentals/classroom-rentals.controller.ts +++ b/apps/server/src/classroom-rentals/classroom-rentals.controller.ts @@ -137,6 +137,42 @@ export class ClassroomRentalsController { return result; } + @Put(':id/cancel') + @RequirePermission('rental:edit') + async cancel(@Param('id') id: string, @Request() req: any) { + const { ipAddress, userAgent } = extractRequestInfo(req); + const result = await this.service.cancel(+id); + await this.logService.log({ + userId: req.user?.id, + username: req.user?.username, + module: '教室租赁', + action: '取消租赁', + targetId: +id, + targetType: 'classroom-rental', + ipAddress, + userAgent, + }); + return result; + } + + @Put(':id/end') + @RequirePermission('rental:edit') + async end(@Param('id') id: string, @Request() req: any) { + const { ipAddress, userAgent } = extractRequestInfo(req); + const result = await this.service.end(+id); + await this.logService.log({ + userId: req.user?.id, + username: req.user?.username, + module: '教室租赁', + action: '结束租赁', + targetId: +id, + targetType: 'classroom-rental', + ipAddress, + userAgent, + }); + return result; + } + @Delete(':id') @RequirePermission('rental:delete') async remove(@Param('id') id: string, @Request() req: any) { diff --git a/apps/server/src/classroom-rentals/classroom-rentals.service.spec.ts b/apps/server/src/classroom-rentals/classroom-rentals.service.spec.ts index b3e4886..e5058bc 100644 --- a/apps/server/src/classroom-rentals/classroom-rentals.service.spec.ts +++ b/apps/server/src/classroom-rentals/classroom-rentals.service.spec.ts @@ -243,7 +243,7 @@ describe('ClassroomRentalsService — rental schedule sync', () => { startDate: '2026-03-01', endDate: '2026-03-31', }; - const classroom = { id: 1, departmentId: 10 } as Classroom; + const classroom = { id: 1, status: 'available' } as Classroom; const hostOrganization = { id: 1, name: 'Host', @@ -315,8 +315,8 @@ describe('ClassroomRentalsService — rental schedule sync', () => { id: 1, classroomId: 1, lesseeOrganizationId: 2, - startDate: '2026-03-01', - endDate: '2026-03-31', + startDate: '2026-07-01', + endDate: '2099-03-31', status: 'active', notes: '', lesseeOrganization: { id: 2, name: 'Organization A' } as Organization, @@ -324,8 +324,8 @@ describe('ClassroomRentalsService — rental schedule sync', () => { } as ClassroomRental; const updatedRental = { ...existingRental, - startDate: '2026-04-01', - endDate: '2026-04-30', + startDate: '2026-08-01', + endDate: '2099-04-30', }; const existingSchedule = { id: 50, @@ -339,12 +339,12 @@ describe('ClassroomRentalsService — rental schedule sync', () => { scheduleRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder([])); scheduleRepo.findOne.mockResolvedValue(existingSchedule); - const dto: UpdateRentalDto = { startDate: '2026-04-01', endDate: '2026-04-30' }; + const dto: UpdateRentalDto = { startDate: '2026-08-01', endDate: '2099-04-30' }; await service.update(1, dto); expect(rentalRepo.update).toHaveBeenCalledWith( 1, - expect.objectContaining({ startDate: '2026-04-01', endDate: '2026-04-30' }), + expect.objectContaining({ startDate: '2026-08-01', endDate: '2099-04-30' }), ); expect(scheduleRepo.update).toHaveBeenCalledWith( 50, @@ -352,8 +352,8 @@ describe('ClassroomRentalsService — rental schedule sync', () => { scheduleType: 'RENTAL', rentalId: 1, classroomId: 1, - startDate: '2026-04-01', - endDate: '2026-04-30', + startDate: '2026-08-01', + endDate: '2099-04-30', status: 'active', subject: 'Organization A 租赁', }), @@ -362,27 +362,59 @@ describe('ClassroomRentalsService — rental schedule sync', () => { expect(scheduleRepo.delete).not.toHaveBeenCalled(); }); - it('deletes the RENTAL schedule row when status changes to cancelled', async () => { + it('deletes the RENTAL schedule row when the rental is cancelled', async () => { const rental = { id: 1, classroomId: 1, lesseeOrganizationId: 2, - startDate: '2026-03-01', - endDate: '2026-03-31', + startDate: '2026-07-01', + endDate: '2099-03-31', status: 'active', lesseeOrganization: { id: 2, name: 'Organization A' } as Organization, } as ClassroomRental; - const cancelledRental = { ...rental, status: 'cancelled' }; + const cancelledRental = { ...rental, status: 'cancelled' } as ClassroomRental; rentalRepo.findOne.mockResolvedValueOnce(rental).mockResolvedValueOnce(cancelledRental); - await service.update(1, { status: 'cancelled' }); + await service.cancel(1); expect(rentalRepo.update).toHaveBeenCalledWith(1, { status: 'cancelled' }); expect(scheduleRepo.delete).toHaveBeenCalledWith({ rentalId: 1, scheduleType: 'RENTAL' }); - expect(scheduleRepo.findOne).not.toHaveBeenCalled(); - expect(scheduleRepo.update).not.toHaveBeenCalled(); - expect(scheduleRepo.create).not.toHaveBeenCalled(); + }); + }); + + describe('lifecycle actions', () => { + it('ends an active rental and shortens a future end date', async () => { + const rental = { + id: 1, + classroomId: 1, + startDate: '2026-07-01', + endDate: '2099-12-31', + status: 'active', + lesseeOrganization: { name: 'Organization A' }, + } as ClassroomRental; + const ended = { ...rental, status: 'ended', endDate: '2026-07-13' } as ClassroomRental; + rentalRepo.findOne.mockResolvedValueOnce(rental).mockResolvedValueOnce(ended); + scheduleRepo.findOne.mockResolvedValue({ id: 50 } as ClassSchedule); + + await service.end(1); + + expect(rentalRepo.update).toHaveBeenCalledWith( + 1, + expect.objectContaining({ status: 'ended' }), + ); + expect(scheduleRepo.update).toHaveBeenCalled(); + }); + + it('rejects ending a future rental', async () => { + rentalRepo.findOne.mockResolvedValue({ + id: 1, + startDate: '2099-01-01', + endDate: '2099-12-31', + status: 'active', + } as ClassroomRental); + + await expect(service.end(1)).rejects.toThrow('租赁尚未开始'); }); }); @@ -394,7 +426,7 @@ describe('ClassroomRentalsService — rental schedule sync', () => { lesseeOrganizationId: 2, startDate: '2026-03-01', endDate: '2026-03-31', - status: 'active', + status: 'cancelled', lesseeOrganization: { id: 2, name: 'Organization A' } as Organization, } as ClassroomRental; @@ -419,7 +451,7 @@ describe('ClassroomRentalsService — organization roles', () => { createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder([])), } as any; const classroomRepo = { - findOne: jest.fn().mockResolvedValue({ id: 1, departmentId: 10 }), + findOne: jest.fn().mockResolvedValue({ id: 1, status: 'available' }), } as any; const organizationRepo = { findOne: jest diff --git a/apps/server/src/classroom-rentals/classroom-rentals.service.ts b/apps/server/src/classroom-rentals/classroom-rentals.service.ts index 54be700..70024cf 100644 --- a/apps/server/src/classroom-rentals/classroom-rentals.service.ts +++ b/apps/server/src/classroom-rentals/classroom-rentals.service.ts @@ -6,8 +6,8 @@ import { } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, Not, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; -import { ClassroomRental } from '../entities/classroom-rental.entity'; -import { Classroom } from '../entities/classroom.entity'; +import { ClassroomRental, ClassroomRentalStatus } from '../entities/classroom-rental.entity'; +import { Classroom, ClassroomStatus } from '../entities/classroom.entity'; import { Organization } from '../entities/organization.entity'; import { ClassSchedule } from '../entities/class-schedule.entity'; import { CreateRentalDto, UpdateRentalDto } from './dto/rental.dto'; @@ -70,8 +70,11 @@ export class ClassroomRentalsService { const last = `${y}-${String(m).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`; qb.andWhere('r.startDate <= :last AND r.endDate >= :first', { first, last }); } - if (!query?.includeEnded) qb.andWhere('r.status != :cancelled', { cancelled: 'cancelled' }); - return qb.getMany(); + if (!query?.includeEnded) { + qb.andWhere('r.status = :active', { active: ClassroomRentalStatus.ACTIVE }); + } + const rentals = await qb.getMany(); + return rentals.map((rental) => this.withEffectiveStatus(rental)); } async findOne(id: number) { @@ -80,7 +83,7 @@ export class ClassroomRentalsService { relations: ['classroom', 'lessorOrganization', 'lesseeOrganization'], }); if (!rental) throw new NotFoundException('租赁订单不存在'); - return rental; + return this.withEffectiveStatus(rental); } async getUnavailableDates(classroomId: number, year: number, month: number, excludeId?: number) { @@ -93,7 +96,7 @@ export class ClassroomRentalsService { where: { ...(excludeId ? { id: Not(excludeId) } : {}), classroomId, - status: Not('cancelled'), + status: ClassroomRentalStatus.ACTIVE, startDate: LessThanOrEqual(monthEnd), endDate: MoreThanOrEqual(monthStart), }, @@ -101,7 +104,7 @@ export class ClassroomRentalsService { this.scheduleRepo.find({ where: { classroomId, - status: 'active', + status: ClassroomRentalStatus.ACTIVE, scheduleType: 'INTERNAL', startDate: LessThanOrEqual(monthEnd), endDate: MoreThanOrEqual(monthStart), @@ -133,7 +136,7 @@ export class ClassroomRentalsService { .createQueryBuilder('r') .leftJoinAndSelect('r.lesseeOrganization', 'lesseeOrganization') .where('r.classroomId = :cid', { cid: classroomId }) - .andWhere('r.status != :cancelled', { cancelled: 'cancelled' }) + .andWhere('r.status = :active', { active: ClassroomRentalStatus.ACTIVE }) .andWhere('r.startDate <= :end', { end: endDate }) .andWhere('r.endDate >= :start', { start: startDate }); if (excludeId) qb.andWhere('r.id != :excludeId', { excludeId }); @@ -222,6 +225,9 @@ export class ClassroomRentalsService { if (dto.startDate > dto.endDate) throw new BadRequestException('起始日期不能晚于结束日期'); const classroom = await this.classroomRepo.findOne({ where: { id: dto.classroomId } }); if (!classroom) throw new NotFoundException('教室不存在'); + if (classroom.status !== ClassroomStatus.AVAILABLE) { + throw new BadRequestException('仅可用教室可以创建租赁'); + } const lessorOrganization = dto.lessorOrganizationId ? await this.organizationRepo.findOne({ where: { id: dto.lessorOrganizationId, status: 'active' }, @@ -253,7 +259,7 @@ export class ClassroomRentalsService { lessorOrganizationId: lessorOrganization.id, lesseeOrganizationId: lesseeOrganization.id, createdBy: userId, - status: 'active', + status: ClassroomRentalStatus.ACTIVE, }); const saved = await this.repo.save(rental); await this.syncScheduleFromRental(saved, lesseeOrganization.name); @@ -262,11 +268,21 @@ export class ClassroomRentalsService { async update(id: number, dto: UpdateRentalDto) { const rental = await this.findOne(id); + if (rental.effectiveStatus !== ClassroomRentalStatus.ACTIVE) { + throw new BadRequestException('已结束或已取消的租赁不能编辑'); + } // 若修改了教室/日期,重新冲突检查 const newClassroomId = dto.classroomId ?? rental.classroomId; const newStart = dto.startDate ?? rental.startDate; const newEnd = dto.endDate ?? rental.endDate; if (newStart > newEnd) throw new BadRequestException('起始日期不能晚于结束日期'); + if (dto.classroomId && dto.classroomId !== rental.classroomId) { + const classroom = await this.classroomRepo.findOne({ where: { id: dto.classroomId } }); + if (!classroom) throw new NotFoundException('教室不存在'); + if (classroom.status !== ClassroomStatus.AVAILABLE) { + throw new BadRequestException('仅可用教室可以承接租赁'); + } + } if (dto.classroomId || dto.startDate || dto.endDate) { const conflicts = await this.findConflicts(newClassroomId, newStart, newEnd, id); if (conflicts.length > 0) { @@ -300,16 +316,46 @@ export class ClassroomRentalsService { } await this.repo.update(id, dto); const updated = await this.findOne(id); - if (dto.status === 'cancelled') { - await this.scheduleRepo.delete({ rentalId: id, scheduleType: 'RENTAL' }); - } else { - await this.syncScheduleFromRental(updated); - } + await this.syncScheduleFromRental(updated); return updated; } + async cancel(id: number) { + const rental = await this.findOne(id); + if (rental.effectiveStatus !== ClassroomRentalStatus.ACTIVE) { + throw new BadRequestException('仅有效租赁可以取消'); + } + await this.repo.update(id, { status: ClassroomRentalStatus.CANCELLED }); + await this.scheduleRepo.delete({ rentalId: id, scheduleType: 'RENTAL' }); + return this.findOne(id); + } + + async end(id: number) { + const rental = await this.findOne(id); + if (rental.effectiveStatus !== ClassroomRentalStatus.ACTIVE) { + throw new BadRequestException('仅有效租赁可以结束'); + } + const today = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(new Date()); + if (rental.startDate > today) throw new BadRequestException('租赁尚未开始,不能结束'); + await this.repo.update(id, { + status: ClassroomRentalStatus.ENDED, + endDate: rental.endDate > today ? today : rental.endDate, + }); + const ended = await this.findOne(id); + await this.syncScheduleFromRental(ended); + return ended; + } + async remove(id: number) { const rental = await this.findOne(id); + if (rental.effectiveStatus === ClassroomRentalStatus.ACTIVE) { + throw new BadRequestException('进行中的租赁请先取消或结束'); + } // 同步删除对应排课记录 await this.scheduleRepo.delete({ rentalId: id, scheduleType: 'RENTAL' }); // 同时删除合同文件 @@ -327,6 +373,20 @@ export class ClassroomRentalsService { return { message: '删除成功' }; } + private withEffectiveStatus(rental: ClassroomRental) { + const today = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(new Date()); + const effectiveStatus = + rental.status === ClassroomRentalStatus.ACTIVE && rental.endDate < today + ? ClassroomRentalStatus.ENDED + : rental.status; + return Object.assign(rental, { effectiveStatus }); + } + /** * 同步租赁订单到 class_schedules(schedule_type = 'RENTAL') */ @@ -348,7 +408,7 @@ export class ClassroomRentalsService { teacherId: null, scheduleType: 'RENTAL', rentalId: rental.id, - status: 'active', + status: rental.status === ClassroomRentalStatus.CANCELLED ? 'cancelled' : 'active', notes: rental.notes, }; if (schedule) { @@ -437,14 +497,16 @@ export class ClassroomRentalsService { const last = `${year}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`; const classrooms = await this.classroomRepo.find({ - where: { status: Not('archived') }, + where: { status: Not(ClassroomStatus.ARCHIVED) }, order: { building: 'ASC', name: 'ASC' }, }); const rentals = await this.repo .createQueryBuilder('r') .leftJoinAndSelect('r.lesseeOrganization', 'lesseeOrganization') .leftJoinAndSelect('r.classroom', 'classroom') - .where('r.status != :cancelled', { cancelled: 'cancelled' }) + .where('r.status IN (:...statuses)', { + statuses: [ClassroomRentalStatus.ACTIVE, ClassroomRentalStatus.ENDED], + }) .andWhere('r.startDate <= :last AND r.endDate >= :first', { first, last }) .getMany(); diff --git a/apps/server/src/classroom-rentals/dto/rental.dto.ts b/apps/server/src/classroom-rentals/dto/rental.dto.ts index 92b34f4..573c4fd 100644 --- a/apps/server/src/classroom-rentals/dto/rental.dto.ts +++ b/apps/server/src/classroom-rentals/dto/rental.dto.ts @@ -1,4 +1,4 @@ -import { IsOptional, IsString, IsInt, IsNumber, IsEnum, IsDateString } from 'class-validator'; +import { IsOptional, IsString, IsInt, IsNumber, IsDateString } from 'class-validator'; export class CreateRentalDto { @IsInt() @@ -62,8 +62,4 @@ export class UpdateRentalDto { @IsOptional() @IsString() notes?: string; - - @IsOptional() - @IsEnum(['active', 'ended', 'cancelled']) - status?: string; } diff --git a/apps/server/src/classrooms/classrooms.service.ts b/apps/server/src/classrooms/classrooms.service.ts index c379914..61ca5f8 100644 --- a/apps/server/src/classrooms/classrooms.service.ts +++ b/apps/server/src/classrooms/classrooms.service.ts @@ -1,14 +1,13 @@ import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; -import { Repository, Not } from 'typeorm'; -import { Classroom } from '../entities/classroom.entity'; -import { ClassroomRental } from '../entities/classroom-rental.entity'; +import { Repository, Not, MoreThanOrEqual } from 'typeorm'; +import { Classroom, ClassroomStatus } from '../entities/classroom.entity'; +import { ClassroomRental, ClassroomRentalStatus } from '../entities/classroom-rental.entity'; import { ClassSchedule } from '../entities/class-schedule.entity'; import { CreateClassroomDto, UpdateClassroomDto } from './dto/classroom.dto'; @Injectable() export class ClassroomsService { - constructor( @InjectRepository(Classroom) private repo: Repository, @InjectRepository(ClassroomRental) private rentalRepo: Repository, @@ -21,49 +20,148 @@ export class ClassroomsService { if (query?.roomType) where.roomType = query.roomType; if (!query?.includeArchived) where.status = Not('archived'); const list = await this.repo.find({ where, order: { building: 'ASC', name: 'ASC' } }); - const usageMap = await this.getCurrentUsageForClassrooms(list.map((c) => c.id)); - return list.map((c) => ({ ...c, currentUsage: usageMap.get(c.id) ?? null })); + const usageMap = await this.getUsageForClassrooms(list.map((c) => c.id)); + return list.map((classroom) => this.withEffectiveStatus(classroom, usageMap.get(classroom.id))); } async findOne(id: number) { const cls = await this.repo.findOne({ where: { id } }); if (!cls) throw new NotFoundException('教室不存在'); - const usageMap = await this.getCurrentUsageForClassrooms([id]); - return { ...cls, currentUsage: usageMap.get(id) ?? null }; + const usageMap = await this.getUsageForClassrooms([id]); + return this.withEffectiveStatus(cls, usageMap.get(id)); } async create(dto: CreateClassroomDto) { const exists = await this.repo.findOne({ where: { name: dto.name } }); if (exists) throw new BadRequestException(`教室 ${dto.name} 已存在`); - return this.repo.save(this.repo.create(dto)); + return this.repo.save(this.repo.create({ ...dto, status: ClassroomStatus.AVAILABLE })); } async update(id: number, dto: UpdateClassroomDto) { - await this.findOne(id); + const classroom = await this.repo.findOne({ where: { id } }); + if (!classroom) throw new NotFoundException('教室不存在'); + if (dto.status === ClassroomStatus.MAINTENANCE && classroom.status !== dto.status) { + await this.assertNoActiveAllocations(id); + } await this.repo.update(id, dto); return this.repo.findOne({ where: { id } }); } async remove(id: number) { - await this.findOne(id); - await this.repo.update(id, { status: 'archived' }); + const classroom = await this.repo.findOne({ where: { id } }); + if (!classroom) throw new NotFoundException('教室不存在'); + await this.assertNoActiveAllocations(id); + await this.repo.update(id, { status: ClassroomStatus.ARCHIVED }); return { message: '已归档' }; } async restore(id: number) { - await this.findOne(id); - await this.repo.update(id, { status: 'reserved' }); + const classroom = await this.repo.findOne({ where: { id } }); + if (!classroom) throw new NotFoundException('教室不存在'); + await this.repo.update(id, { status: ClassroomStatus.AVAILABLE }); return this.repo.findOne({ where: { id } }); } - private async getCurrentUsageForClassrooms(classroomIds: number[]): Promise> { - const result = new Map(); + private withEffectiveStatus( + classroom: Classroom, + usage?: { + state: 'in_use' | 'reserved'; + currentUsage: { + type: 'schedule' | 'rental'; + title: string; + startTime: string; + endTime: string; + } | null; + }, + ) { + const effectiveStatus = + classroom.status === ClassroomStatus.ARCHIVED || + classroom.status === ClassroomStatus.MAINTENANCE + ? classroom.status + : (usage?.state ?? ClassroomStatus.AVAILABLE); + return { ...classroom, currentUsage: usage?.currentUsage ?? null, effectiveStatus }; + } + + private async assertNoActiveAllocations(classroomId: number) { + const today = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(new Date()); + const scheduleCount = await this.scheduleRepo + .createQueryBuilder('schedule') + .where('schedule.classroomId = :classroomId', { classroomId }) + .andWhere('schedule.status = :active', { active: 'active' }) + .andWhere('schedule.endDate >= :today', { today }) + .getCount(); + const rentalCount = await this.rentalRepo.count({ + where: { + classroomId, + status: ClassroomRentalStatus.ACTIVE, + endDate: MoreThanOrEqual(today), + }, + }); + if (rentalCount > 0 || scheduleCount > 0) { + throw new BadRequestException('该教室存在有效排课或租赁,无法维护或归档'); + } + } + + private async getUsageForClassrooms(classroomIds: number[]): Promise< + Map< + number, + { + state: 'in_use' | 'reserved'; + currentUsage: { + type: 'schedule' | 'rental'; + title: string; + startTime: string; + endTime: string; + } | null; + } + > + > { + const result = new Map< + number, + { + state: 'in_use' | 'reserved'; + currentUsage: { + type: 'schedule' | 'rental'; + title: string; + startTime: string; + endTime: string; + } | null; + } + >(); if (classroomIds.length === 0) return result; const now = new Date(); - const todayStr = now.toISOString().slice(0, 10); - const currentTime = now.toTimeString().slice(0, 5); - const weekDay = now.getDay() || 7; + const todayStr = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(now); + const currentTime = new Intl.DateTimeFormat('en-GB', { + timeZone: 'Asia/Shanghai', + hour: '2-digit', + minute: '2-digit', + hour12: false, + }).format(now); + const shanghaiParts = new Intl.DateTimeFormat('en-US', { + timeZone: 'Asia/Shanghai', + weekday: 'short', + }).format(now); + const weekDayMap: Record = { + Mon: 1, + Tue: 2, + Wed: 3, + Thu: 4, + Fri: 5, + Sat: 6, + Sun: 7, + }; + const weekDay = weekDayMap[shanghaiParts]; const schedules = await this.scheduleRepo .createQueryBuilder('s') @@ -71,26 +169,37 @@ export class ClassroomsService { .select('s.classroomId', 'classroomId') .addSelect('s.startTime', 'startTime') .addSelect('s.endTime', 'endTime') + .addSelect('s.startDate', 'startDate') + .addSelect('s.endDate', 'endDate') + .addSelect('s.weekDay', 'weekDay') .addSelect('s.subject', 'subject') .addSelect('c.name', 'className') .where('s.classroomId IN (:...ids)', { ids: classroomIds }) .andWhere('s.status = :active', { active: 'active' }) .andWhere('s.scheduleType = :type', { type: 'INTERNAL' }) - .andWhere('s.startDate <= :today', { today: todayStr }) .andWhere('s.endDate >= :today', { today: todayStr }) - .andWhere('s.weekDay = :weekDay', { weekDay }) - .andWhere('s.startTime <= :currentTime', { currentTime }) - .andWhere('s.endTime >= :currentTime', { currentTime }) .getRawMany(); - for (const s of schedules) { - const classroomId = Number(s.classroomId); - if (!result.has(classroomId)) { + for (const schedule of schedules) { + const classroomId = Number(schedule.classroomId); + const isCurrent = + String(schedule.startDate) <= todayStr && + String(schedule.endDate) >= todayStr && + Number(schedule.weekDay) === weekDay && + String(schedule.startTime) <= currentTime && + String(schedule.endTime) >= currentTime; + const existing = result.get(classroomId); + if (!existing || isCurrent) { result.set(classroomId, { - type: 'schedule', - title: `${s.className || ''} ${s.subject || ''}`.trim() || '内部课程', - startTime: String(s.startTime), - endTime: String(s.endTime), + state: isCurrent ? 'in_use' : 'reserved', + currentUsage: isCurrent + ? { + type: 'schedule', + title: `${schedule.className || ''} ${schedule.subject || ''}`.trim() || '内部课程', + startTime: String(schedule.startTime), + endTime: String(schedule.endTime), + } + : null, }); } } @@ -103,19 +212,25 @@ export class ClassroomsService { .addSelect('r.endDate', 'endDate') .addSelect('t.name', 'tenantName') .where('r.classroomId IN (:...ids)', { ids: classroomIds }) - .andWhere('r.status != :cancelled', { cancelled: 'cancelled' }) - .andWhere('r.startDate <= :today', { today: todayStr }) + .andWhere('r.status = :active', { active: ClassroomRentalStatus.ACTIVE }) .andWhere('r.endDate >= :today', { today: todayStr }) .getRawMany(); - for (const r of rentals) { - const classroomId = Number(r.classroomId); - if (!result.has(classroomId)) { + for (const rental of rentals) { + const classroomId = Number(rental.classroomId); + const isCurrent = String(rental.startDate) <= todayStr && String(rental.endDate) >= todayStr; + const existing = result.get(classroomId); + if (!existing || isCurrent) { result.set(classroomId, { - type: 'rental', - title: r.tenantName ? `${r.tenantName} 租赁` : '外部租赁', - startTime: '00:00', - endTime: '23:59', + state: isCurrent ? 'in_use' : 'reserved', + currentUsage: isCurrent + ? { + type: 'rental', + title: rental.tenantName ? `${rental.tenantName} 租赁` : '外部租赁', + startTime: '00:00', + endTime: '23:59', + } + : null, }); } } @@ -136,24 +251,38 @@ export class ClassroomsService { let skipped = 0; const errors: string[] = []; for (const row of rows) { - if (!row.name?.trim()) { skipped++; continue; } + if (!row.name?.trim()) { + skipped++; + continue; + } const exists = await this.repo.findOne({ where: { name: row.name.trim() } }); - if (exists) { errors.push(`教室 ${row.name} 已存在`); skipped++; continue; } + if (exists) { + errors.push(`教室 ${row.name} 已存在`); + skipped++; + continue; + } await this.repo.save(this.repo.create({ ...row, capacity: row.capacity || 30 })); imported++; } - return { message: `成功导入 ${imported} 间教室,跳过 ${skipped} 间`, imported, skipped, errors: errors.length > 0 ? errors : undefined }; + return { + message: `成功导入 ${imported} 间教室,跳过 ${skipped} 间`, + imported, + skipped, + errors: errors.length > 0 ? errors : undefined, + }; } async getUsageReport(dateFrom: string, dateTo: string) { const classrooms = await this.repo.find({ - where: { status: Not('archived') }, + where: { status: Not(ClassroomStatus.ARCHIVED) }, order: { building: 'ASC', name: 'ASC' }, }); const rentals = await this.rentalRepo .createQueryBuilder('r') - .where('r.status != :cancelled', { cancelled: 'cancelled' }) + .where('r.status IN (:...statuses)', { + statuses: [ClassroomRentalStatus.ACTIVE, ClassroomRentalStatus.ENDED], + }) .andWhere('r.startDate <= :dateTo AND r.endDate >= :dateFrom', { dateFrom, dateTo }) .getMany(); diff --git a/apps/server/src/classrooms/classrooms.status.spec.ts b/apps/server/src/classrooms/classrooms.status.spec.ts new file mode 100644 index 0000000..0976c88 --- /dev/null +++ b/apps/server/src/classrooms/classrooms.status.spec.ts @@ -0,0 +1,39 @@ +import { BadRequestException } from '@nestjs/common'; +import { ClassroomStatus } from '../entities/classroom.entity'; +import { ClassroomsService } from './classrooms.service'; + +function createService(options?: { rentals?: number; schedules?: number }) { + const repo = { + findOne: jest.fn().mockResolvedValue({ id: 1, name: 'A101', status: ClassroomStatus.ARCHIVED }), + update: jest.fn(), + }; + const rentalRepo = { count: jest.fn().mockResolvedValue(options?.rentals ?? 0) }; + const scheduleRepo = { + createQueryBuilder: jest.fn().mockReturnValue({ + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + getCount: jest.fn().mockResolvedValue(options?.schedules ?? 0), + }), + }; + return { + service: new ClassroomsService(repo as never, rentalRepo as never, scheduleRepo as never), + repo, + }; +} + +describe('ClassroomsService — persisted classroom status', () => { + it('restores an archived classroom to available', async () => { + const { service, repo } = createService(); + + await service.restore(1); + + expect(repo.update).toHaveBeenCalledWith(1, { status: ClassroomStatus.AVAILABLE }); + }); + + it('rejects archiving a classroom with active allocations', async () => { + const { service, repo } = createService({ rentals: 1 }); + + await expect(service.remove(1)).rejects.toBeInstanceOf(BadRequestException); + expect(repo.update).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/classrooms/dto/classroom.dto.ts b/apps/server/src/classrooms/dto/classroom.dto.ts index 6f65bb3..21f07d1 100644 --- a/apps/server/src/classrooms/dto/classroom.dto.ts +++ b/apps/server/src/classrooms/dto/classroom.dto.ts @@ -1,4 +1,5 @@ import { IsOptional, IsString, IsNotEmpty, IsInt, IsEnum } from 'class-validator'; +import { ClassroomStatus } from '../../entities/classroom.entity'; export class CreateClassroomDto { @IsString() @@ -21,11 +22,9 @@ export class CreateClassroomDto { @IsString() roomType?: string; // 大 / 次大 / 小 - @IsOptional() @IsString() notes?: string; - } export class UpdateClassroomDto { @@ -49,12 +48,11 @@ export class UpdateClassroomDto { @IsString() roomType?: string; - @IsOptional() @IsString() notes?: string; @IsOptional() - @IsEnum(['available', 'archived']) - status?: string; + @IsEnum([ClassroomStatus.AVAILABLE, ClassroomStatus.MAINTENANCE]) + status?: ClassroomStatus; } diff --git a/apps/server/src/dashboard/dashboard.service.ts b/apps/server/src/dashboard/dashboard.service.ts index aa7a0b4..02dfe05 100644 --- a/apps/server/src/dashboard/dashboard.service.ts +++ b/apps/server/src/dashboard/dashboard.service.ts @@ -124,7 +124,7 @@ export class DashboardService { const pendingDeposits = parseFloat(pendingResult?.total || '0'); const activeRentals = await this.rentalRepo.count({ - where: { endDate: MoreThanOrEqual(todayStr) }, + where: { status: 'active' as const, endDate: MoreThanOrEqual(todayStr) }, }); const occByBldQb = this.occRepo @@ -365,7 +365,7 @@ export class DashboardService { async getClassroomOccupancy() { const classrooms = await this.classroomRepo.find({ - where: { status: Not('archived') }, + where: { status: 'available' as const }, order: { building: 'ASC', name: 'ASC' }, }); const today = new Date().toISOString().slice(0, 10); @@ -382,7 +382,7 @@ export class DashboardService { .createQueryBuilder('r') .select('r.classroomId', 'classroomId') .addSelect('COUNT(*)', 'rentalCount') - .where('r.status != :cancelled', { cancelled: 'cancelled' }) + .where('r.status = :active', { active: 'active' }) .andWhere('r.startDate <= :today AND r.endDate >= :today', { today }) .groupBy('r.classroomId'); const rentals = await rentalQb.getRawMany(); @@ -402,7 +402,7 @@ export class DashboardService { async getClassroomUtilizationStats() { const totalClassrooms = await this.classroomRepo.count({ - where: { status: Not('archived') }, + where: { status: 'available' as const }, }); const today = new Date().toISOString().slice(0, 10); @@ -420,7 +420,7 @@ export class DashboardService { const rentalQb = this.rentalRepo .createQueryBuilder('r') .select('COUNT(DISTINCT r.classroomId)', 'cnt') - .where('r.status != :cancelled', { cancelled: 'cancelled' }) + .where('r.status = :active', { active: 'active' }) .andWhere('r.startDate <= :today AND r.endDate >= :today', { today }); const rentalResult = await rentalQb.getRawOne(); @@ -437,7 +437,7 @@ export class DashboardService { const combinedRentalQb = this.rentalRepo .createQueryBuilder('r') .select('r.classroomId') - .where('r.status != :cancelled', { cancelled: 'cancelled' }) + .where('r.status = :active', { active: 'active' }) .andWhere('r.startDate <= :today AND r.endDate >= :today', { today }) .groupBy('r.classroomId'); const rentalIds = await combinedRentalQb.getRawMany(); diff --git a/apps/server/src/database/database-migrations.classroom-status.spec.ts b/apps/server/src/database/database-migrations.classroom-status.spec.ts new file mode 100644 index 0000000..aaf079e --- /dev/null +++ b/apps/server/src/database/database-migrations.classroom-status.spec.ts @@ -0,0 +1,36 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getDataSourceToken } from '@nestjs/typeorm'; +import { DatabaseMigrationsService } from './database-migrations.service'; + +describe('DatabaseMigrationsService — classroom status normalization', () => { + it('normalizes legacy persisted statuses to available', async () => { + const runner = { + connect: jest.fn(), + release: jest.fn(), + getTables: jest.fn().mockResolvedValue([{ name: 'classrooms' }]), + query: 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 & { + normalizeClassroomStatuses(): Promise; + }; + + await service.normalizeClassroomStatuses(); + + expect(runner.query).toHaveBeenCalledWith( + expect.stringContaining("status NOT IN ('available', 'maintenance', 'archived')"), + ); + expect(runner.release).toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/database/database-migrations.service.ts b/apps/server/src/database/database-migrations.service.ts index d9c8587..b348687 100644 --- a/apps/server/src/database/database-migrations.service.ts +++ b/apps/server/src/database/database-migrations.service.ts @@ -17,6 +17,7 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap { await this.removeUnusedClassroomColumns(); await this.cleanupDepositRefundColumns(); await this.removeUnusedClassStudentColumns(); + await this.normalizeClassroomStatuses(); } private async removeUnusedClassroomColumns(): Promise { @@ -92,6 +93,22 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap { } } + private async normalizeClassroomStatuses(): Promise { + const runner = this.dataSource.createQueryRunner(); + await runner.connect(); + try { + const tables = await runner.getTables(['classrooms']); + if (tables.length === 0) return; + await runner.query(` + UPDATE classrooms + SET status = 'available' + WHERE status IS NULL OR status NOT IN ('available', 'maintenance', 'archived') + `); + } finally { + await runner.release(); + } + } + private async ensureAiConfigTable(): Promise { const runner = this.dataSource.createQueryRunner(); await runner.connect(); @@ -410,14 +427,17 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap { // Drop any existing FK constraint on schedule_id or class_id const fkColumns = ['schedule_id', 'class_id']; for (const col of fkColumns) { - const fkRows: { CONSTRAINT_NAME: string }[] = await runner.query(` + const fkRows: { CONSTRAINT_NAME: string }[] = await runner.query( + ` SELECT CONSTRAINT_NAME FROM INFORMATION_SCHEMA.KEY_COLUMN_USAGE WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'attendance_sessions' AND COLUMN_NAME = ? AND REFERENCED_TABLE_NAME IS NOT NULL - `, [col]); + `, + [col], + ); for (const row of fkRows) { try { @@ -437,13 +457,16 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap { ]; for (const c of constraints) { // Only skip if RESTRICT constraint is already confirmed via information_schema - const existing: Array<{ DELETE_RULE: string }> = await runner.query(` + const existing: Array<{ DELETE_RULE: string }> = await runner.query( + ` SELECT DELETE_RULE FROM INFORMATION_SCHEMA.REFERENTIAL_CONSTRAINTS WHERE CONSTRAINT_SCHEMA = DATABASE() AND TABLE_NAME = 'attendance_sessions' AND CONSTRAINT_NAME = ? - `, [c.name]); + `, + [c.name], + ); if (existing.length > 0 && existing[0].DELETE_RULE === 'RESTRICT') { this.logger.log(`考勤场次删除保护约束已存在: ${c.name}`); @@ -505,17 +528,13 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap { FROM attendance_sessions `); await runner.query('DROP TABLE attendance_sessions'); - await runner.query( - 'ALTER TABLE attendance_sessions_new RENAME TO attendance_sessions', - ); + await runner.query('ALTER TABLE attendance_sessions_new RENAME TO attendance_sessions'); await runner.query( 'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_schedule_date ON attendance_sessions(schedule_id, lesson_date)', ); // Rebuild attendance_records to add/protect FK on attendance_session_id - const recordsFk = await runner.query( - "PRAGMA foreign_key_list('attendance_records')", - ); + const recordsFk = await runner.query("PRAGMA foreign_key_list('attendance_records')"); const hasSessionFk = recordsFk.some( (r: { from: string }) => r.from === 'attendance_session_id', ); @@ -548,9 +567,7 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap { FROM attendance_records `); await runner.query('DROP TABLE attendance_records'); - await runner.query( - 'ALTER TABLE attendance_records_new RENAME TO attendance_records', - ); + await runner.query('ALTER TABLE attendance_records_new RENAME TO attendance_records'); await runner.query( 'CREATE UNIQUE INDEX IF NOT EXISTS uq_attendance_session_student ON attendance_records(attendance_session_id, student_id)', ); @@ -560,9 +577,7 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap { // If violations exist, the transaction rolls back and old tables are preserved. const checkRows = await runner.query('PRAGMA foreign_key_check'); if (checkRows.length > 0) { - throw new Error( - `外键一致性检查失败: ${checkRows.length} 行违反外键约束`, - ); + throw new Error(`外键一致性检查失败: ${checkRows.length} 行违反外键约束`); } await runner.query('COMMIT'); diff --git a/apps/server/src/entities/classroom-rental.entity.ts b/apps/server/src/entities/classroom-rental.entity.ts index 9b3ff46..fd86c8d 100644 --- a/apps/server/src/entities/classroom-rental.entity.ts +++ b/apps/server/src/entities/classroom-rental.entity.ts @@ -11,6 +11,12 @@ import { import { Classroom } from './classroom.entity'; import { Organization } from './organization.entity'; +export enum ClassroomRentalStatus { + ACTIVE = 'active', + ENDED = 'ended', + CANCELLED = 'cancelled', +} + @Entity('classroom_rentals') @Index(['classroomId', 'startDate', 'endDate']) export class ClassroomRental { @@ -57,8 +63,8 @@ export class ClassroomRental { @Column({ name: 'total_amount', type: 'decimal', precision: 10, scale: 2, nullable: true }) totalAmount: number; - @Column({ type: 'varchar', length: 20, default: 'active' }) - status: string; // active / ended / cancelled + @Column({ type: 'varchar', length: 20, default: ClassroomRentalStatus.ACTIVE }) + status: ClassroomRentalStatus | 'active' | 'ended' | 'cancelled'; @Column({ type: 'text', nullable: true }) notes: string; diff --git a/apps/server/src/entities/classroom.entity.ts b/apps/server/src/entities/classroom.entity.ts index 2bb29bf..3ea0630 100644 --- a/apps/server/src/entities/classroom.entity.ts +++ b/apps/server/src/entities/classroom.entity.ts @@ -1,4 +1,10 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, JoinColumn } from 'typeorm'; +import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn } from 'typeorm'; + +export enum ClassroomStatus { + AVAILABLE = 'available', + MAINTENANCE = 'maintenance', + ARCHIVED = 'archived', +} @Entity('classrooms') export class Classroom { @@ -20,14 +26,12 @@ export class Classroom { @Column({ name: 'room_type', type: 'varchar', length: 20, default: '大' }) roomType: string; // 大 / 次大 / 小 - - @Column({ type: 'varchar', length: 20, default: 'reserved' }) - status: string; + @Column({ type: 'varchar', length: 20, default: ClassroomStatus.AVAILABLE }) + status: ClassroomStatus | 'available' | 'maintenance' | 'archived'; @Column({ type: 'text', nullable: true }) notes: string; @CreateDateColumn({ name: 'created_at' }) createdAt: Date; - } diff --git a/apps/server/src/entities/index.ts b/apps/server/src/entities/index.ts index 361e55e..59dc86d 100644 --- a/apps/server/src/entities/index.ts +++ b/apps/server/src/entities/index.ts @@ -11,9 +11,9 @@ export { User } from './user.entity'; export { OperationLog } from './operation-log.entity'; export { Deposit } from './deposit.entity'; export { DepositInstallment } from './deposit-installment.entity'; -export { Classroom } from './classroom.entity'; +export { Classroom, ClassroomStatus } from './classroom.entity'; export { Organization } from './organization.entity'; -export { ClassroomRental } from './classroom-rental.entity'; +export { ClassroomRental, ClassroomRentalStatus } from './classroom-rental.entity'; export { Permission } from './permission.entity'; export { Role } from './role.entity'; export { Class, ClassType, ClassStatus } from './class.entity'; diff --git a/apps/server/src/entities/room.entity.ts b/apps/server/src/entities/room.entity.ts index f458470..fea5ae8 100644 --- a/apps/server/src/entities/room.entity.ts +++ b/apps/server/src/entities/room.entity.ts @@ -1,4 +1,12 @@ -import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, OneToMany, ManyToOne, JoinColumn } from 'typeorm'; +import { + Entity, + PrimaryGeneratedColumn, + Column, + CreateDateColumn, + OneToMany, + ManyToOne, + JoinColumn, +} from 'typeorm'; import { Occupancy } from './occupancy.entity'; import { RoomExpense } from './room-expense.entity'; @@ -26,7 +34,7 @@ export class Room { roomType: string; @Column({ length: 10, nullable: true }) - gender: string; + gender: '男' | '女' | null; @Column({ name: 'rental_category', length: 10, default: 'short' }) rentalCategory: string; @@ -42,5 +50,4 @@ export class Room { @OneToMany(() => RoomExpense, (e) => e.room) roomExpenses: RoomExpense[]; - } diff --git a/apps/server/src/integration/config/dto/config.dto.spec.ts b/apps/server/src/integration/config/dto/config.dto.spec.ts new file mode 100644 index 0000000..ad2de69 --- /dev/null +++ b/apps/server/src/integration/config/dto/config.dto.spec.ts @@ -0,0 +1,48 @@ +import { ValidationPipe } from '@nestjs/common'; +import { + DingTalkThirdConfigDto, + IntegrationType, + SaveIntegrationConfigDto, + WeComThirdConfigDto, +} from './config.dto'; + +const pipe = new ValidationPipe({ transform: true, whitelist: true }); + +const transform = (value: unknown) => + pipe.transform(value, { type: 'body', metatype: SaveIntegrationConfigDto }); + +describe('integration config request DTO', () => { + it('validates and transforms DingTalk configuration', async () => { + const result = await transform({ + type: 'DINGTALK', + config: { + agentId: 'app-key', + corpId: 'corp-id', + appSecret: '', + appId: 'app-id', + ignored: 'value', + }, + }); + + expect(result.type).toBe(IntegrationType.DINGTALK); + expect(result.config).toBeInstanceOf(DingTalkThirdConfigDto); + expect(result.config).toMatchObject({ agentId: 'app-key', corpId: 'corp-id', appSecret: '' }); + expect(result.config).not.toHaveProperty('ignored'); + }); + + it('uses the WeCom nested DTO and removes DingTalk-only fields', async () => { + const result = await transform({ + type: 'WECOM', + config: { agentId: 'agent', corpId: 'corp', appId: 'not-supported' }, + }); + + expect(result.config).toBeInstanceOf(WeComThirdConfigDto); + expect(result.config).not.toHaveProperty('appId'); + }); + + it('rejects invalid platform types and incomplete nested config', async () => { + await expect(transform({ type: 'UNKNOWN', config: {} })).rejects.toThrow(); + await expect(transform({ type: 'DINGTALK', config: { corpId: 'corp' } })).rejects.toThrow(); + await expect(transform({ type: 'DINGTALK' })).rejects.toThrow(); + }); +}); diff --git a/apps/server/src/integration/config/dto/config.dto.ts b/apps/server/src/integration/config/dto/config.dto.ts index d67e4cf..3f14667 100644 --- a/apps/server/src/integration/config/dto/config.dto.ts +++ b/apps/server/src/integration/config/dto/config.dto.ts @@ -1,27 +1,71 @@ -/** 钉钉配置 */ -export interface DingTalkThirdConfig { - agentId: string; // AppKey - appSecret?: string; // AppSecret;更新已有配置时可留空保留旧值 - corpId: string; // CorpId - appId?: string; // 内部应用ID,用于消息推送(可选) +import { Type } from 'class-transformer'; +import { + IsDefined, + IsEnum, + IsNotEmpty, + IsOptional, + IsString, + ValidateNested, +} from 'class-validator'; + +export enum IntegrationType { + WECOM = 'WECOM', + DINGTALK = 'DINGTALK', } -/** 企微配置 */ -export interface WeComThirdConfig { +export class DingTalkThirdConfigDto { + @IsString() + @IsNotEmpty() agentId: string; + + @IsOptional() + @IsString() appSecret?: string; + + @IsString() + @IsNotEmpty() + corpId: string; + + @IsOptional() + @IsString() + appId?: string; +} + +export class WeComThirdConfigDto { + @IsString() + @IsNotEmpty() + agentId: string; + + @IsOptional() + @IsString() + appSecret?: string; + + @IsString() + @IsNotEmpty() corpId: string; } +export class IntegrationConfigRequestDto { + @IsEnum(IntegrationType) + type: IntegrationType; + + @IsDefined() + @ValidateNested() + @Type((options) => + options?.object?.type === IntegrationType.DINGTALK + ? DingTalkThirdConfigDto + : WeComThirdConfigDto, + ) + config: DingTalkThirdConfigDto | WeComThirdConfigDto; +} + +export class SaveIntegrationConfigDto extends IntegrationConfigRequestDto {} + +export class TestIntegrationConfigDto extends IntegrationConfigRequestDto {} + /** 对外返回的配置(脱敏后,不含 appSecret) */ export interface ThirdConfigBaseDTO { type: string; verify?: boolean; config: T; } - -/** 保存配置的请求体 */ -export interface SaveConfigRequest { - type: 'WECOM' | 'DINGTALK'; - config: DingTalkThirdConfig | WeComThirdConfig; -} diff --git a/apps/server/src/integration/config/integration-config.controller.ts b/apps/server/src/integration/config/integration-config.controller.ts index f916927..ded9582 100644 --- a/apps/server/src/integration/config/integration-config.controller.ts +++ b/apps/server/src/integration/config/integration-config.controller.ts @@ -2,7 +2,7 @@ import { Controller, Get, Post, Body, Param, UseGuards } from '@nestjs/common'; import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard'; import { RequirePermission } from '../../auth/decorators/permission.decorator'; import { IntegrationConfigService } from './integration-config.service'; -import type { SaveConfigRequest } from './dto/config.dto'; +import { SaveIntegrationConfigDto, TestIntegrationConfigDto } from './dto/config.dto'; @Controller('integration/config') @UseGuards(JwtAuthGuard) @@ -31,7 +31,7 @@ export class IntegrationConfigController { /** 保存配置 */ @Post() @RequirePermission('integration:trigger') - async saveConfig(@Body() body: SaveConfigRequest) { + async saveConfig(@Body() body: SaveIntegrationConfigDto) { await this.service.saveConfig(body); return { success: true, message: '配置已保存' }; } @@ -39,7 +39,7 @@ export class IntegrationConfigController { /** 测试连接 */ @Post('test') @RequirePermission('integration:read') - async testConnection(@Body() body: SaveConfigRequest) { + async testConnection(@Body() body: TestIntegrationConfigDto) { const success = await this.service.testConnection(body.type, body.config); return { success, message: success ? '连接成功' : '连接失败,请检查配置信息' }; } diff --git a/apps/server/src/integration/config/integration-config.service.ts b/apps/server/src/integration/config/integration-config.service.ts index 3216230..638d6d5 100644 --- a/apps/server/src/integration/config/integration-config.service.ts +++ b/apps/server/src/integration/config/integration-config.service.ts @@ -1,15 +1,13 @@ import { Injectable, Logger, BadRequestException, NotFoundException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; -import { - IntegrationConfig, - IntegrationConfigDetail, -} from '../entities/integration-config.entity'; +import { IntegrationConfig, IntegrationConfigDetail } from '../entities/integration-config.entity'; import { ThirdConfigBaseDTO, - DingTalkThirdConfig, - WeComThirdConfig, - SaveConfigRequest, + DingTalkThirdConfigDto, + WeComThirdConfigDto, + IntegrationType, + SaveIntegrationConfigDto, } from './dto/config.dto'; @Injectable() @@ -67,7 +65,7 @@ export class IntegrationConfigService { } /** 保存/更新配置 */ - async saveConfig(request: SaveConfigRequest): Promise { + async saveConfig(request: SaveIntegrationConfigDto): Promise { const config = await this.ensureConfig(); const detailType = this.getDetailType(request.type); @@ -121,8 +119,8 @@ export class IntegrationConfigService { /** 测试连接 */ async testConnection( - type: string, - config: DingTalkThirdConfig | WeComThirdConfig, + type: IntegrationType, + config: DingTalkThirdConfigDto | WeComThirdConfigDto, ): Promise { try { const finalConfig = { ...config } as Record; diff --git a/apps/server/src/occupancies/occupancies.service.ts b/apps/server/src/occupancies/occupancies.service.ts index ded817f..9252e98 100644 --- a/apps/server/src/occupancies/occupancies.service.ts +++ b/apps/server/src/occupancies/occupancies.service.ts @@ -108,7 +108,7 @@ export class OccupanciesService { } // 首位入住者确定房间性别 - if (student.gender && !room.gender) { + if ((student.gender === '男' || student.gender === '女') && !room.gender) { await this.roomRepo.update(room.id, { gender: student.gender }); } @@ -140,14 +140,6 @@ export class OccupanciesService { // 更新宿舍状态 await this.roomRepo.update(occ.roomId, { status: 'available' }); - // 如果房间已无在住人员,重置房间性别 - const remaining = await this.repo.count({ - where: { roomId: occ.roomId, checkOutDate: IsNull() }, - }); - if (remaining === 0) { - await this.roomRepo.update(occ.roomId, { gender: null as any }); - } - return occ; } @@ -173,14 +165,6 @@ export class OccupanciesService { await runner.manager.update(Locker, oldOcc.lockerId, { status: 'available' }); } await runner.manager.update(Room, oldOcc.roomId, { status: 'available' }); - // 旧房如果已无在住人员,重置性别 - const oldRemaining = await runner.manager.count(Occupancy, { - where: { roomId: oldOcc.roomId, checkOutDate: IsNull() }, - }); - if (oldRemaining === 0) { - await runner.manager.update(Room, oldOcc.roomId, { gender: null as any }); - } - // 检查新房容量 const newRoom = await runner.manager.findOne(Room, { where: { id: dto.newRoomId } }); if (!newRoom) throw new NotFoundException('目标宿舍不存在'); @@ -240,7 +224,7 @@ export class OccupanciesService { } // 首位入住者确定新房性别 - if (student?.gender && !newRoom.gender) { + if ((student?.gender === '男' || student?.gender === '女') && !newRoom.gender) { await runner.manager.update(Room, newRoom.id, { gender: student.gender }); } @@ -339,13 +323,6 @@ export class OccupanciesService { await runner.manager.save(occ); // 更新房间状态 await runner.manager.update(Room, occ.roomId, { status: 'available' }); - // 如果房间已无在住人员,重置性别 - const remaining = await runner.manager.count(Occupancy, { - where: { roomId: occ.roomId, checkOutDate: IsNull() }, - }); - if (remaining === 0) { - await runner.manager.update(Room, occ.roomId, { gender: null as any }); - } // 释放床位/柜子 if (occ.bedId) await runner.manager.update(Bed, occ.bedId, { status: 'available' }); if (occ.lockerId) @@ -521,7 +498,11 @@ export class OccupanciesService { await this.repo.save(this.repo.create(occData)); // 7. 首位入住者确定房间性别 - if (student.gender && !room.gender) { + if ( + !row.checkOutDate?.trim() && + (student.gender === '男' || student.gender === '女') && + !room.gender + ) { await this.roomRepo.update(room.id, { gender: student.gender }); room.gender = student.gender; } diff --git a/apps/server/src/rooms/dto/room.dto.spec.ts b/apps/server/src/rooms/dto/room.dto.spec.ts new file mode 100644 index 0000000..c0cb72e --- /dev/null +++ b/apps/server/src/rooms/dto/room.dto.spec.ts @@ -0,0 +1,21 @@ +import { ValidationPipe } from '@nestjs/common'; +import { CreateRoomDto, UpdateRoomDto } from './room.dto'; + +const pipe = new ValidationPipe({ transform: true, whitelist: true }); +const transform = (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(); + }); +}); diff --git a/apps/server/src/rooms/dto/room.dto.ts b/apps/server/src/rooms/dto/room.dto.ts index 7f53dea..883b75c 100644 --- a/apps/server/src/rooms/dto/room.dto.ts +++ b/apps/server/src/rooms/dto/room.dto.ts @@ -1,4 +1,4 @@ -import { IsString, IsOptional, IsInt, IsEnum, Min, IsNumber } from 'class-validator'; +import { IsString, IsOptional, IsInt, IsEnum, IsIn, Min, IsNumber } from 'class-validator'; export class CreateRoomDto { @IsString() @@ -20,6 +20,10 @@ export class CreateRoomDto { @IsString() roomType?: string; + @IsOptional() + @IsIn(['男', '女']) + gender?: '男' | '女' | null; + @IsOptional() @IsString() rentalCategory?: string; @@ -27,7 +31,6 @@ export class CreateRoomDto { @IsOptional() @IsNumber() monthlyRate?: number; - } export class UpdateRoomDto { @@ -53,8 +56,8 @@ export class UpdateRoomDto { roomType?: string; @IsOptional() - @IsString() - gender?: string; + @IsIn(['男', '女']) + gender?: '男' | '女' | null; @IsOptional() @IsEnum(['available', 'full', 'maintenance']) diff --git a/apps/server/src/rooms/rooms.controller.ts b/apps/server/src/rooms/rooms.controller.ts index 3576a18..d923ac9 100644 --- a/apps/server/src/rooms/rooms.controller.ts +++ b/apps/server/src/rooms/rooms.controller.ts @@ -67,6 +67,7 @@ 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', @@ -76,6 +77,7 @@ export class RoomsController { roomType: '四人间', rentalCategory: 'long', monthlyRate: 800, + gender: '男', }); ws.addRow({ roomNumber: '2-201', @@ -85,6 +87,7 @@ export class RoomsController { roomType: '单人间', rentalCategory: 'short', monthlyRate: 0, + gender: '女', }); res.setHeader( 'Content-Type', @@ -168,11 +171,7 @@ export class RoomsController { @Put(':roomId/beds/:id') @RequirePermission('room:edit') - updateBed( - @Param('roomId') roomId: string, - @Param('id') id: string, - @Body() dto: UpdateBedDto, - ) { + updateBed(@Param('roomId') roomId: string, @Param('id') id: string, @Body() dto: UpdateBedDto) { return this.service.updateBed(+roomId, +id, dto); } @@ -340,16 +339,26 @@ export class RoomsController { roomType?: string; rentalCategory?: string; monthlyRate?: number; + gender?: '男' | '女'; }[] = []; ws.eachRow((row, idx) => { if (idx === 1) return; - const rentalCategoryRaw = String(row.getCell(6).value || '').trim().toLowerCase(); + const rentalCategoryRaw = String(row.getCell(6).value || '') + .trim() + .toLowerCase(); const rentalCategory = rentalCategoryRaw === 'long' || rentalCategoryRaw === 'short' ? rentalCategoryRaw : 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, @@ -358,6 +367,7 @@ export class RoomsController { roomType: String(row.getCell(5).value || '').trim() || undefined, rentalCategory, monthlyRate, + gender, }); }); const result = await this.service.batchImport(rows); diff --git a/apps/server/src/rooms/rooms.gender.spec.ts b/apps/server/src/rooms/rooms.gender.spec.ts new file mode 100644 index 0000000..6cc5cfe --- /dev/null +++ b/apps/server/src/rooms/rooms.gender.spec.ts @@ -0,0 +1,43 @@ +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(); + }); +}); diff --git a/apps/server/src/rooms/rooms.service.ts b/apps/server/src/rooms/rooms.service.ts index 706afc3..8b23d67 100644 --- a/apps/server/src/rooms/rooms.service.ts +++ b/apps/server/src/rooms/rooms.service.ts @@ -114,7 +114,13 @@ export class RoomsService { } async update(id: number, dto: UpdateRoomDto) { - await this.findOne(id); + 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.repo.update(id, dto); return this.repo.findOne({ where: { id } }); } @@ -202,10 +208,7 @@ export class RoomsService { for (const occ of occupancies) { if (!occMap.has(occ.roomId)) occMap.set(occ.roomId, []); const checkIn = new Date(occ.checkInDate); - const days = Math.max( - 1, - Math.ceil((refTime - checkIn.getTime()) / (1000 * 60 * 60 * 24)), - ); + const days = Math.max(1, Math.ceil((refTime - checkIn.getTime()) / (1000 * 60 * 60 * 24))); occMap.get(occ.roomId)!.push({ studentId: occ.studentId, studentName: occ.student?.name || '未知', @@ -250,8 +253,11 @@ export class RoomsService { const allSameOrg = occ.every((o: any) => o.organization && o.organization === orgs[0]); orgLabel = allSameOrg ? `均为${orgs[0]}人员` : `存在${orgs.join('、')}人员`; } - const organizationColors = [...new Set(occ.map((o: any) => o.organizationColor).filter(Boolean))]; - const organizationColor: string | null = organizationColors.length === 1 ? organizationColors[0] : null; + const organizationColors = [ + ...new Set(occ.map((o: any) => o.organizationColor).filter(Boolean)), + ]; + const organizationColor: string | null = + organizationColors.length === 1 ? organizationColors[0] : null; const organizationIds = [...new Set(occ.map((o: any) => o.organizationId).filter(Boolean))]; return { id: room.id, @@ -274,7 +280,14 @@ export class RoomsService { ...new Map( occupancies .filter((o) => o.responsibleOrganizationId && o.responsibleOrganization) - .map((o) => [o.responsibleOrganizationId, { id: o.responsibleOrganizationId, name: o.responsibleOrganization.name, color: o.responsibleOrganization.color || null }]), + .map((o) => [ + o.responsibleOrganizationId, + { + id: o.responsibleOrganizationId, + name: o.responsibleOrganization.name, + color: o.responsibleOrganization.color || null, + }, + ]), ).values(), ].sort((a, b) => a.name.localeCompare(b.name)), }; @@ -289,6 +302,7 @@ export class RoomsService { roomType?: string; rentalCategory?: string; monthlyRate?: number; + gender?: '男' | '女'; }[], ) { let imported = 0; @@ -314,6 +328,7 @@ export class RoomsService { roomType: row.roomType || parsed.roomType || undefined, rentalCategory: row.rentalCategory || undefined, monthlyRate: row.monthlyRate ?? undefined, + gender: row.gender ?? undefined, }), ); imported++; @@ -336,7 +351,10 @@ export class RoomsService { async getRoomAvailableBeds(roomId: number): Promise { const room = await this.repo.findOne({ where: { id: roomId } }); if (!room) throw new NotFoundException('宿舍不存在'); - return this.bedRepo.find({ where: { roomId, status: 'available' }, order: { bedNumber: 'ASC' } }); + return this.bedRepo.find({ + where: { roomId, status: 'available' }, + order: { bedNumber: 'ASC' }, + }); } async createBed(roomId: number, dto: CreateBedDto): Promise { @@ -377,7 +395,7 @@ export class RoomsService { if (!room) throw new NotFoundException('宿舍不存在'); if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加床位'); const existing = await this.bedRepo.find({ where: { roomId }, order: { bedNumber: 'ASC' } }); - const numbers = existing.map(b => { + const numbers = existing.map((b) => { const match = b.bedNumber.match(/^\d+/); return match ? parseInt(match[0]) : 0; }); @@ -400,14 +418,19 @@ export class RoomsService { async getRoomAvailableLockers(roomId: number): Promise { const room = await this.repo.findOne({ where: { id: roomId } }); if (!room) throw new NotFoundException('宿舍不存在'); - return this.lockerRepo.find({ where: { roomId, status: 'available' }, order: { lockerNumber: 'ASC' } }); + return this.lockerRepo.find({ + where: { roomId, status: 'available' }, + order: { lockerNumber: 'ASC' }, + }); } async createLocker(roomId: number, dto: CreateLockerDto): Promise { const room = await this.repo.findOne({ where: { id: roomId } }); if (!room) throw new NotFoundException('宿舍不存在'); if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加柜子'); - const existing = await this.lockerRepo.findOne({ where: { roomId, lockerNumber: dto.lockerNumber } }); + const existing = await this.lockerRepo.findOne({ + where: { roomId, lockerNumber: dto.lockerNumber }, + }); if (existing) throw new BadRequestException('该柜子编号已存在'); const locker = this.lockerRepo.create({ ...dto, roomId }); return this.lockerRepo.save(locker); @@ -420,7 +443,9 @@ export class RoomsService { throw new BadRequestException('该柜子有人占用,请先释放'); } if (dto.lockerNumber && dto.lockerNumber !== locker.lockerNumber) { - const dup = await this.lockerRepo.findOne({ where: { roomId, lockerNumber: dto.lockerNumber } }); + const dup = await this.lockerRepo.findOne({ + where: { roomId, lockerNumber: dto.lockerNumber }, + }); if (dup) throw new BadRequestException('该柜子编号已存在'); } Object.assign(locker, dto); @@ -438,8 +463,11 @@ export class RoomsService { const room = await this.repo.findOne({ where: { id: roomId } }); if (!room) throw new NotFoundException('宿舍不存在'); if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加柜子'); - const existing = await this.lockerRepo.find({ where: { roomId }, order: { lockerNumber: 'ASC' } }); - const numbers = existing.map(b => { + const existing = await this.lockerRepo.find({ + where: { roomId }, + order: { lockerNumber: 'ASC' }, + }); + const numbers = existing.map((b) => { const match = b.lockerNumber.match(/^\d+/); return match ? parseInt(match[0]) : 0; }); diff --git a/apps/server/src/schedules/schedules.service.ts b/apps/server/src/schedules/schedules.service.ts index 8490cb3..ae0b650 100644 --- a/apps/server/src/schedules/schedules.service.ts +++ b/apps/server/src/schedules/schedules.service.ts @@ -11,6 +11,7 @@ import { ClassSchedule, Class, Classroom, + ClassroomStatus, ClassroomRental, ClassTeacher, AttendanceSession, @@ -83,7 +84,7 @@ export class SchedulesService { }); const classrooms = await this.classroomRepo.find({ - where: { status: Not('archived') }, + where: { status: ClassroomStatus.AVAILABLE }, select: ['id', 'name', 'building', 'floor', 'roomType'], order: { building: 'ASC', name: 'ASC' }, }); @@ -159,7 +160,16 @@ export class SchedulesService { return schedule; } + private async assertClassroomAvailable(classroomId: number) { + const classroom = await this.classroomRepo.findOne({ where: { id: classroomId } }); + if (!classroom) throw new NotFoundException('教室不存在'); + if (classroom.status !== ClassroomStatus.AVAILABLE) { + throw new BadRequestException('仅可用教室可以排课'); + } + } + async create(dto: CreateScheduleDto) { + await this.assertClassroomAvailable(dto.classroomId); await this.normalizeTeacherForSchedule(dto); await this.assertTeacherAssignedToClass(dto.classId, dto.teacherId); await this.checkConflict( @@ -182,6 +192,9 @@ export class SchedulesService { // If classroom, weekDay, or times are changing, check conflicts excluding self const classroomId = dto.classroomId ?? existing.classroomId; + if (dto.classroomId !== undefined && dto.classroomId !== existing.classroomId) { + await this.assertClassroomAvailable(dto.classroomId); + } const weekDay = dto.weekDay ?? existing.weekDay; const startTime = dto.startTime ?? existing.startTime; const endTime = dto.endTime ?? existing.endTime; @@ -253,7 +266,7 @@ export class SchedulesService { const rentalConflicts = await this.rentalRepo .createQueryBuilder('r') .where('r.classroomId = :classroomId', { classroomId }) - .andWhere('r.status != :cancelled', { cancelled: 'cancelled' }) + .andWhere('r.status = :activeRental', { activeRental: 'active' }) .andWhere('r.startDate <= :endDate', { endDate }) .andWhere('r.endDate >= :startDate', { startDate }) .getMany(); diff --git a/package-lock.json b/package-lock.json index fb3aab7..08d36ee 100644 --- a/package-lock.json +++ b/package-lock.json @@ -62,6 +62,7 @@ "@nestjs/core": "^11.0.1", "@nestjs/event-emitter": "^3.1.0", "@nestjs/jwt": "^11.0.2", + "@nestjs/mapped-types": "^2.1.1", "@nestjs/passport": "^11.0.5", "@nestjs/platform-express": "^11.1.19", "@nestjs/schedule": "^6.1.3", @@ -3101,6 +3102,26 @@ "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0" } }, + "node_modules/@nestjs/mapped-types": { + "version": "2.1.1", + "resolved": "https://registry.npmmirror.com/@nestjs/mapped-types/-/mapped-types-2.1.1.tgz", + "integrity": "sha512-SCCoMEJ6jdeI5h/N+KCVF1+pmg/hmEkNA5nHTS8Gvww7T/LCl4o1gFLinw2iQ60w7slFkszHcGLKGdazVI4F8A==", + "license": "MIT", + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "class-transformer": "^0.4.0 || ^0.5.0", + "class-validator": "^0.13.0 || ^0.14.0 || ^0.15.0", + "reflect-metadata": "^0.1.12 || ^0.2.0" + }, + "peerDependenciesMeta": { + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, "node_modules/@nestjs/passport": { "version": "11.0.5", "resolved": "https://registry.npmmirror.com/@nestjs/passport/-/passport-11.0.5.tgz", -- 2.49.1 From f98c0ccaa8c0b9922132ad48613eec6fa6aa9f20 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Mon, 13 Jul 2026 15:19:09 +0800 Subject: [PATCH 13/13] refactor(rooms): remove dorm gender restrictions --- apps/admin/src/pages/Rooms/index.tsx | 25 +---------- .../database-migrations.room-gender.spec.ts | 38 ++++++++++++++++ .../database/database-migrations.service.ts | 17 ++++++++ apps/server/src/entities/room.entity.ts | 3 -- .../src/occupancies/occupancies.controller.ts | 3 +- .../occupancies/occupancies.service.spec.ts | 2 +- .../src/occupancies/occupancies.service.ts | 41 ------------------ apps/server/src/rooms/dto/room.dto.spec.ts | 21 --------- apps/server/src/rooms/dto/room.dto.ts | 10 +---- apps/server/src/rooms/rooms.controller.ts | 14 ------ apps/server/src/rooms/rooms.gender.spec.ts | 43 ------------------- apps/server/src/rooms/rooms.service.ts | 10 +---- 12 files changed, 61 insertions(+), 166 deletions(-) create mode 100644 apps/server/src/database/database-migrations.room-gender.spec.ts delete mode 100644 apps/server/src/rooms/dto/room.dto.spec.ts delete mode 100644 apps/server/src/rooms/rooms.gender.spec.ts diff --git a/apps/admin/src/pages/Rooms/index.tsx b/apps/admin/src/pages/Rooms/index.tsx index c7cd535..b7ba609 100644 --- a/apps/admin/src/pages/Rooms/index.tsx +++ b/apps/admin/src/pages/Rooms/index.tsx @@ -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 ? {v}生宿舍 : '未指定', - }, { 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="留空自动解析" />
- - {
楼栋:{drawerRoom.building || '-'}
楼层:{drawerRoom.floor ?? '-'}
类型:{drawerRoom.roomType || '-'}
-
宿舍性别:{drawerRoom.gender ? `${drawerRoom.gender}生宿舍` : '未指定'}
额定人数:{drawerRoom.capacity}
租赁类别:{drawerRoom.rentalCategory === 'long' ? '长租' : '短租'}
月租金:{drawerRoom.monthlyRate ? `¥${drawerRoom.monthlyRate}` : '-'}
diff --git a/apps/server/src/database/database-migrations.room-gender.spec.ts b/apps/server/src/database/database-migrations.room-gender.spec.ts new file mode 100644 index 0000000..3728e3c --- /dev/null +++ b/apps/server/src/database/database-migrations.room-gender.spec.ts @@ -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; + }; + + await service.removeUnusedRoomColumns(); + + expect(runner.dropColumn).toHaveBeenCalledWith('rooms', 'gender'); + expect(runner.release).toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/database/database-migrations.service.ts b/apps/server/src/database/database-migrations.service.ts index b348687..305cb95 100644 --- a/apps/server/src/database/database-migrations.service.ts +++ b/apps/server/src/database/database-migrations.service.ts @@ -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 { + 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 { const runner = this.dataSource.createQueryRunner(); await runner.connect(); diff --git a/apps/server/src/entities/room.entity.ts b/apps/server/src/entities/room.entity.ts index fea5ae8..c5290b2 100644 --- a/apps/server/src/entities/room.entity.ts +++ b/apps/server/src/entities/room.entity.ts @@ -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; diff --git a/apps/server/src/occupancies/occupancies.controller.ts b/apps/server/src/occupancies/occupancies.controller.ts index 4e4a5ee..ee0aa79 100644 --- a/apps/server/src/occupancies/occupancies.controller.ts +++ b/apps/server/src/occupancies/occupancies.controller.ts @@ -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', diff --git a/apps/server/src/occupancies/occupancies.service.spec.ts b/apps/server/src/occupancies/occupancies.service.spec.ts index 982c9f0..45ff838 100644 --- a/apps/server/src/occupancies/occupancies.service.spec.ts +++ b/apps/server/src/occupancies/occupancies.service.spec.ts @@ -16,7 +16,7 @@ describe('OccupanciesService — responsible organization', () => { save: jest.fn(async (value) => ({ ...value, id: 10 })), } as any as Repository; 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; const studentRepo = { diff --git a/apps/server/src/occupancies/occupancies.service.ts b/apps/server/src/occupancies/occupancies.service.ts index 9252e98..79444bf 100644 --- a/apps/server/src/occupancies/occupancies.service.ts +++ b/apps/server/src/occupancies/occupancies.service.ts @@ -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' }); diff --git a/apps/server/src/rooms/dto/room.dto.spec.ts b/apps/server/src/rooms/dto/room.dto.spec.ts deleted file mode 100644 index c0cb72e..0000000 --- a/apps/server/src/rooms/dto/room.dto.spec.ts +++ /dev/null @@ -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 = (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(); - }); -}); diff --git a/apps/server/src/rooms/dto/room.dto.ts b/apps/server/src/rooms/dto/room.dto.ts index 883b75c..2a5add1 100644 --- a/apps/server/src/rooms/dto/room.dto.ts +++ b/apps/server/src/rooms/dto/room.dto.ts @@ -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; diff --git a/apps/server/src/rooms/rooms.controller.ts b/apps/server/src/rooms/rooms.controller.ts index d923ac9..8d10c92 100644 --- a/apps/server/src/rooms/rooms.controller.ts +++ b/apps/server/src/rooms/rooms.controller.ts @@ -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); diff --git a/apps/server/src/rooms/rooms.gender.spec.ts b/apps/server/src/rooms/rooms.gender.spec.ts deleted file mode 100644 index 6cc5cfe..0000000 --- a/apps/server/src/rooms/rooms.gender.spec.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/apps/server/src/rooms/rooms.service.ts b/apps/server/src/rooms/rooms.service.ts index 8b23d67..42c2f65 100644 --- a/apps/server/src/rooms/rooms.service.ts +++ b/apps/server/src/rooms/rooms.service.ts @@ -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++; -- 2.49.1