From aa1ed7db567b9e2d3b5dc0c984ec3fca21a46769 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Mon, 13 Jul 2026 15:12:36 +0800 Subject: [PATCH] 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",