diff --git a/apps/admin/src/pages/RoomVisual/index.tsx b/apps/admin/src/pages/RoomVisual/index.tsx index 6134b54..c6f4af9 100644 --- a/apps/admin/src/pages/RoomVisual/index.tsx +++ b/apps/admin/src/pages/RoomVisual/index.tsx @@ -13,6 +13,8 @@ import { DatePicker, Alert, Button, + Switch, + Space, } from 'antd'; import { HomeOutlined, @@ -21,10 +23,15 @@ import { BankOutlined, HistoryOutlined, ShopOutlined, + CheckCircleOutlined, + SaveOutlined, } from '@ant-design/icons'; import dayjs, { Dayjs } from 'dayjs'; import api from '../../api'; import { message } from '../../ui/app-message'; +import PermissionButton from '../../components/PermissionButton'; +import { usePermission } from '../../hooks/usePermission'; +import { getInitialPresentOccupancyIds, togglePresentOccupancy } from './inspection-state'; function getCardStyle(room: any): React.CSSProperties { let base: React.CSSProperties; @@ -84,6 +91,9 @@ const RoomVisualPage: React.FC = () => { const [selectedOrganization, setSelectedOrganization] = useState('all'); const [detailRoom, setDetailRoom] = useState(null); const [asOf, setAsOf] = useState(null); + const [presentOccupancyIds, setPresentOccupancyIds] = useState([]); + const [inspectionSaving, setInspectionSaving] = useState(false); + const { hasPermission } = usePermission(); const isHistorical = !!asOf && !asOf.isSame(dayjs(), 'day'); @@ -104,6 +114,42 @@ const RoomVisualPage: React.FC = () => { fetchData(); }, [fetchData]); + useEffect(() => { + if (!detailRoom) { + setPresentOccupancyIds([]); + return; + } + setPresentOccupancyIds( + getInitialPresentOccupancyIds( + detailRoom.occupants || [], + detailRoom.inspection?.submitted === true, + ), + ); + }, [detailRoom]); + + const inspectionDate = (asOf || dayjs()).format('YYYY-MM-DD'); + + const submitInspection = async () => { + if (!detailRoom || isHistorical) return; + setInspectionSaving(true); + try { + await api.put(`/rooms/${detailRoom.id}/inspections/${inspectionDate}`, { + presentOccupancyIds, + }); + message.success(detailRoom.inspection?.submitted ? '查寝记录已更新' : '查寝已提交'); + const params = isHistorical ? { asOf: inspectionDate } : undefined; + const res: any = await api.get('/rooms/visual', { params }); + setData(res); + const updatedRoom = res.rooms.find((room: any) => room.id === detailRoom.id); + if (updatedRoom) setDetailRoom(updatedRoom); + } catch (e: unknown) { + const err = e as { message?: string }; + message.error(err?.message || '查寝提交失败'); + } finally { + setInspectionSaving(false); + } + }; + if (!data) return ; const rooms = data.rooms.filter((r: any) => { @@ -345,6 +391,17 @@ const RoomVisualPage: React.FC = () => { {room.occupants.length > 4 && +{room.occupants.length - 4}} )} + {room.occupants.length > 0 && ( +
+ {room.inspection?.submitted ? ( + + {room.inspection.source === 'automatic' ? '自动补记' : '已查寝'} + + ) : ( + 未查寝 + )} +
+ )} ))} @@ -388,9 +445,26 @@ const RoomVisualPage: React.FC = () => {
{getStatusLabel(detailRoom)}
{detailRoom.occupants.length > 0 ? (
-

当前住户

+
+

床位查寝

+ {detailRoom.inspection?.submitted && ( + + {detailRoom.inspection.source === 'automatic' ? '自动补记' : '已提交'} ·{' '} + {detailRoom.inspection.inspectorName} + + )} +
{detailRoom.occupants.map((o: any) => ( - +
{ )}
- {o.days} 天 + {isHistorical ? ( + + {o.inspectionStatus === 'present' + ? '在寝' + : o.inspectionStatus === 'absent' + ? '缺勤' + : '无记录'} + + ) : ( + + + {presentOccupancyIds.includes(o.occupancyId) ? '在寝' : '缺勤'} + + + setPresentOccupancyIds((current) => + togglePresentOccupancy(current, o.occupancyId, checked), + ) + } + /> + + )}
+ 床位:{o.bedNumber || '未分配'} |{' '} 入住:{o.checkInDate} | 计费起:{o.billingStartDate} {o.supervisor && ( 负责人:{o.supervisor} @@ -418,6 +526,37 @@ const RoomVisualPage: React.FC = () => {
))} + {!isHistorical && hasPermission('room:inspect') && ( +
+ + } + loading={inspectionSaving} + onClick={submitInspection} + > + 提交查寝 + +
+ )} ) : (
diff --git a/apps/admin/src/pages/RoomVisual/inspection-state.integration.test.ts b/apps/admin/src/pages/RoomVisual/inspection-state.integration.test.ts new file mode 100644 index 0000000..e7426b2 --- /dev/null +++ b/apps/admin/src/pages/RoomVisual/inspection-state.integration.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, it } from 'vitest'; +import { getInitialPresentOccupancyIds, togglePresentOccupancy } from './inspection-state'; + +describe('room inspection state', () => { + const occupants = [ + { occupancyId: 11, inspectionStatus: 'present' as const }, + { occupancyId: 12, inspectionStatus: 'absent' as const }, + ]; + + it('starts every occupant unchecked before the room is submitted', () => { + expect(getInitialPresentOccupancyIds(occupants, false)).toEqual([]); + }); + + it('restores the saved present occupants after submission', () => { + expect(getInitialPresentOccupancyIds(occupants, true)).toEqual([11]); + }); + + it('adds and removes a single occupant without changing the others', () => { + expect(togglePresentOccupancy([11], 12, true).sort()).toEqual([11, 12]); + expect(togglePresentOccupancy([11, 12], 11, false)).toEqual([12]); + }); +}); diff --git a/apps/admin/src/pages/RoomVisual/inspection-state.ts b/apps/admin/src/pages/RoomVisual/inspection-state.ts new file mode 100644 index 0000000..52fa53f --- /dev/null +++ b/apps/admin/src/pages/RoomVisual/inspection-state.ts @@ -0,0 +1,25 @@ +export interface InspectionOccupant { + occupancyId: number; + inspectionStatus?: 'present' | 'absent' | null; +} + +export function getInitialPresentOccupancyIds( + occupants: InspectionOccupant[], + submitted: boolean, +): number[] { + if (!submitted) return []; + return occupants + .filter((occupant) => occupant.inspectionStatus === 'present') + .map((occupant) => occupant.occupancyId); +} + +export function togglePresentOccupancy( + currentIds: number[], + occupancyId: number, + present: boolean, +): number[] { + const next = new Set(currentIds); + if (present) next.add(occupancyId); + else next.delete(occupancyId); + return [...next]; +} diff --git a/apps/server/src/app.module.ts b/apps/server/src/app.module.ts index 2c3efe9..fb49f7e 100644 --- a/apps/server/src/app.module.ts +++ b/apps/server/src/app.module.ts @@ -17,6 +17,8 @@ import { BillItem, User, OperationLog, + RoomInspection, + RoomInspectionDetail, Deposit, DepositInstallment, Classroom, @@ -53,7 +55,12 @@ import { import { AuthModule } from './auth/auth.module'; import { InitialSchema1784520727860 } from './migrations/1784520727860-InitialSchema'; import { AddExamManagement1784600000000 } from './migrations/1784600000000-AddExamManagement'; -const allMigrations = [InitialSchema1784520727860, AddExamManagement1784600000000]; +import { AddRoomInspections1784680000000 } from './migrations/1784680000000-AddRoomInspections'; +const allMigrations = [ + InitialSchema1784520727860, + AddExamManagement1784600000000, + AddRoomInspections1784680000000, +]; import { AuthorizationModule } from './authorization'; import { RbacModule } from './rbac/rbac.module'; import { StudentsModule } from './students/students.module'; @@ -120,6 +127,8 @@ import { IntegrationConfigModule } from './integration/config/config.module'; BillItem, User, OperationLog, + RoomInspection, + RoomInspectionDetail, Deposit, DepositInstallment, Classroom, diff --git a/apps/server/src/entities/index.ts b/apps/server/src/entities/index.ts index 9e85eae..db71eed 100644 --- a/apps/server/src/entities/index.ts +++ b/apps/server/src/entities/index.ts @@ -9,6 +9,8 @@ export { Bill } from './bill.entity'; export { BillItem } from './bill-item.entity'; export { User } from './user.entity'; export { OperationLog } from './operation-log.entity'; +export { RoomInspection } from './room-inspection.entity'; +export { RoomInspectionDetail } from './room-inspection-detail.entity'; export { Deposit } from './deposit.entity'; export { DepositInstallment } from './deposit-installment.entity'; export { Classroom, ClassroomStatus } from './classroom.entity'; diff --git a/apps/server/src/entities/room-inspection-detail.entity.ts b/apps/server/src/entities/room-inspection-detail.entity.ts new file mode 100644 index 0000000..5b94759 --- /dev/null +++ b/apps/server/src/entities/room-inspection-detail.entity.ts @@ -0,0 +1,58 @@ +import { + Column, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + Unique, +} from 'typeorm'; +import { RoomInspection } from './room-inspection.entity'; +import { Occupancy } from './occupancy.entity'; +import { Student } from './student.entity'; +import { Bed } from './bed.entity'; + +export type RoomInspectionStatus = 'present' | 'absent'; + +@Entity('room_inspection_details') +@Unique('uq_room_inspection_details_occupancy', ['inspectionId', 'occupancyId']) +export class RoomInspectionDetail { + @PrimaryGeneratedColumn() + id: number; + + @Column({ name: 'inspection_id' }) + inspectionId: number; + + @ManyToOne(() => RoomInspection, (inspection) => inspection.details, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'inspection_id' }) + inspection: RoomInspection; + + @Column({ name: 'occupancy_id' }) + occupancyId: number; + + @ManyToOne(() => Occupancy, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'occupancy_id' }) + occupancy: Occupancy; + + @Column({ name: 'student_id' }) + studentId: number; + + @ManyToOne(() => Student, { onDelete: 'RESTRICT' }) + @JoinColumn({ name: 'student_id' }) + student: Student; + + @Column({ name: 'bed_id', type: 'integer', nullable: true }) + bedId: number | null; + + @ManyToOne(() => Bed, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'bed_id' }) + bed: Bed | null; + + @Column({ type: 'varchar', length: 20 }) + status: RoomInspectionStatus; + + @Column({ name: 'student_name_snapshot', length: 100 }) + studentNameSnapshot: string; + + @Column({ name: 'bed_number_snapshot', type: 'varchar', length: 20, nullable: true }) + bedNumberSnapshot: string | null; +} diff --git a/apps/server/src/entities/room-inspection.entity.ts b/apps/server/src/entities/room-inspection.entity.ts new file mode 100644 index 0000000..4730f44 --- /dev/null +++ b/apps/server/src/entities/room-inspection.entity.ts @@ -0,0 +1,58 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + OneToMany, + PrimaryGeneratedColumn, + Unique, + UpdateDateColumn, +} from 'typeorm'; +import { Room } from './room.entity'; +import { User } from './user.entity'; +import { RoomInspectionDetail } from './room-inspection-detail.entity'; + +export type RoomInspectionSource = 'manual' | 'automatic'; + +@Entity('room_inspections') +@Unique('uq_room_inspections_date_room', ['inspectionDate', 'roomId']) +export class RoomInspection { + @PrimaryGeneratedColumn() + id: number; + + @Column({ name: 'inspection_date', type: 'date' }) + inspectionDate: string; + + @Column({ name: 'room_id' }) + roomId: number; + + @ManyToOne(() => Room, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'room_id' }) + room: Room; + + @Column({ name: 'inspector_id', type: 'integer', nullable: true }) + inspectorId: number | null; + + @ManyToOne(() => User, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'inspector_id' }) + inspector: User | null; + + @Column({ name: 'inspector_name', length: 50 }) + inspectorName: string; + + @Column({ type: 'varchar', length: 20, default: 'manual' }) + source: RoomInspectionSource; + + @Column({ name: 'submitted_at', type: 'datetime' }) + submittedAt: Date; + + @CreateDateColumn({ name: 'created_at' }) + createdAt: Date; + + @UpdateDateColumn({ name: 'updated_at' }) + updatedAt: Date; + + @OneToMany(() => RoomInspectionDetail, (detail) => detail.inspection) + details: RoomInspectionDetail[]; +} diff --git a/apps/server/src/migration-runner.ts b/apps/server/src/migration-runner.ts index e8e6bb3..de0ed27 100644 --- a/apps/server/src/migration-runner.ts +++ b/apps/server/src/migration-runner.ts @@ -1,6 +1,7 @@ import { DataSource } from 'typeorm'; import { InitialSchema1784520727860 } from './migrations/1784520727860-InitialSchema'; import { AddExamManagement1784600000000 } from './migrations/1784600000000-AddExamManagement'; +import { AddRoomInspections1784680000000 } from './migrations/1784680000000-AddRoomInspections'; import { config } from 'dotenv'; config(); @@ -19,7 +20,11 @@ export async function runMigrationsOnStartup(): Promise { password: process.env.DB_PASSWORD || '', database: process.env.DB_DATABASE || 'dorm_billing', charset: 'utf8mb4', - migrations: [InitialSchema1784520727860, AddExamManagement1784600000000], + migrations: [ + InitialSchema1784520727860, + AddExamManagement1784600000000, + AddRoomInspections1784680000000, + ], }); await ds.initialize(); diff --git a/apps/server/src/migrations/1784680000000-AddRoomInspections.ts b/apps/server/src/migrations/1784680000000-AddRoomInspections.ts new file mode 100644 index 0000000..877810c --- /dev/null +++ b/apps/server/src/migrations/1784680000000-AddRoomInspections.ts @@ -0,0 +1,54 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddRoomInspections1784680000000 implements MigrationInterface { + name = 'AddRoomInspections1784680000000'; + + async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE \`room_inspections\` ( + \`id\` int NOT NULL AUTO_INCREMENT, + \`inspection_date\` date NOT NULL, + \`room_id\` int NOT NULL, + \`inspector_id\` int NULL, + \`inspector_name\` varchar(50) NOT NULL, + \`source\` varchar(20) NOT NULL DEFAULT 'manual', + \`submitted_at\` datetime NOT NULL, + \`created_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6), + \`updated_at\` datetime(6) NOT NULL DEFAULT CURRENT_TIMESTAMP(6) ON UPDATE CURRENT_TIMESTAMP(6), + UNIQUE INDEX \`uq_room_inspections_date_room\` (\`inspection_date\`, \`room_id\`), + INDEX \`idx_room_inspections_inspector_id\` (\`inspector_id\`), + PRIMARY KEY (\`id\`) + ) ENGINE=InnoDB`); + await queryRunner.query(`CREATE TABLE \`room_inspection_details\` ( + \`id\` int NOT NULL AUTO_INCREMENT, + \`inspection_id\` int NOT NULL, + \`occupancy_id\` int NOT NULL, + \`student_id\` int NOT NULL, + \`bed_id\` int NULL, + \`status\` varchar(20) NOT NULL, + \`student_name_snapshot\` varchar(100) NOT NULL, + \`bed_number_snapshot\` varchar(20) NULL, + UNIQUE INDEX \`uq_room_inspection_details_occupancy\` (\`inspection_id\`, \`occupancy_id\`), + INDEX \`idx_room_inspection_details_occupancy_id\` (\`occupancy_id\`), + INDEX \`idx_room_inspection_details_student_id\` (\`student_id\`), + INDEX \`idx_room_inspection_details_bed_id\` (\`bed_id\`), + PRIMARY KEY (\`id\`) + ) ENGINE=InnoDB`); + await queryRunner.query('ALTER TABLE `room_inspections` ADD CONSTRAINT `fk_room_inspections_room` FOREIGN KEY (`room_id`) REFERENCES `rooms`(`id`) ON DELETE CASCADE'); + await queryRunner.query('ALTER TABLE `room_inspections` ADD CONSTRAINT `fk_room_inspections_inspector` FOREIGN KEY (`inspector_id`) REFERENCES `users`(`id`) ON DELETE SET NULL'); + await queryRunner.query('ALTER TABLE `room_inspection_details` ADD CONSTRAINT `fk_room_inspection_details_inspection` FOREIGN KEY (`inspection_id`) REFERENCES `room_inspections`(`id`) ON DELETE CASCADE'); + await queryRunner.query('ALTER TABLE `room_inspection_details` ADD CONSTRAINT `fk_room_inspection_details_occupancy` FOREIGN KEY (`occupancy_id`) REFERENCES `occupancies`(`id`) ON DELETE RESTRICT'); + await queryRunner.query('ALTER TABLE `room_inspection_details` ADD CONSTRAINT `fk_room_inspection_details_student` FOREIGN KEY (`student_id`) REFERENCES `students`(`id`) ON DELETE RESTRICT'); + await queryRunner.query('ALTER TABLE `room_inspection_details` ADD CONSTRAINT `fk_room_inspection_details_bed` FOREIGN KEY (`bed_id`) REFERENCES `beds`(`id`) ON DELETE SET NULL'); + } + + async down(queryRunner: QueryRunner): Promise { + await queryRunner.query('ALTER TABLE `room_inspection_details` DROP FOREIGN KEY `fk_room_inspection_details_bed`'); + await queryRunner.query('ALTER TABLE `room_inspection_details` DROP FOREIGN KEY `fk_room_inspection_details_student`'); + await queryRunner.query('ALTER TABLE `room_inspection_details` DROP FOREIGN KEY `fk_room_inspection_details_occupancy`'); + await queryRunner.query('ALTER TABLE `room_inspection_details` DROP FOREIGN KEY `fk_room_inspection_details_inspection`'); + await queryRunner.query('ALTER TABLE `room_inspections` DROP FOREIGN KEY `fk_room_inspections_inspector`'); + await queryRunner.query('ALTER TABLE `room_inspections` DROP FOREIGN KEY `fk_room_inspections_room`'); + await queryRunner.query('DROP TABLE `room_inspection_details`'); + await queryRunner.query('DROP TABLE `room_inspections`'); + } +} diff --git a/apps/server/src/rbac/rbac.permissions.spec.ts b/apps/server/src/rbac/rbac.permissions.spec.ts index 3a8d1ac..5cbf2d5 100644 --- a/apps/server/src/rbac/rbac.permissions.spec.ts +++ b/apps/server/src/rbac/rbac.permissions.spec.ts @@ -44,6 +44,7 @@ describe('preset role permissions', () => { ); expect(accommodation.extras).toContain('student:basic-view'); expect(accommodation.extras).not.toContain('organization:view'); + expect(accommodation.groups).toContain('room'); }); it('keeps classroom rental operations separate from accommodation operations', () => { diff --git a/apps/server/src/rbac/rbac.service.ts b/apps/server/src/rbac/rbac.service.ts index a814e61..0cf4688 100644 --- a/apps/server/src/rbac/rbac.service.ts +++ b/apps/server/src/rbac/rbac.service.ts @@ -28,6 +28,7 @@ const PRESET_PERMISSIONS: Array<{ code: string; name: string; group: string }> = { code: 'student:export', name: '导出学生', group: 'student' }, { code: 'exam:view', name: '查看和录入考试成绩', group: 'exam' }, { code: 'room:view', name: '查看宿舍', group: 'room' }, + { code: 'room:inspect', name: '宿舍查寝', group: 'room' }, { code: 'room:create', name: '新增宿舍', group: 'room' }, { code: 'room:edit', name: '编辑宿舍', group: 'room' }, { code: 'room:delete', name: '归档宿舍', group: 'room' }, diff --git a/apps/server/src/rooms/dto/room-inspection.dto.ts b/apps/server/src/rooms/dto/room-inspection.dto.ts new file mode 100644 index 0000000..bceb6ba --- /dev/null +++ b/apps/server/src/rooms/dto/room-inspection.dto.ts @@ -0,0 +1,8 @@ +import { ArrayUnique, IsArray, IsInt } from 'class-validator'; + +export class UpdateRoomInspectionDto { + @IsArray() + @ArrayUnique() + @IsInt({ each: true }) + presentOccupancyIds: number[]; +} diff --git a/apps/server/src/rooms/room-inspections.service.spec.ts b/apps/server/src/rooms/room-inspections.service.spec.ts new file mode 100644 index 0000000..b6d6c82 --- /dev/null +++ b/apps/server/src/rooms/room-inspections.service.spec.ts @@ -0,0 +1,174 @@ +import { BadRequestException } from '@nestjs/common'; +import { DataSource, EntityManager, Repository } from 'typeorm'; +import { Occupancy } from '../entities/occupancy.entity'; +import { Room } from '../entities/room.entity'; +import { RoomInspection } from '../entities/room-inspection.entity'; +import { RoomInspectionDetail } from '../entities/room-inspection-detail.entity'; +import { OperationLogsService } from '../operation-logs/operation-logs.service'; +import { RoomInspectionsService } from './room-inspections.service'; + +describe('RoomInspectionsService', () => { + const today = new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(new Date()); + + const createService = (options?: { + existingInspection?: Partial | null; + existingInspections?: Partial[]; + occupancies?: Occupancy[]; + }) => { + const inspectionRepo = { + findOne: jest.fn().mockResolvedValue(options?.existingInspection ?? null), + create: jest.fn((value) => value), + save: jest.fn(async (value) => ({ id: 91, ...value })), + find: jest.fn().mockResolvedValue(options?.existingInspections ?? []), + } as unknown as Repository; + const detailRepo = { + create: jest.fn((value) => value), + save: jest.fn(async (value) => value), + delete: jest.fn().mockResolvedValue(undefined), + } as unknown as Repository; + const roomRepo = { + findOne: jest.fn().mockResolvedValue({ id: 5, roomNumber: '4-102' }), + } as unknown as Repository; + const occupancies = options?.occupancies ?? ([ + { + id: 11, + roomId: 5, + studentId: 21, + bedId: 31, + student: { name: '张三' }, + bed: { bedNumber: '1号床' }, + }, + { + id: 12, + roomId: 5, + studentId: 22, + bedId: 32, + student: { name: '李四' }, + bed: { bedNumber: '2号床' }, + }, + ] as Occupancy[]); + const occupancyRepo = { + find: jest.fn().mockResolvedValue(occupancies), + } as unknown as Repository; + const roomQuery = { + where: jest.fn().mockReturnThis(), + getOne: jest.fn().mockResolvedValue({ id: 5, roomNumber: '4-102', status: 'full' }), + setLock: jest.fn().mockReturnThis(), + }; + const manager = { + createQueryBuilder: jest.fn().mockReturnValue(roomQuery), + getRepository: jest.fn((entity) => { + if (entity === RoomInspection) return inspectionRepo; + if (entity === RoomInspectionDetail) return detailRepo; + if (entity === Occupancy) return occupancyRepo; + throw new Error(`unexpected repository ${String(entity)}`); + }), + } as unknown as EntityManager; + const dataSource = { + options: { type: 'better-sqlite3' }, + manager, + transaction: jest.fn(async (callback) => callback(manager)), + } as unknown as DataSource; + const logs = { log: jest.fn().mockResolvedValue(undefined) } as unknown as OperationLogsService; + const service = new RoomInspectionsService( + inspectionRepo, + detailRepo, + roomRepo, + occupancyRepo, + dataSource, + logs, + ); + return { service, inspectionRepo, detailRepo, occupancyRepo, roomRepo, logs }; + }; + + it('creates present and absent details in one transaction', async () => { + const { service, detailRepo } = createService(); + + const result = await service.submit(5, today, [11], { id: 7, username: 'teacher' }); + + expect(detailRepo.delete).toHaveBeenCalledWith({ inspectionId: 91 }); + expect(detailRepo.save).toHaveBeenCalledWith([ + expect.objectContaining({ occupancyId: 11, status: 'present', studentNameSnapshot: '张三' }), + expect.objectContaining({ occupancyId: 12, status: 'absent', studentNameSnapshot: '李四' }), + ]); + expect(result.presentNames).toEqual(['张三']); + expect(result.absentNames).toEqual(['李四']); + }); + + it('replaces details when the same room is submitted again today', async () => { + const { service, inspectionRepo, detailRepo } = createService({ + existingInspection: { id: 91, roomId: 5, inspectionDate: today }, + }); + + const result = await service.submit(5, today, [11, 12], { id: 8, username: 'reviewer' }); + + expect(result.isUpdate).toBe(true); + expect(inspectionRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ id: 91, inspectorId: 8, source: 'manual' }), + ); + expect(detailRepo.delete).toHaveBeenCalledWith({ inspectionId: 91 }); + }); + + it('rejects historical and future dates', async () => { + const { service } = createService(); + await expect(service.submit('5' as never, '2000-01-01', [], {})).rejects.toThrow( + new BadRequestException('历史日期的查寝记录不可更改'), + ); + await expect(service.submit(5, '2999-01-01', [], {})).rejects.toThrow( + new BadRequestException('不能提前提交未来日期的查寝记录'), + ); + }); + + it('rejects occupancy ids outside the room and date snapshot', async () => { + const { service } = createService(); + await expect(service.submit(5, today, [999], { id: 7, username: 'teacher' })).rejects.toThrow( + '存在不属于该宿舍当日住户的入住记录: 999', + ); + }); + + it('attributes automatic absences to the last manual inspector', async () => { + const { service, inspectionRepo, logs } = createService(); + (inspectionRepo.findOne as jest.Mock).mockResolvedValueOnce({ + inspectorId: 8, + inspectorName: 'last-teacher', + source: 'manual', + }); + + const created = await service.settleDate('2026-07-21'); + + expect(created).toBe(1); + expect(inspectionRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + inspectorId: 8, + inspectorName: 'last-teacher', + source: 'automatic', + }), + ); + expect(logs.log).toHaveBeenCalledWith( + expect.objectContaining({ username: 'last-teacher', action: '自动补记缺勤' }), + ); + }); + + it('uses the system identity when nobody inspected any room that day', async () => { + const { service, inspectionRepo, logs } = createService(); + (inspectionRepo.findOne as jest.Mock).mockResolvedValueOnce(null); + + await service.settleDate('2026-07-21'); + + expect(inspectionRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + inspectorId: null, + inspectorName: '系统自动判定', + source: 'automatic', + }), + ); + expect(logs.log).toHaveBeenCalledWith( + expect.objectContaining({ username: '系统自动判定' }), + ); + }); +}); diff --git a/apps/server/src/rooms/room-inspections.service.ts b/apps/server/src/rooms/room-inspections.service.ts new file mode 100644 index 0000000..5226b55 --- /dev/null +++ b/apps/server/src/rooms/room-inspections.service.ts @@ -0,0 +1,255 @@ +import { BadRequestException, Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; +import { Cron } from '@nestjs/schedule'; +import { InjectRepository } from '@nestjs/typeorm'; +import { DataSource, EntityManager, IsNull, LessThanOrEqual, MoreThanOrEqual, Repository } from 'typeorm'; +import { Bed } from '../entities/bed.entity'; +import { Occupancy } from '../entities/occupancy.entity'; +import { Room } from '../entities/room.entity'; +import { RoomInspection } from '../entities/room-inspection.entity'; +import { RoomInspectionDetail } from '../entities/room-inspection-detail.entity'; +import { OperationLogsService } from '../operation-logs/operation-logs.service'; + +interface InspectorIdentity { + id?: number; + username?: string; +} + +@Injectable() +export class RoomInspectionsService implements OnApplicationBootstrap { + private readonly logger = new Logger(RoomInspectionsService.name); + private settling = false; + + constructor( + @InjectRepository(RoomInspection) + private readonly inspectionRepo: Repository, + @InjectRepository(RoomInspectionDetail) + private readonly detailRepo: Repository, + @InjectRepository(Room) + private readonly roomRepo: Repository, + @InjectRepository(Occupancy) + private readonly occupancyRepo: Repository, + private readonly dataSource: DataSource, + private readonly operationLogs: OperationLogsService, + ) {} + + async onApplicationBootstrap(): Promise { + await this.settlePreviousDay().catch((error) => { + this.logger.error('补记昨日宿舍查寝失败', error instanceof Error ? error.stack : String(error)); + }); + } + + @Cron('5 0 * * *', { timeZone: 'Asia/Shanghai' }) + async settlePreviousDay(now = new Date()): Promise { + if (this.settling) return; + this.settling = true; + try { + const today = this.getChinaDate(now); + const targetDate = this.shiftDate(today, -1); + await this.settleDate(targetDate); + } finally { + this.settling = false; + } + } + + async submit( + roomId: number, + inspectionDate: string, + presentOccupancyIds: number[], + inspector: InspectorIdentity, + ) { + this.assertToday(inspectionDate); + const uniquePresentIds = [...new Set(presentOccupancyIds)]; + + return this.dataSource.transaction(async (manager) => { + const room = await this.lockRoom(manager, roomId, false); + const occupancies = await this.findOccupanciesForDate(manager, roomId, inspectionDate); + const allowedIds = new Set(occupancies.map((occupancy) => occupancy.id)); + const invalidIds = uniquePresentIds.filter((id) => !allowedIds.has(id)); + if (invalidIds.length > 0) { + throw new BadRequestException(`存在不属于该宿舍当日住户的入住记录: ${invalidIds.join(', ')}`); + } + + const inspectionRepo = manager.getRepository(RoomInspection); + const detailRepo = manager.getRepository(RoomInspectionDetail); + let inspection = await inspectionRepo.findOne({ + where: { roomId, inspectionDate }, + }); + const isUpdate = !!inspection; + if (!inspection) { + inspection = inspectionRepo.create({ roomId, inspectionDate }); + } + inspection.inspectorId = inspector.id ?? null; + inspection.inspectorName = inspector.username || '未知用户'; + inspection.source = 'manual'; + inspection.submittedAt = new Date(); + inspection = await inspectionRepo.save(inspection); + + await detailRepo.delete({ inspectionId: inspection.id }); + const presentSet = new Set(uniquePresentIds); + const details = occupancies.map((occupancy) => + detailRepo.create({ + inspectionId: inspection.id, + occupancyId: occupancy.id, + studentId: occupancy.studentId, + bedId: occupancy.bedId ?? null, + status: presentSet.has(occupancy.id) ? 'present' : 'absent', + studentNameSnapshot: occupancy.student?.name || '未知学生', + bedNumberSnapshot: occupancy.bed?.bedNumber || null, + }), + ); + if (details.length > 0) await detailRepo.save(details); + + return { + inspection: { ...inspection, details }, + roomNumber: room.roomNumber, + isUpdate, + presentNames: details + .filter((detail) => detail.status === 'present') + .map((detail) => detail.studentNameSnapshot), + absentNames: details + .filter((detail) => detail.status === 'absent') + .map((detail) => detail.studentNameSnapshot), + }; + }); + } + + async getByRoomsAndDate(roomIds: number[], inspectionDate: string) { + if (roomIds.length === 0) return new Map(); + const inspections = await this.inspectionRepo + .createQueryBuilder('inspection') + .leftJoinAndSelect('inspection.details', 'detail') + .where('inspection.roomId IN (:...roomIds)', { roomIds }) + .andWhere('inspection.inspectionDate = :inspectionDate', { inspectionDate }) + .getMany(); + return new Map(inspections.map((inspection) => [inspection.roomId, inspection])); + } + + async settleDate(inspectionDate: string): Promise { + const existing = await this.inspectionRepo.find({ where: { inspectionDate } }); + const existingRoomIds = new Set(existing.map((inspection) => inspection.roomId)); + const occupancies = await this.findAllOccupanciesForDate(this.dataSource.manager, inspectionDate); + const byRoom = new Map(); + for (const occupancy of occupancies) { + if (existingRoomIds.has(occupancy.roomId)) continue; + const roomOccupancies = byRoom.get(occupancy.roomId) ?? []; + roomOccupancies.push(occupancy); + byRoom.set(occupancy.roomId, roomOccupancies); + } + if (byRoom.size === 0) return 0; + + const lastManualInspection = await this.inspectionRepo.findOne({ + where: { inspectionDate, source: 'manual' }, + order: { submittedAt: 'DESC' }, + }); + const inspectorId = lastManualInspection?.inspectorId ?? null; + const inspectorName = lastManualInspection?.inspectorName || '系统自动判定'; + let created = 0; + + for (const [roomId, roomOccupancies] of byRoom) { + const result = await this.dataSource.transaction(async (manager) => { + await this.lockRoom(manager, roomId, true); + const inspectionRepo = manager.getRepository(RoomInspection); + const detailRepo = manager.getRepository(RoomInspectionDetail); + const duplicate = await inspectionRepo.findOne({ where: { roomId, inspectionDate } }); + if (duplicate) return null; + const inspection = await inspectionRepo.save( + inspectionRepo.create({ + inspectionDate, + roomId, + inspectorId, + inspectorName, + source: 'automatic', + submittedAt: new Date(), + }), + ); + const details = roomOccupancies.map((occupancy) => + detailRepo.create({ + inspectionId: inspection.id, + occupancyId: occupancy.id, + studentId: occupancy.studentId, + bedId: occupancy.bedId ?? null, + status: 'absent', + studentNameSnapshot: occupancy.student?.name || '未知学生', + bedNumberSnapshot: occupancy.bed?.bedNumber || null, + }), + ); + await detailRepo.save(details); + return { inspection, details }; + }); + if (!result) continue; + created++; + const room = await this.roomRepo.findOne({ where: { id: roomId } }); + await this.operationLogs.log({ + userId: inspectorId ?? undefined, + username: inspectorName, + module: '宿舍查寝', + action: '自动补记缺勤', + targetId: roomId, + targetType: 'room', + detail: `查寝日期: ${inspectionDate}, 宿舍: ${room?.roomNumber || roomId}, 缺勤: ${result.details.map((detail) => detail.studentNameSnapshot).join('、') || '无'}`, + }); + } + return created; + } + + private async lockRoom( + manager: EntityManager, + roomId: number, + allowArchived: boolean, + ): Promise { + let query = manager.createQueryBuilder(Room, 'room').where('room.id = :roomId', { roomId }); + if (['mysql', 'mariadb', 'postgres', 'cockroachdb'].includes(this.dataSource.options.type)) { + query = query.setLock('pessimistic_write'); + } + const room = await query.getOne(); + if (!room) throw new BadRequestException('宿舍不存在'); + if (!allowArchived && room.status === 'archived') { + throw new BadRequestException('已归档宿舍不能查寝'); + } + return room; + } + + private findOccupanciesForDate(manager: EntityManager, roomId: number, date: string) { + return manager.getRepository(Occupancy).find({ + where: [ + { roomId, checkInDate: LessThanOrEqual(date), checkOutDate: IsNull() }, + { roomId, checkInDate: LessThanOrEqual(date), checkOutDate: MoreThanOrEqual(date) }, + ], + relations: ['student', 'bed'], + order: { id: 'ASC' }, + }); + } + + private findAllOccupanciesForDate(manager: EntityManager, date: string) { + return manager.getRepository(Occupancy).find({ + where: [ + { checkInDate: LessThanOrEqual(date), checkOutDate: IsNull() }, + { checkInDate: LessThanOrEqual(date), checkOutDate: MoreThanOrEqual(date) }, + ], + relations: ['student', 'bed'], + order: { roomId: 'ASC', id: 'ASC' }, + }); + } + + private assertToday(date: string): void { + if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) throw new BadRequestException('查寝日期格式错误'); + const today = this.getChinaDate(new Date()); + if (date < today) throw new BadRequestException('历史日期的查寝记录不可更改'); + if (date > today) throw new BadRequestException('不能提前提交未来日期的查寝记录'); + } + + private getChinaDate(now: Date): string { + return new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(now); + } + + private shiftDate(date: string, days: number): string { + const shifted = new Date(`${date}T12:00:00Z`); + shifted.setUTCDate(shifted.getUTCDate() + days); + return shifted.toISOString().slice(0, 10); + } +} diff --git a/apps/server/src/rooms/rooms.boundaries.spec.ts b/apps/server/src/rooms/rooms.boundaries.spec.ts index f84658c..a8e7b58 100644 --- a/apps/server/src/rooms/rooms.boundaries.spec.ts +++ b/apps/server/src/rooms/rooms.boundaries.spec.ts @@ -7,6 +7,7 @@ import { RoomExpense } from '../entities/room-expense.entity'; import { RoomsService } from './rooms.service'; import { RoomsController } from './rooms.controller'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; +import { PERMISSION_KEY } from '../auth/decorators/permission.decorator'; // ── Controller test mocks ────────────────────────────────────────────── @@ -90,6 +91,14 @@ describe('RoomsService — parseRoomNumber boundary conditions', () => { }); }); +describe('RoomsController — inspection permission boundary', () => { + it('requires the dedicated room:inspect permission', () => { + expect(Reflect.getMetadata(PERMISSION_KEY, RoomsController.prototype.updateInspection)).toEqual([ + 'room:inspect', + ]); + }); +}); + // ── Service: batchImport boundary conditions ─────────────────────────── describe('RoomsService — batchImport boundary conditions', () => { @@ -123,6 +132,7 @@ describe('RoomsService — batchImport boundary conditions', () => { bedRepo, lockerRepo, dataSource, + { getByRoomsAndDate: jest.fn().mockResolvedValue(new Map()) } as never, ); return { service, roomRepo, bedRepo }; @@ -212,7 +222,11 @@ describe('RoomsController — boundary conditions', () => { cb(dataRow, 2); }); - const controller = new RoomsController(mockRoomsService, mockLogService); + const controller = new RoomsController( + mockRoomsService, + mockLogService, + { submit: jest.fn() } as never, + ); const file = { buffer: Buffer.from('fake') } as Express.Multer.File; const req = { user: { id: 1, username: 'tester' } }; diff --git a/apps/server/src/rooms/rooms.controller.ts b/apps/server/src/rooms/rooms.controller.ts index 88ff447..1d10ea4 100644 --- a/apps/server/src/rooms/rooms.controller.ts +++ b/apps/server/src/rooms/rooms.controller.ts @@ -19,6 +19,8 @@ import { RoomsService } from './rooms.service'; import { CreateRoomDto, UpdateRoomDto } from './dto/room.dto'; import { CreateBedDto, UpdateBedDto, BatchCreateBedDto } from './dto/bed.dto'; import { CreateLockerDto, UpdateLockerDto, BatchCreateLockerDto } from './dto/locker.dto'; +import { UpdateRoomInspectionDto } from './dto/room-inspection.dto'; +import { RoomInspectionsService } from './room-inspections.service'; import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard'; import { OperationLogsService } from '../operation-logs/operation-logs.service'; import { extractRequestInfo } from '../common/request-utils'; @@ -31,6 +33,7 @@ export class RoomsController { constructor( private service: RoomsService, private logService: OperationLogsService, + private inspectionsService: RoomInspectionsService, ) {} @Get() @@ -54,6 +57,35 @@ export class RoomsController { return this.service.getRoomVisual(asOf); } + @Put(':roomId/inspections/:date') + @RequirePermission('room:inspect') + async updateInspection( + @Param('roomId') roomId: string, + @Param('date') date: string, + @Body() dto: UpdateRoomInspectionDto, + @Request() req: any, + ) { + const { ipAddress, userAgent } = extractRequestInfo(req); + const result = await this.inspectionsService.submit( + +roomId, + date, + dto.presentOccupancyIds, + { id: req.user?.id, username: req.user?.username }, + ); + await this.logService.log({ + userId: req.user?.id, + username: req.user?.username, + module: '宿舍查寝', + action: result.isUpdate ? '修改查寝记录' : '提交查寝记录', + targetId: +roomId, + targetType: 'room', + detail: `查寝日期: ${date}, 宿舍: ${result.roomNumber}, 在寝: ${result.presentNames.join('、') || '无'}, 缺勤: ${result.absentNames.join('、') || '无'}`, + ipAddress, + userAgent, + }); + return result.inspection; + } + @Get('template') @RequirePermission('room:view') async downloadTemplate(@Res() res: Response) { diff --git a/apps/server/src/rooms/rooms.module.ts b/apps/server/src/rooms/rooms.module.ts index 5ce2996..c194366 100644 --- a/apps/server/src/rooms/rooms.module.ts +++ b/apps/server/src/rooms/rooms.module.ts @@ -5,14 +5,28 @@ import { Occupancy } from '../entities/occupancy.entity'; import { RoomExpense } from '../entities/room-expense.entity'; import { Bed } from '../entities/bed.entity'; import { Locker } from '../entities/locker.entity'; +import { RoomInspection } from '../entities/room-inspection.entity'; +import { RoomInspectionDetail } from '../entities/room-inspection-detail.entity'; import { RoomsService } from './rooms.service'; import { RoomsController } from './rooms.controller'; import { OperationLogsModule } from '../operation-logs/operation-logs.module'; +import { RoomInspectionsService } from './room-inspections.service'; @Module({ - imports: [TypeOrmModule.forFeature([Room, Occupancy, RoomExpense, Bed, Locker]), OperationLogsModule], + imports: [ + TypeOrmModule.forFeature([ + Room, + Occupancy, + RoomExpense, + Bed, + Locker, + RoomInspection, + RoomInspectionDetail, + ]), + OperationLogsModule, + ], controllers: [RoomsController], - providers: [RoomsService], - exports: [RoomsService], + providers: [RoomsService, RoomInspectionsService], + exports: [RoomsService, RoomInspectionsService], }) export class RoomsModule {} diff --git a/apps/server/src/rooms/rooms.service.spec.ts b/apps/server/src/rooms/rooms.service.spec.ts index ef9d40a..9328129 100644 --- a/apps/server/src/rooms/rooms.service.spec.ts +++ b/apps/server/src/rooms/rooms.service.spec.ts @@ -54,6 +54,7 @@ describe('RoomsService — capacity consistency', () => { bedRepo, {} as Repository, dataSource, + { getByRoomsAndDate: jest.fn().mockResolvedValue(new Map()) } as never, ); return { service, roomRepo, bedRepo, occupancyRepo }; diff --git a/apps/server/src/rooms/rooms.service.ts b/apps/server/src/rooms/rooms.service.ts index 896848c..299bb69 100644 --- a/apps/server/src/rooms/rooms.service.ts +++ b/apps/server/src/rooms/rooms.service.ts @@ -19,6 +19,7 @@ import { Locker } from '../entities/locker.entity'; import { CreateRoomDto, UpdateRoomDto } from './dto/room.dto'; import { CreateBedDto, UpdateBedDto, BatchCreateBedDto } from './dto/bed.dto'; import { CreateLockerDto, UpdateLockerDto, BatchCreateLockerDto } from './dto/locker.dto'; +import { RoomInspectionsService } from './room-inspections.service'; @Injectable() export class RoomsService { @@ -29,6 +30,7 @@ export class RoomsService { @InjectRepository(Bed) private bedRepo: Repository, @InjectRepository(Locker) private lockerRepo: Repository, private dataSource: DataSource, + private readonly inspectionsService: RoomInspectionsService, ) {} /** @@ -235,7 +237,7 @@ export class RoomsService { async getRoomVisual(asOf?: string) { // asOf 为空 = 实时(今天)。带 asOf = 还原该日期结束时的历史入住快照。 const isHistorical = !!asOf; - const targetDate = asOf || new Date().toISOString().slice(0, 10); + const targetDate = asOf || this.getChinaDate(new Date()); // 实时视图排除已归档房间;历史视图不排除——当时有人住的房间即使现在已归档也应显示。 const rooms = await this.repo.find({ @@ -244,13 +246,11 @@ export class RoomsService { }); const occupancies = await this.occRepo.find({ - where: isHistorical - ? [ - { checkInDate: LessThanOrEqual(targetDate), checkOutDate: IsNull() }, - { checkInDate: LessThanOrEqual(targetDate), checkOutDate: MoreThanOrEqual(targetDate) }, - ] - : { checkOutDate: IsNull() }, - relations: ['student', 'student.organization', 'responsibleOrganization'], + where: [ + { checkInDate: LessThanOrEqual(targetDate), checkOutDate: IsNull() }, + { checkInDate: LessThanOrEqual(targetDate), checkOutDate: MoreThanOrEqual(targetDate) }, + ], + relations: ['student', 'student.organization', 'responsibleOrganization', 'bed'], order: { checkInDate: 'ASC' }, }); @@ -264,7 +264,10 @@ export class RoomsService { const days = Math.max(1, Math.ceil((refTime - checkIn.getTime()) / (1000 * 60 * 60 * 24))); occMap.get(occ.roomId)!.push({ studentId: occ.studentId, + occupancyId: occ.id, studentName: occ.student?.name || '未知', + bedId: occ.bedId ?? null, + bedNumber: occ.bed?.bedNumber || null, checkInDate: occ.checkInDate, billingStartDate: occ.billingStartDate, days, @@ -296,10 +299,19 @@ export class RoomsService { if (bed.status === 'occupied') entry.occupied++; } + const inspectionMap = await this.inspectionsService.getByRoomsAndDate( + visibleRooms.map((room) => room.id), + targetDate, + ); + return { buildings, rooms: visibleRooms.map((room) => { const occ = occMap.get(room.id) || []; + const inspection = inspectionMap.get(room.id); + const inspectionByOccupancyId = new Map( + (inspection?.details || []).map((detail) => [detail.occupancyId, detail.status]), + ); const orgs = [...new Set(occ.map((o: any) => o.organization).filter(Boolean))]; let orgLabel: string | null = null; if (orgs.length > 0 && occ.length > 0) { @@ -322,7 +334,19 @@ export class RoomsService { currentCount: occ.length, totalBeds: bedMap.get(room.id)?.total ?? 0, occupiedBeds: bedMap.get(room.id)?.occupied ?? 0, - occupants: occ, + occupants: occ.map((occupant) => ({ + ...occupant, + inspectionStatus: inspectionByOccupancyId.get(occupant.occupancyId) || null, + })), + inspection: inspection + ? { + submitted: true, + inspectorId: inspection.inspectorId, + inspectorName: inspection.inspectorName, + source: inspection.source, + submittedAt: inspection.submittedAt, + } + : { submitted: false }, orgLabel, organizationColor, organizationIds, @@ -346,6 +370,15 @@ export class RoomsService { }; } + private getChinaDate(now: Date): string { + return new Intl.DateTimeFormat('en-CA', { + timeZone: 'Asia/Shanghai', + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(now); + } + async batchImport( rows: { roomNumber: string;