diff --git a/apps/admin/src/pages/Attendance/attendance.css b/apps/admin/src/pages/Attendance/attendance.css index 8e542ff..1323f69 100644 --- a/apps/admin/src/pages/Attendance/attendance.css +++ b/apps/admin/src/pages/Attendance/attendance.css @@ -971,7 +971,9 @@ .student-teacher-item { display: flex; align-items: center; + justify-content: center; gap: 10px; + text-align: center; min-width: 0; padding: 10px; border: 1px solid rgb(21 122 101 / 10%); @@ -990,7 +992,9 @@ } .student-teacher-item > div { + flex: 1; min-width: 0; + text-align: center; } .student-teacher-item strong, diff --git a/apps/admin/src/pages/Attendance/index.tsx b/apps/admin/src/pages/Attendance/index.tsx index 82d5f7b..096edb4 100644 --- a/apps/admin/src/pages/Attendance/index.tsx +++ b/apps/admin/src/pages/Attendance/index.tsx @@ -74,9 +74,18 @@ const ADMIN_CORRECTION_OPTIONS = [ { value: 'absent', label: '缺勤' }, ]; +interface ClassTeacherOption { + userId: number; + username: string | null; + name: string | null; + roleType: string; + subject: string | null; +} + interface ClassOption { classId: number; className: string; + teachers?: ClassTeacherOption[]; } interface AttendanceRecordItem { @@ -126,6 +135,8 @@ interface HistoryScheduleOption { endDate: string; subject: string; teacherId: number | null; + teacherName?: string | null; + teacherUsername?: string | null; status: string; } @@ -197,6 +208,28 @@ function displayAttendanceStatus(status?: string | null): string { return status === 'pending' || !status ? 'absent' : status; } +function getTeacherDisplayName(teacher?: { + name?: string | null; + username?: string | null; +}): string { + const name = teacher?.name?.trim(); + if (name) return name; + return teacher?.username?.trim() || '未设置'; +} + +function formatTeacherNames( + teachers: readonly { name?: string | null; username?: string | null }[], +): string { + const names = [ + ...new Set( + teachers + .map((teacher) => getTeacherDisplayName(teacher)) + .filter((name) => name && name !== '未设置'), + ), + ]; + return names.length > 0 ? names.join('、') : '未设置'; +} + function AttendanceStatusTag({ status }: { status: string }) { const displayStatus = displayAttendanceStatus(status); const meta = STATUS_META[displayStatus] ?? { @@ -916,9 +949,30 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) => }); }, [metricFilter, studentPanels, studentSearch]); - const selectedClass = classId - ? classOptions.find((item) => item.classId === classId)?.className || `班级 ${classId}` - : '全部班级'; + const selectedClassOption = classId + ? classOptions.find((item) => item.classId === classId) + : undefined; + const selectedClass = selectedClassOption?.className || (classId ? `班级 ${classId}` : '全部班级'); + const overviewTeachers = selectedClassOption?.teachers ?? []; + const headTeacherNames = classId + ? formatTeacherNames(overviewTeachers.filter((teacher) => teacher.roleType === 'head_teacher')) + : '请选择班级'; + const lifeTeacherNames = classId + ? formatTeacherNames(overviewTeachers.filter((teacher) => teacher.roleType === 'life_teacher')) + : '请选择班级'; + const currentSchedule = scheduleId + ? scheduleOptions.find((item) => item.id === scheduleId) + : undefined; + const subjectTeacherNames = currentSchedule + ? getTeacherDisplayName({ + name: currentSchedule.teacherName, + username: currentSchedule.teacherUsername, + }) + : classId + ? formatTeacherNames( + overviewTeachers.filter((teacher) => teacher.roleType === 'subject_teacher'), + ) + : '请选择班级'; const attendanceRate = summary.total > 0 ? Math.round((summary.present / summary.total) * 100) : 0; const dateLabel = attendanceDate?.format('YYYY-MM-DD') || '未选择日期'; @@ -1121,21 +1175,21 @@ const AdminAttendanceArchive: React.FC<{ canEdit: boolean }> = ({ canEdit }) =>
班主任 - 按班级筛选后查看 + {headTeacherNames}
-
+
生活老师 - 暂未接入 + {lifeTeacherNames}
- 当前任课 - {session ? sessionMap[session] : '全部时段'} + 任课老师 + {subjectTeacherNames}
diff --git a/apps/server/src/attendance/attendance.lesson-session.spec.ts b/apps/server/src/attendance/attendance.lesson-session.spec.ts index 1bd36a2..f212905 100644 --- a/apps/server/src/attendance/attendance.lesson-session.spec.ts +++ b/apps/server/src/attendance/attendance.lesson-session.spec.ts @@ -45,6 +45,7 @@ const createService = () => { {} as never, sessionRepo as never, attendanceDeviceRepo as never, + {} as never, dataSource as unknown as DataSource, ); return { service, attendanceRepo, dingRawRepo, scheduleRepo, classStudentRepo, sessionRepo, attendanceDeviceRepo, dataSource }; diff --git a/apps/server/src/attendance/attendance.service.spec.ts b/apps/server/src/attendance/attendance.service.spec.ts index 2de9889..3a8978b 100644 --- a/apps/server/src/attendance/attendance.service.spec.ts +++ b/apps/server/src/attendance/attendance.service.spec.ts @@ -7,6 +7,7 @@ import { AttendanceService } from './attendance.service'; import { AttendanceRecord } from '../entities/attendance-record.entity'; import { AttendanceSession } from '../entities/attendance-session.entity'; import { AttendanceDevice } from '../entities/attendance-device.entity'; +import { AttendancePeriodConfig } from '../entities/attendance-period-config.entity'; import { DingAttendanceRaw } from '../entities/ding-attendance-raw.entity'; import { Class } from '../entities/class.entity'; import { Student } from '../entities/student.entity'; @@ -59,6 +60,7 @@ describe('AttendanceService — batchCreate', () => { { provide: getRepositoryToken(ClassTeacher), useValue: { findOne: jest.fn() } }, { provide: getRepositoryToken(AttendanceSession), useValue: {} }, { provide: getRepositoryToken(AttendanceDevice), useValue: mockAttendanceDeviceRepo }, + { provide: getRepositoryToken(AttendancePeriodConfig), useValue: {} }, { provide: getDataSourceToken(), useValue: { transaction: jest.fn() } }, ], }).compile(); @@ -140,6 +142,7 @@ describe('AttendanceService — teacher DingTalk class scope', () => { {} as never, {} as never, {} as never, + {} as never, ); beforeEach(() => { @@ -216,6 +219,7 @@ describe('AttendanceService — DingTalk raw query', () => { {} as never, {} as never, {} as never, + {} as never, ); await expect( @@ -280,6 +284,7 @@ describe('AttendanceService — attendance device display mappings', () => { {} as never, attendanceDeviceRepo as never, {} as never, + {} as never, ); return { service, qb, attendanceDeviceRepo }; } @@ -389,6 +394,7 @@ describe('AttendanceService — session serialization', () => { {} as never, {} as never, { find: jest.fn().mockResolvedValue([]) } as never, + {} as never, dataSourceMock as never, ); } diff --git a/apps/server/src/attendance/attendance.service.ts b/apps/server/src/attendance/attendance.service.ts index a689aa7..3218b81 100644 --- a/apps/server/src/attendance/attendance.service.ts +++ b/apps/server/src/attendance/attendance.service.ts @@ -12,6 +12,7 @@ import { ClassSchedule, ClassStudent, ClassTeacher, + TeacherRoleType, ScheduleType, StudentDingMapping, } from '../entities'; @@ -970,8 +971,12 @@ export class AttendanceService { async getScheduleOptionsForAttendance(classId: number, date: string) { const weekDay = this.getWeekDayForDate(date); - return this.scheduleRepo + const { entities, raw } = await this.scheduleRepo .createQueryBuilder('cs') + .leftJoin('cs.teacher', 'teacher') + .addSelect('cs.id', 'scheduleIdForTeacherMap') + .addSelect('teacher.username', 'teacherUsername') + .addSelect('teacher.name', 'teacherName') .where('cs.classId = :classId', { classId }) .andWhere('cs.weekDay = :weekDay', { weekDay }) .andWhere('cs.startDate <= :date', { date }) @@ -979,7 +984,25 @@ export class AttendanceService { .andWhere('cs.status = :status', { status: 'active' }) .orderBy('cs.startTime', 'ASC') .addOrderBy('cs.subject', 'ASC') - .getMany(); + .getRawAndEntities(); + + const teacherByScheduleId = new Map( + raw.map((row) => [ + Number(row.scheduleIdForTeacherMap), + { + teacherName: row.teacherName || null, + teacherUsername: row.teacherUsername || null, + }, + ]), + ); + + return entities.map((schedule, index) => { + const teacher = teacherByScheduleId.get(schedule.id) ?? { + teacherName: raw[index]?.teacherName || null, + teacherUsername: raw[index]?.teacherUsername || null, + }; + return { ...schedule, ...teacher }; + }); } private async buildCalendar(classId: number, weekStart: string) { @@ -1095,9 +1118,51 @@ export class AttendanceService { if (classIds.length === 0) return []; const where = { id: In(classIds) }; - const classes = await this.classRepo.find({ where }); + const [classes, teachers] = await Promise.all([ + this.classRepo.find({ where }), + this.classTeacherRepo.find({ + where: { + classId: In(classIds), + roleType: In([ + TeacherRoleType.HEAD_TEACHER, + TeacherRoleType.LIFE_TEACHER, + TeacherRoleType.SUBJECT_TEACHER, + ]), + }, + relations: ['user'], + order: { roleType: 'ASC', id: 'ASC' }, + }), + ]); const nameMap = new Map(classes.map((c) => [c.id, c.name])); - return classIds.map((id) => ({ classId: id, className: nameMap.get(id) || `班级${id}` })); + const teacherMap = new Map< + number, + Array<{ + userId: number; + username: string | null; + name: string | null; + roleType: string; + subject: string | null; + }> + >(); + + for (const teacher of teachers) { + const user = teacher.user as { username?: string | null; name?: string | null } | undefined; + const items = teacherMap.get(teacher.classId) ?? []; + items.push({ + userId: teacher.userId, + username: user?.username || null, + name: user?.name || null, + roleType: teacher.roleType, + subject: teacher.subject || null, + }); + teacherMap.set(teacher.classId, items); + } + + return classIds.map((id) => ({ + classId: id, + className: nameMap.get(id) || `班级${id}`, + teachers: teacherMap.get(id) ?? [], + })); } // ── DingAttendance raw records ── diff --git a/apps/server/src/attendance/dto/attendance.dto.ts b/apps/server/src/attendance/dto/attendance.dto.ts index c07dce0..cf55fec 100644 --- a/apps/server/src/attendance/dto/attendance.dto.ts +++ b/apps/server/src/attendance/dto/attendance.dto.ts @@ -64,6 +64,7 @@ export class AttendanceRecordItem { attendanceDate: string; @IsString() + @IsIn(['morning_reading', 'morning', 'afternoon', 'evening_study', 'night_check']) @IsNotEmpty() session: string; diff --git a/apps/server/src/occupancies/occupancies.controller.ts b/apps/server/src/occupancies/occupancies.controller.ts index b32b420..b2746ee 100644 --- a/apps/server/src/occupancies/occupancies.controller.ts +++ b/apps/server/src/occupancies/occupancies.controller.ts @@ -12,6 +12,7 @@ import { Res, UseInterceptors, UploadedFile, + BadRequestException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; @@ -206,7 +207,8 @@ export class OccupanciesController { { header: '学生姓名', key: 'studentName', width: 12 }, { header: '性别', key: 'gender', width: 8 }, { header: '电话', key: 'phone', width: 18 }, - { header: '学号/身份证', key: 'idNumber', width: 22 }, + { header: '学号', key: 'studentNo', width: 15 }, + { header: '身份证号', key: 'idNumber', width: 22 }, { header: '负责人/班主任', key: 'supervisor', width: 15 }, { header: '入住日期', key: 'checkInDate', width: 14 }, { header: '退宿日期', key: 'checkOutDate', width: 14 }, @@ -227,6 +229,7 @@ export class OccupanciesController { studentName: r.student?.name || '', gender: r.student?.gender || '', phone: r.student?.phone || '', + studentNo: r.student?.studentNo || '', idNumber: r.student?.idNumber || '', supervisor: r.student?.supervisor || '', checkInDate: r.checkInDate || '', @@ -270,13 +273,14 @@ export class OccupanciesController { @Query('depositAmount') depositAmount?: string, ) { const { ipAddress, userAgent } = extractRequestInfo(req); + if (!file?.buffer) throw new BadRequestException('请上传入住名单 Excel 文件'); const workbook = new ExcelJS.Workbook(); await workbook.xlsx.load(file.buffer as any); const ws = workbook.worksheets[0]; const rows = parseOccupancyImportWorksheet(ws); const result = await this.service.batchImportCheckIn(rows, { autoDeposit: autoDeposit === 'true', - depositAmount: depositAmount ? +depositAmount : undefined, + depositAmount: depositAmount?.trim() ? Number(depositAmount) : undefined, }); await this.logService.log({ userId: req.user?.id, diff --git a/apps/server/src/occupancies/occupancies.service.spec.ts b/apps/server/src/occupancies/occupancies.service.spec.ts index 8667149..79b2e18 100644 --- a/apps/server/src/occupancies/occupancies.service.spec.ts +++ b/apps/server/src/occupancies/occupancies.service.spec.ts @@ -6,35 +6,117 @@ import { Student } from '../entities/student.entity'; import { Deposit } from '../entities/deposit.entity'; import { Bed } from '../entities/bed.entity'; import { Locker } from '../entities/locker.entity'; +import { Organization } from '../entities/organization.entity'; + +type QueryBuilderMock = { + where: jest.Mock; + andWhere: jest.Mock; + setLock: jest.Mock; + getOne: jest.Mock, []>; +}; + +function createQueryBuilderMock(result: T | null): QueryBuilderMock { + const qb = { + where: jest.fn(), + andWhere: jest.fn(), + setLock: jest.fn(), + getOne: jest.fn().mockResolvedValue(result), + } as QueryBuilderMock; + qb.where.mockReturnValue(qb); + qb.andWhere.mockReturnValue(qb); + qb.setLock.mockReturnValue(qb); + return qb; +} + +function createTransactionDataSource(manager: Record): DataSource { + return { + options: { type: 'sqlite' }, + transaction: jest.fn(async (fn: (manager: Record) => unknown) => fn(manager)), + } as any as DataSource; +} + +function createCheckInManager(options?: { + existingOccupancy?: Occupancy | null; + room?: Partial | null; + student?: Partial | null; + bed?: Partial | null; + locker?: Partial | null; + occupancyCount?: number; + deposit?: Deposit | null; +}) { + const manager = { + createQueryBuilder: jest.fn(), + count: jest.fn().mockResolvedValue(options?.occupancyCount ?? 0), + findOne: jest.fn(), + create: jest.fn((_: unknown, value: unknown) => value), + save: jest.fn(async (value: any) => ({ ...value, id: value?.id ?? 10 })), + update: jest.fn(), + }; + const queryResults = [ + options?.existingOccupancy ?? null, + options?.room ?? { id: 2, capacity: 4, status: 'available' }, + ...(options?.bed !== undefined ? [options.bed] : []), + ...(options?.locker !== undefined ? [options.locker] : []), + ]; + manager.createQueryBuilder.mockImplementation(() => + createQueryBuilderMock(queryResults.shift() ?? null), + ); + manager.findOne.mockImplementation(async (entity: unknown) => { + if (entity === Student) return options?.student ?? { id: 3, organizationId: 7 }; + if (entity === Deposit) return options?.deposit ?? null; + return null; + }); + return manager; +} + + +function createImportTransactionDataSource(repos: { + occupancyRepo: Repository; + roomRepo: Repository; + studentRepo: Repository; + depositRepo: Repository; + bedRepo: Repository; + lockerRepo: Repository; + organizationRepo: Repository; +}): DataSource { + return createTransactionDataSource({ + getRepository: jest.fn((entity: unknown) => { + if (entity === Occupancy) return repos.occupancyRepo; + if (entity === Room) return repos.roomRepo; + if (entity === Student) return repos.studentRepo; + if (entity === Deposit) return repos.depositRepo; + if (entity === Bed) return repos.bedRepo; + if (entity === Locker) return repos.lockerRepo; + if (entity === Organization) return repos.organizationRepo; + throw new Error('Unexpected repository'); + }), + }); +} + +function createCheckOutManager(occupancy: Occupancy | null) { + const manager = { + createQueryBuilder: jest.fn().mockReturnValue(createQueryBuilderMock(occupancy)), + save: jest.fn(async (value) => value), + update: jest.fn(), + }; + return manager; +} describe('OccupanciesService — responsible organization', () => { it('always takes the responsible organization from the student', async () => { - const occupancyRepo = { - findOne: jest.fn().mockResolvedValue(null), - count: jest.fn().mockResolvedValue(0), - create: jest.fn((value) => value), - save: jest.fn(async (value) => ({ ...value, id: 10 })), - } as any as Repository; - const roomRepo = { - findOne: jest.fn().mockResolvedValue({ id: 2, capacity: 4 }), - update: jest.fn(), - } as any as Repository; - const studentRepo = { - findOne: jest.fn().mockResolvedValue({ id: 3, gender: '男', organizationId: 7 }), - } as any as Repository; - + const manager = createCheckInManager({ + student: { id: 3, gender: '男', organizationId: 7 }, + bed: { id: 4, roomId: 2, status: 'available' }, + }); const service = new OccupanciesService( - occupancyRepo, - roomRepo, - studentRepo, + {} as Repository, + {} as Repository, + {} as Repository, {} as Repository, - { - findOne: jest.fn().mockResolvedValue({ id: 4, roomId: 2, status: 'available' }), - update: jest.fn(), - } as any as Repository, + {} as Repository, {} as Repository, {} as Repository, - {} as DataSource, + createTransactionDataSource(manager), ); await service.checkIn({ @@ -45,7 +127,8 @@ describe('OccupanciesService — responsible organization', () => { responsibleOrganizationId: 99, } as any); - expect(occupancyRepo.create).toHaveBeenCalledWith( + expect(manager.create).toHaveBeenCalledWith( + Occupancy, expect.objectContaining({ responsibleOrganizationId: 7 }), ); }); @@ -53,46 +136,28 @@ describe('OccupanciesService — responsible organization', () => { describe('OccupanciesService — manual check-in deposit', () => { const createService = (existingDeposit: Deposit | null = null) => { - const occupancyRepo = { - findOne: jest.fn().mockResolvedValue(null), - count: jest.fn().mockResolvedValue(0), - create: jest.fn((value) => value), - save: jest.fn(async (value) => ({ ...value, id: 10 })), - } as any as Repository; - const roomRepo = { - findOne: jest.fn().mockResolvedValue({ id: 2, capacity: 4 }), - update: jest.fn(), - } as any as Repository; - const studentRepo = { - findOne: jest.fn().mockResolvedValue({ id: 3, organizationId: 7 }), - } as any as Repository; - const depositRepo = { - findOne: jest.fn().mockResolvedValue(existingDeposit), - create: jest.fn((value) => value), - save: jest.fn(async (value) => ({ ...value, id: 20 })), - } as any as Repository; - const bedRepo = { - findOne: jest.fn().mockResolvedValue({ id: 4, roomId: 2, status: 'available' }), - update: jest.fn(), - } as any as Repository; + const manager = createCheckInManager({ + bed: { id: 4, roomId: 2, status: 'available' }, + deposit: existingDeposit, + }); return { service: new OccupanciesService( - occupancyRepo, - roomRepo, - studentRepo, - depositRepo, - bedRepo, + {} as Repository, + {} as Repository, + {} as Repository, + {} as Repository, + {} as Repository, {} as Repository, {} as Repository, - {} as DataSource, + createTransactionDataSource(manager), ), - depositRepo, + manager, }; }; it('creates a paid deposit together with manual check-in', async () => { - const { service, depositRepo } = createService(); + const { service, manager } = createService(); await service.checkIn( { @@ -106,7 +171,7 @@ describe('OccupanciesService — manual check-in deposit', () => { 11, ); - expect(depositRepo.create).toHaveBeenCalledWith({ + expect(manager.create).toHaveBeenCalledWith(Deposit, { studentId: 3, amount: 800, paidDate: '2026-07-14', @@ -114,12 +179,12 @@ describe('OccupanciesService — manual check-in deposit', () => { recordedBy: 11, notes: '入住登记自动收取', }); - expect(depositRepo.save).toHaveBeenCalledTimes(1); + expect(manager.save).toHaveBeenCalledWith(expect.objectContaining({ status: 'paid' })); }); it('adds the collected amount to the existing student deposit', async () => { const existing = { id: 99, amount: 200, status: 'refunded' } as Deposit; - const { service, depositRepo } = createService(existing); + const { service, manager } = createService(existing); await service.checkIn({ studentId: 3, @@ -130,8 +195,8 @@ describe('OccupanciesService — manual check-in deposit', () => { depositAmount: 800, }); - expect(depositRepo.create).not.toHaveBeenCalled(); - expect(depositRepo.save).toHaveBeenCalledWith( + expect(manager.create).toHaveBeenCalledTimes(1); + expect(manager.save).toHaveBeenCalledWith( expect.objectContaining({ id: 99, amount: 1000, @@ -184,7 +249,15 @@ describe('OccupanciesService — import bed capacity', () => { bedRepo, { findOne: jest.fn() } as any as Repository, organizationRepo, - {} as DataSource, + createImportTransactionDataSource({ + occupancyRepo, + roomRepo, + studentRepo, + depositRepo: { findOne: jest.fn(), create: jest.fn(), save: jest.fn() } as any as Repository, + bedRepo, + lockerRepo: { findOne: jest.fn() } as any as Repository, + organizationRepo, + }), ); const result = await service.batchImportCheckIn([ @@ -261,7 +334,15 @@ describe('OccupanciesService — import student matching', () => { bedRepo, { findOne: jest.fn() } as any as Repository, organizationRepo, - {} as DataSource, + createImportTransactionDataSource({ + occupancyRepo, + roomRepo, + studentRepo, + depositRepo: { findOne: jest.fn() } as any as Repository, + bedRepo, + lockerRepo: { findOne: jest.fn() } as any as Repository, + organizationRepo, + }), ); const result = await service.batchImportCheckIn([ @@ -295,54 +376,159 @@ describe('OccupanciesService — stay lifecycle boundaries', () => { billingStartDate: '2026-07-10', checkOutDate: null, } as Occupancy; - const occupancyRepo = { - findOne: jest.fn().mockResolvedValue(occupancy), - save: jest.fn(), - } as any as Repository; - const roomRepo = { update: jest.fn() } as any as Repository; - const bedRepo = { update: jest.fn() } as any as Repository; - const lockerRepo = { update: jest.fn() } as any as Repository; + const manager = createCheckOutManager(occupancy); const service = new OccupanciesService( - occupancyRepo, - roomRepo, - {} as Repository, - {} as Repository, - bedRepo, - lockerRepo, - {} as Repository, - {} as DataSource, - ); - - await expect(service.checkOut(1, { checkOutDate: '2026-07-09' })).rejects.toThrow( - '退宿日期不能早于入住日期', - ); - expect(occupancyRepo.save).not.toHaveBeenCalled(); - expect(bedRepo.update).not.toHaveBeenCalled(); - expect(lockerRepo.update).not.toHaveBeenCalled(); - expect(roomRepo.update).not.toHaveBeenCalled(); - }); - - it('rejects check-in to a maintenance room', async () => { - const occupancyRepo = { - findOne: jest.fn().mockResolvedValue(null), - count: jest.fn(), - } as any as Repository; - const service = new OccupanciesService( - occupancyRepo, - { - findOne: jest.fn().mockResolvedValue({ id: 2, capacity: 4, status: 'maintenance' }), - } as any, + {} as Repository, + {} as Repository, {} as Repository, {} as Repository, {} as Repository, {} as Repository, {} as Repository, - {} as DataSource, + createTransactionDataSource(manager), + ); + + await expect(service.checkOut(1, { checkOutDate: '2026-07-09' })).rejects.toThrow( + '退宿日期不能早于入住日期', + ); + expect(manager.save).not.toHaveBeenCalled(); + expect(manager.update).not.toHaveBeenCalled(); + }); + + it('rejects check-in to a maintenance room', async () => { + const manager = createCheckInManager({ + room: { id: 2, capacity: 4, status: 'maintenance' }, + bed: { id: 3, roomId: 2, status: 'available' }, + }); + const service = new OccupanciesService( + {} as Repository, + {} as Repository, + {} as Repository, + {} as Repository, + {} as Repository, + {} as Repository, + {} as Repository, + createTransactionDataSource(manager), ); await expect( service.checkIn({ studentId: 1, roomId: 2, checkInDate: '2026-07-10', bedId: 3 }), ).rejects.toThrow('该宿舍当前不可入住'); - expect(occupancyRepo.count).not.toHaveBeenCalled(); + expect(manager.count).not.toHaveBeenCalled(); + }); +}); + +describe('OccupanciesService — import deposit boundaries', () => { + const createImportService = (existingDeposit: Deposit | null = null) => { + const occupancyRepo = { + findOne: jest.fn().mockResolvedValue(null), + count: jest.fn().mockResolvedValue(0), + create: jest.fn((value) => value), + save: jest.fn(async (value) => ({ ...value, id: 10 })), + } as any as Repository; + const roomRepo = { + findOne: jest.fn().mockResolvedValue({ id: 2, roomNumber: '4-102', capacity: 4 }), + update: jest.fn(), + } as any as Repository; + const studentRepo = { + findOne: jest.fn().mockResolvedValue({ id: 3, name: '张三', organizationId: 7 }), + } as any as Repository; + const bedRepo = { + findOne: jest.fn().mockResolvedValue({ id: 4, roomId: 2, status: 'available' }), + update: jest.fn(), + } as any as Repository; + const depositRepo = { + findOne: jest.fn().mockResolvedValue(existingDeposit), + create: jest.fn((value) => value), + save: jest.fn(async (value) => ({ ...value, id: value?.id ?? 20 })), + } as any as Repository; + + return { + service: new OccupanciesService( + occupancyRepo, + roomRepo, + studentRepo, + depositRepo, + bedRepo, + { findOne: jest.fn() } as any as Repository, + {} as Repository, + createImportTransactionDataSource({ + occupancyRepo, + roomRepo, + studentRepo, + depositRepo, + bedRepo, + lockerRepo: { findOne: jest.fn() } as any as Repository, + organizationRepo: {} as Repository, + }), + ), + depositRepo, + }; + }; + + const row = { + name: '张三', + phone: '13800138000', + roomNumber: '4-102', + bedNumber: '1号床', + checkInDate: '2026-07-14', + }; + + + + it('rejects invalid import deposit amount before writing rows', async () => { + const { service, depositRepo } = createImportService(); + + await expect( + service.batchImportCheckIn([row], { autoDeposit: true, depositAmount: 0 }), + ).rejects.toThrow('押金金额必须大于0'); + expect(depositRepo.create).not.toHaveBeenCalled(); + expect(depositRepo.save).not.toHaveBeenCalled(); + }); + + it('does not collect import deposit again when the student already has paid deposit', async () => { const existing = { id: 99, amount: 500, status: 'paid' } as Deposit; + const { service, depositRepo } = createImportService(existing); + + const result = await service.batchImportCheckIn([row], { + autoDeposit: true, + depositAmount: 800, + }); + + expect(result).toEqual(expect.objectContaining({ imported: 1, depositsCreated: 0 })); + expect(depositRepo.create).not.toHaveBeenCalled(); + expect(depositRepo.save).not.toHaveBeenCalledWith(expect.objectContaining({ id: 99 })); + expect(existing.amount).toBe(500); + }); + + it('reactivates a refunded import deposit without adding the old refunded amount', async () => { + const existing = { + id: 99, + amount: 0, + status: 'refunded', + refundDate: '2026-07-01', + refundAmount: 500, + refundedBy: 11, + refundedAt: new Date('2026-07-01T00:00:00Z'), + } as Deposit; + const { service, depositRepo } = createImportService(existing); + + const result = await service.batchImportCheckIn([row], { + autoDeposit: true, + depositAmount: 800, + }); + + expect(result).toEqual(expect.objectContaining({ imported: 1, depositsCreated: 1 })); + expect(depositRepo.save).toHaveBeenCalledWith( + expect.objectContaining({ + id: 99, + amount: 800, + status: 'paid', + paidDate: '2026-07-14', + refundDate: null, + refundAmount: null, + refundedBy: null, + refundedAt: null, + }), + ); }); }); diff --git a/apps/server/src/occupancies/occupancies.service.ts b/apps/server/src/occupancies/occupancies.service.ts index cb6c339..496aebb 100644 --- a/apps/server/src/occupancies/occupancies.service.ts +++ b/apps/server/src/occupancies/occupancies.service.ts @@ -8,6 +8,8 @@ import { LessThanOrEqual, MoreThanOrEqual, In, + SelectQueryBuilder, + ObjectLiteral, } from 'typeorm'; import { Occupancy } from '../entities/occupancy.entity'; import { Room } from '../entities/room.entity'; @@ -19,6 +21,8 @@ import { Organization } from '../entities/organization.entity'; import { CheckInDto, CheckOutDto, TransferRoomDto } from './dto/occupancy.dto'; import { RoomsService } from '../rooms/rooms.service'; +class ImportRowSkipped extends Error {} + @Injectable() export class OccupanciesService { constructor( @@ -32,6 +36,16 @@ export class OccupanciesService { private dataSource: DataSource, ) {} + private withPessimisticWriteLock( + qb: SelectQueryBuilder, + ): SelectQueryBuilder { + const type = this.dataSource.options.type; + if (type === 'mysql' || type === 'mariadb' || type === 'postgres' || type === 'cockroachdb') { + return qb.setLock('pessimistic_write'); + } + return qb; + } + async findAll(query?: { roomId?: number; studentId?: number; active?: boolean }) { const qb = this.repo .createQueryBuilder('o') @@ -50,54 +64,64 @@ export class OccupanciesService { async checkIn(dto: CheckInDto, userId?: number) { this.assertDateOrder(dto.checkInDate, dto.billingStartDate, '计费起始日不能早于入住日期'); return this.dataSource.transaction(async (manager) => { - const existing = await manager.createQueryBuilder(Occupancy, 'occupancy') - .where('occupancy.studentId = :studentId', { studentId: dto.studentId }) - .andWhere('occupancy.checkOutDate IS NULL') - .setLock('pessimistic_write') - .getOne(); + const existing = await this.withPessimisticWriteLock( + manager + .createQueryBuilder(Occupancy, 'occupancy') + .where('occupancy.studentId = :studentId', { studentId: dto.studentId }) + .andWhere('occupancy.checkOutDate IS NULL'), + ).getOne(); if (existing) throw new BadRequestException('该学生已有在住记录,请先办理退宿'); - const room = await manager.createQueryBuilder(Room, 'room') - .where('room.id = :roomId', { roomId: dto.roomId }) - .setLock('pessimistic_write') - .getOne(); + const room = await this.withPessimisticWriteLock( + manager.createQueryBuilder(Room, 'room').where('room.id = :roomId', { roomId: dto.roomId }), + ).getOne(); if (!room) throw new NotFoundException('宿舍不存在'); if (room.status === 'archived' || room.status === 'maintenance') { throw new BadRequestException('该宿舍当前不可入住'); } - const count = await manager.count(Occupancy, { where: { roomId: dto.roomId, checkOutDate: IsNull() } }); + const count = await manager.count(Occupancy, { + where: { roomId: dto.roomId, checkOutDate: IsNull() }, + }); if (count >= room.capacity) throw new BadRequestException('宿舍已满'); const student = await manager.findOne(Student, { where: { id: dto.studentId } }); if (!student) throw new NotFoundException('学生不存在'); if (dto.bedId) { - const bed = await manager.createQueryBuilder(Bed, 'bed') - .where('bed.id = :bedId AND bed.roomId = :roomId', { bedId: dto.bedId, roomId: dto.roomId }) - .setLock('pessimistic_write') - .getOne(); + const bed = await this.withPessimisticWriteLock( + manager.createQueryBuilder(Bed, 'bed').where('bed.id = :bedId AND bed.roomId = :roomId', { + bedId: dto.bedId, + roomId: dto.roomId, + }), + ).getOne(); if (!bed) throw new BadRequestException('床位不存在或不属于该宿舍'); if (bed.status !== 'available') throw new BadRequestException('该床位已被占用或维修中'); } if (dto.lockerId) { - const locker = await manager.createQueryBuilder(Locker, 'locker') - .where('locker.id = :lockerId AND locker.roomId = :roomId', { lockerId: dto.lockerId, roomId: dto.roomId }) - .setLock('pessimistic_write') - .getOne(); + const locker = await this.withPessimisticWriteLock( + manager + .createQueryBuilder(Locker, 'locker') + .where('locker.id = :lockerId AND locker.roomId = :roomId', { + lockerId: dto.lockerId, + roomId: dto.roomId, + }), + ).getOne(); if (!locker) throw new BadRequestException('柜子不存在或不属于该宿舍'); if (locker.status !== 'available') throw new BadRequestException('柜子已被占用或维修中'); } - const saved = await manager.save(manager.create(Occupancy, { - studentId: dto.studentId, - roomId: dto.roomId, - checkInDate: dto.checkInDate, - billingStartDate: dto.billingStartDate || dto.checkInDate, - stayType: dto.stayType, - responsibleOrganizationId: student.organizationId, - notes: dto.notes, - bedId: dto.bedId, - lockerId: dto.lockerId, - })); + const saved = await manager.save( + manager.create(Occupancy, { + studentId: dto.studentId, + roomId: dto.roomId, + checkInDate: dto.checkInDate, + billingStartDate: dto.billingStartDate || dto.checkInDate, + stayType: dto.stayType, + responsibleOrganizationId: student.organizationId, + notes: dto.notes, + bedId: dto.bedId, + lockerId: dto.lockerId, + }), + ); if (dto.bedId) await manager.update(Bed, dto.bedId, { status: 'occupied' }); if (dto.lockerId) await manager.update(Locker, dto.lockerId, { status: 'occupied' }); if (count + 1 >= room.capacity) await manager.update(Room, room.id, { status: 'full' }); @@ -105,7 +129,9 @@ export class OccupanciesService { if (dto.collectDeposit) { let deposit = await manager.findOne(Deposit, { where: { studentId: dto.studentId } }); if (deposit) { - deposit.amount = Number((Number(deposit.amount || 0) + Number(dto.depositAmount ?? 500)).toFixed(2)); + deposit.amount = Number( + (Number(deposit.amount || 0) + Number(dto.depositAmount ?? 500)).toFixed(2), + ); deposit.status = 'paid'; deposit.paidDate = dto.checkInDate; deposit.recordedBy = userId ?? null; @@ -128,10 +154,11 @@ export class OccupanciesService { async checkOut(occupancyId: number, dto: CheckOutDto) { return this.dataSource.transaction(async (manager) => { - const occ = await manager.createQueryBuilder(Occupancy, 'occupancy') - .where('occupancy.id = :id', { id: occupancyId }) - .setLock('pessimistic_write') - .getOne(); + const occ = await this.withPessimisticWriteLock( + manager + .createQueryBuilder(Occupancy, 'occupancy') + .where('occupancy.id = :id', { id: occupancyId }), + ).getOne(); if (!occ) throw new NotFoundException('入住记录不存在'); if (occ.checkOutDate) throw new BadRequestException('该记录已退宿'); this.assertDateOrder(occ.checkInDate, dto.checkOutDate, '退宿日期不能早于入住日期'); @@ -156,10 +183,11 @@ export class OccupanciesService { await runner.connect(); await runner.startTransaction(); try { - const oldOcc = await runner.manager.createQueryBuilder(Occupancy, 'occupancy') - .where('occupancy.id = :id', { id: occupancyId }) - .setLock('pessimistic_write') - .getOne(); + const oldOcc = await this.withPessimisticWriteLock( + runner.manager + .createQueryBuilder(Occupancy, 'occupancy') + .where('occupancy.id = :id', { id: occupancyId }), + ).getOne(); if (!oldOcc) throw new NotFoundException('入住记录不存在'); if (oldOcc.checkOutDate) throw new BadRequestException('该记录已退宿'); if (oldOcc.roomId === dto.newRoomId) @@ -185,10 +213,11 @@ export class OccupanciesService { } await runner.manager.update(Room, oldOcc.roomId, { status: 'available' }); // 检查新房容量 - const newRoom = await runner.manager.createQueryBuilder(Room, 'room') - .where('room.id = :roomId', { roomId: dto.newRoomId }) - .setLock('pessimistic_write') - .getOne(); + const newRoom = await this.withPessimisticWriteLock( + runner.manager + .createQueryBuilder(Room, 'room') + .where('room.id = :roomId', { roomId: dto.newRoomId }), + ).getOne(); if (!newRoom) throw new NotFoundException('目标宿舍不存在'); if (newRoom.status === 'archived' || newRoom.status === 'maintenance') { throw new BadRequestException('目标宿舍当前不可入住'); @@ -200,18 +229,26 @@ export class OccupanciesService { // 新床位校验 if (dto.newBedId) { - const newBed = await runner.manager.createQueryBuilder(Bed, 'bed') - .where('bed.id = :bedId AND bed.roomId = :roomId', { bedId: dto.newBedId, roomId: dto.newRoomId }) - .setLock('pessimistic_write') - .getOne(); + const newBed = await this.withPessimisticWriteLock( + runner.manager + .createQueryBuilder(Bed, 'bed') + .where('bed.id = :bedId AND bed.roomId = :roomId', { + bedId: dto.newBedId, + roomId: dto.newRoomId, + }), + ).getOne(); if (!newBed) throw new BadRequestException('目标床位不存在或不属于目标宿舍'); if (newBed.status !== 'available') throw new BadRequestException('目标床位已被占用'); } if (dto.newLockerId) { - const newLocker = await runner.manager.createQueryBuilder(Locker, 'locker') - .where('locker.id = :lockerId AND locker.roomId = :roomId', { lockerId: dto.newLockerId, roomId: dto.newRoomId }) - .setLock('pessimistic_write') - .getOne(); + const newLocker = await this.withPessimisticWriteLock( + runner.manager + .createQueryBuilder(Locker, 'locker') + .where('locker.id = :lockerId AND locker.roomId = :roomId', { + lockerId: dto.newLockerId, + roomId: dto.newRoomId, + }), + ).getOne(); if (!newLocker) throw new BadRequestException('目标柜子不存在或不属于目标宿舍'); if (newLocker.status !== 'available') throw new BadRequestException('目标柜子已被占用'); } @@ -387,6 +424,7 @@ export class OccupanciesService { rows: { name: string; phone?: string; + studentNo?: string; idNumber?: string; gender?: string; ethnicity?: string; @@ -409,6 +447,9 @@ export class OccupanciesService { let skipped = 0; let depositsCreated = 0; const errors: string[] = []; + const importDepositAmount = options?.autoDeposit + ? this.normalizePositiveMoney(options.depositAmount ?? 500, '押金金额') + : undefined; for (let i = 0; i < rows.length; i++) { const row = rows[i]; @@ -420,195 +461,216 @@ export class OccupanciesService { } try { - // 1. 通过手机号关联学生;未找到时创建学生并归入本机构 - const phone = row.phone?.trim(); - if (!phone) throw new BadRequestException('手机号不能为空,无法关联学生'); + const result = await this.dataSource.transaction(async (manager) => { + const occupancyRepo = manager.getRepository(Occupancy); + const roomRepo = manager.getRepository(Room); + const studentRepo = manager.getRepository(Student); + const depositRepo = manager.getRepository(Deposit); + const bedRepo = manager.getRepository(Bed); + const lockerRepo = manager.getRepository(Locker); + const organizationRepo = manager.getRepository(Organization); + let rowDepositsCreated = 0; - let student = await this.studentRepo.findOne({ where: { phone } }); - if (!student) { - const hostOrganization = await this.organizationRepo.findOne({ - where: { isHost: true, status: 'active' }, - }); - if (!hostOrganization) throw new BadRequestException('尚未配置本机构'); + // 1. 通过手机号关联学生;未找到时创建学生并归入本机构 + const phone = row.phone?.trim(); + if (!phone) throw new BadRequestException('手机号不能为空,无法关联学生'); - student = await this.studentRepo.save( - this.studentRepo.create({ - name: row.name.trim(), - phone, - idNumber: row.idNumber?.trim() || undefined, - gender: row.gender?.trim() || undefined, - ethnicity: row.ethnicity?.trim() || undefined, - emergencyContact: row.emergencyContact?.trim() || undefined, - emergencyPhone: row.emergencyPhone?.trim() || undefined, - organizationId: hostOrganization.id, - supervisor: row.supervisor?.trim() || undefined, - }), - ); - } else { - // 更新已有学生的缺失信息 - const updates: any = {}; - if (!student.idNumber && row.idNumber?.trim()) updates.idNumber = row.idNumber.trim(); - if (!student.gender && row.gender?.trim()) updates.gender = row.gender.trim(); - if (!student.ethnicity && row.ethnicity?.trim()) updates.ethnicity = row.ethnicity.trim(); - if (!student.emergencyContact && row.emergencyContact?.trim()) - updates.emergencyContact = row.emergencyContact.trim(); - if (!student.emergencyPhone && row.emergencyPhone?.trim()) - updates.emergencyPhone = row.emergencyPhone.trim(); - if (!student.supervisor && row.supervisor?.trim()) - updates.supervisor = row.supervisor.trim(); - if (Object.keys(updates).length > 0) { - await this.studentRepo.update(student.id, updates); - Object.assign(student, updates); - } - } + let student = await studentRepo.findOne({ where: { phone } }); + if (!student) { + const hostOrganization = await organizationRepo.findOne({ + where: { isHost: true, status: 'active' }, + }); + if (!hostOrganization) throw new BadRequestException('尚未配置本机构'); - // 2. 查找或创建宿舍(使用智能解析) - let room = await this.roomRepo.findOne({ where: { roomNumber: row.roomNumber.trim() } }); - if (!room) { - const parsed = RoomsService.parseRoomNumber(row.roomNumber.trim()); - room = await this.roomRepo.save( - this.roomRepo.create({ - roomNumber: row.roomNumber.trim(), - building: row.building?.trim() || parsed.building || undefined, - floor: parsed.floor || undefined, - capacity: parsed.capacity || 4, - roomType: parsed.roomType || undefined, - }), - ); - } - - const checkInDate = row.checkInDate?.trim() || new Date().toISOString().split('T')[0]; - const checkOutDate = row.checkOutDate?.trim(); - const billingStartDate = row.billingStartDate?.trim() || checkInDate; - const isHistoricalRecord = Boolean(checkOutDate); - this.assertDateOnly(checkInDate, '入住日期'); - this.assertDateOnly(billingStartDate, '计费起始日'); - this.assertDateOrder(checkInDate, billingStartDate, '计费起始日不能早于入住日期'); - if (checkOutDate) { - this.assertDateOnly(checkOutDate, '退宿日期'); - this.assertDateOrder(checkInDate, checkOutDate, '退宿日期不能早于入住日期'); - this.assertDateOrder(billingStartDate, checkOutDate, '退宿日期不能早于计费起始日'); - } - - // 3. 检查是否已有活跃入住(历史记录不影响当前入住) - const existing = await this.repo.findOne({ - where: { studentId: student.id, checkOutDate: IsNull() }, - relations: ['room'], - }); - if (existing && !isHistoricalRecord) { - errors.push( - `第${rowNum}行: ${row.name} 已在住(${existing.room?.roomNumber || '房间' + existing.roomId}),跳过`, - ); - skipped++; - continue; - } - - // 4. 检查宿舍容量 - const count = await this.repo.count({ where: { roomId: room.id, checkOutDate: IsNull() } }); - if (!isHistoricalRecord && count >= room.capacity) { - errors.push( - `第${rowNum}行: 宿舍 ${row.roomNumber} 已满(${count}/${room.capacity}),跳过 ${row.name}`, - ); - skipped++; - continue; - } - - // 5. 匹配或创建床位、柜子,并校验是否可用 - let bed: Bed | null = null; - if (row.bedNumber?.trim()) { - const bedNumber = row.bedNumber.trim(); - bed = await this.bedRepo.findOne({ where: { roomId: room.id, bedNumber } }); - if (!bed) { - const existingBedCount = await this.bedRepo.count({ where: { roomId: room.id } }); - if (existingBedCount >= room.capacity) { - throw new BadRequestException( - `宿舍 ${room.roomNumber} 已有 ${existingBedCount} 张床位,不能超过额定人数 ${room.capacity}`, - ); - } - bed = await this.bedRepo.save( - this.bedRepo.create({ roomId: room.id, bedNumber, status: 'available' }), - ); - } - if (!isHistoricalRecord && bed.status !== 'available') { - throw new BadRequestException(`床位 ${bedNumber} 已被占用或维修中`); - } - } - - let locker: Locker | null = null; - if (row.lockerNumber?.trim()) { - const lockerNumber = row.lockerNumber.trim(); - locker = await this.lockerRepo.findOne({ where: { roomId: room.id, lockerNumber } }); - if (!locker) { - locker = await this.lockerRepo.save( - this.lockerRepo.create({ roomId: room.id, lockerNumber, status: 'available' }), - ); - } - if (!isHistoricalRecord && locker.status !== 'available') { - throw new BadRequestException(`柜子 ${lockerNumber} 已被占用或维修中`); - } - } - - // 6. 创建入住记录 - const occData: any = { - studentId: student.id, - roomId: room.id, - checkInDate, - billingStartDate, - stayType: row.stayType || undefined, - responsibleOrganizationId: student.organizationId, - notes: row.notes || undefined, - bedId: bed?.id, - lockerId: locker?.id, - }; - // 如果有退宿日期,直接记录 - if (checkOutDate) { - occData.checkOutDate = checkOutDate; - occData.billingEndDate = checkOutDate; - } - await this.repo.save(this.repo.create(occData)); - - // 7. 更新床位、柜子和宿舍状态 - if (!isHistoricalRecord) { - if (bed) await this.bedRepo.update(bed.id, { status: 'occupied' }); - if (locker) await this.lockerRepo.update(locker.id, { status: 'occupied' }); - if (count + 1 >= room.capacity) { - await this.roomRepo.update(room.id, { status: 'full' }); - } - } - - // 9. 自动收取押金(仅对新入住且非历史记录的学生) - if (options?.autoDeposit && !isHistoricalRecord) { - const existingDeposit = await this.depositRepo.findOne({ - where: { studentId: student.id }, - }); - if (existingDeposit) { - existingDeposit.amount = Number( - (Number(existingDeposit.amount || 0) + Number(options.depositAmount || 500)).toFixed( - 2, - ), - ); - existingDeposit.status = 'paid'; - existingDeposit.paidDate = checkInDate; - existingDeposit.notes = '入住导入自动收取'; - await this.depositRepo.save(existingDeposit); - depositsCreated++; - } else { - await this.depositRepo.save( - this.depositRepo.create({ - studentId: student.id, - amount: options.depositAmount || 500, - paidDate: checkInDate, - status: 'paid', - notes: '入住导入自动收取', + student = await studentRepo.save( + studentRepo.create({ + name: row.name.trim(), + phone, + studentNo: row.studentNo?.trim() || undefined, + idNumber: row.idNumber?.trim() || undefined, + gender: row.gender?.trim() || undefined, + ethnicity: row.ethnicity?.trim() || undefined, + emergencyContact: row.emergencyContact?.trim() || undefined, + emergencyPhone: row.emergencyPhone?.trim() || undefined, + organizationId: hostOrganization.id, + supervisor: row.supervisor?.trim() || undefined, }), ); - depositsCreated++; + } else { + // 更新已有学生的缺失信息 + const updates: any = {}; + if (!student.studentNo && row.studentNo?.trim()) updates.studentNo = row.studentNo.trim(); + if (!student.idNumber && row.idNumber?.trim()) updates.idNumber = row.idNumber.trim(); + if (!student.gender && row.gender?.trim()) updates.gender = row.gender.trim(); + if (!student.ethnicity && row.ethnicity?.trim()) updates.ethnicity = row.ethnicity.trim(); + if (!student.emergencyContact && row.emergencyContact?.trim()) + updates.emergencyContact = row.emergencyContact.trim(); + if (!student.emergencyPhone && row.emergencyPhone?.trim()) + updates.emergencyPhone = row.emergencyPhone.trim(); + if (!student.supervisor && row.supervisor?.trim()) + updates.supervisor = row.supervisor.trim(); + if (Object.keys(updates).length > 0) { + await studentRepo.update(student.id, updates); + Object.assign(student, updates); + } } - } + + // 2. 查找或创建宿舍(使用智能解析) + let room = await roomRepo.findOne({ where: { roomNumber: row.roomNumber.trim() } }); + if (!room) { + const parsed = RoomsService.parseRoomNumber(row.roomNumber.trim()); + room = await roomRepo.save( + roomRepo.create({ + roomNumber: row.roomNumber.trim(), + building: row.building?.trim() || parsed.building || undefined, + floor: parsed.floor || undefined, + capacity: parsed.capacity || 4, + roomType: parsed.roomType || undefined, + }), + ); + } + + const checkInDate = row.checkInDate?.trim() || new Date().toISOString().split('T')[0]; + const checkOutDate = row.checkOutDate?.trim(); + const billingStartDate = row.billingStartDate?.trim() || checkInDate; + const isHistoricalRecord = Boolean(checkOutDate); + this.assertDateOnly(checkInDate, '入住日期'); + this.assertDateOnly(billingStartDate, '计费起始日'); + this.assertDateOrder(checkInDate, billingStartDate, '计费起始日不能早于入住日期'); + if (checkOutDate) { + this.assertDateOnly(checkOutDate, '退宿日期'); + this.assertDateOrder(checkInDate, checkOutDate, '退宿日期不能早于入住日期'); + this.assertDateOrder(billingStartDate, checkOutDate, '退宿日期不能早于计费起始日'); + } + + // 3. 检查是否已有活跃入住(历史记录不影响当前入住) + const existing = await occupancyRepo.findOne({ + where: { studentId: student.id, checkOutDate: IsNull() }, + relations: ['room'], + }); + if (existing && !isHistoricalRecord) { + throw new ImportRowSkipped( + `第${rowNum}行: ${row.name} 已在住(${existing.room?.roomNumber || '房间' + existing.roomId}),跳过`, + ); + } + + // 4. 检查宿舍容量 + const count = await occupancyRepo.count({ where: { roomId: room.id, checkOutDate: IsNull() } }); + if (!isHistoricalRecord && count >= room.capacity) { + throw new ImportRowSkipped( + `第${rowNum}行: 宿舍 ${row.roomNumber} 已满(${count}/${room.capacity}),跳过 ${row.name}`, + ); + } + + // 5. 匹配或创建床位、柜子,并校验是否可用 + let bed: Bed | null = null; + if (row.bedNumber?.trim()) { + const bedNumber = row.bedNumber.trim(); + bed = await bedRepo.findOne({ where: { roomId: room.id, bedNumber } }); + if (!bed) { + const existingBedCount = await bedRepo.count({ where: { roomId: room.id } }); + if (existingBedCount >= room.capacity) { + throw new BadRequestException( + `宿舍 ${room.roomNumber} 已有 ${existingBedCount} 张床位,不能超过额定人数 ${room.capacity}`, + ); + } + bed = await bedRepo.save( + bedRepo.create({ roomId: room.id, bedNumber, status: 'available' }), + ); + } + if (!isHistoricalRecord && bed.status !== 'available') { + throw new BadRequestException(`床位 ${bedNumber} 已被占用或维修中`); + } + } + + let locker: Locker | null = null; + if (row.lockerNumber?.trim()) { + const lockerNumber = row.lockerNumber.trim(); + locker = await lockerRepo.findOne({ where: { roomId: room.id, lockerNumber } }); + if (!locker) { + locker = await lockerRepo.save( + lockerRepo.create({ roomId: room.id, lockerNumber, status: 'available' }), + ); + } + if (!isHistoricalRecord && locker.status !== 'available') { + throw new BadRequestException(`柜子 ${lockerNumber} 已被占用或维修中`); + } + } + + // 6. 创建入住记录 + const occData: any = { + studentId: student.id, + roomId: room.id, + checkInDate, + billingStartDate, + stayType: row.stayType || undefined, + responsibleOrganizationId: student.organizationId, + notes: row.notes || undefined, + bedId: bed?.id, + lockerId: locker?.id, + }; + // 如果有退宿日期,直接记录 + if (checkOutDate) { + occData.checkOutDate = checkOutDate; + occData.billingEndDate = checkOutDate; + } + await occupancyRepo.save(occupancyRepo.create(occData)); + + // 7. 更新床位、柜子和宿舍状态 + if (!isHistoricalRecord) { + if (bed) await bedRepo.update(bed.id, { status: 'occupied' }); + if (locker) await lockerRepo.update(locker.id, { status: 'occupied' }); + if (count + 1 >= room.capacity) { + await roomRepo.update(room.id, { status: 'full' }); + } + } + + // 9. 自动收取押金(仅对新入住且非历史记录的学生) + if (options?.autoDeposit && !isHistoricalRecord) { + const existingDeposit = await depositRepo.findOne({ + where: { studentId: student.id }, + }); + const depositAmount = importDepositAmount!; + const hasPaidDeposit = + existingDeposit?.status === 'paid' && Number(existingDeposit.amount || 0) > 0; + if (hasPaidDeposit) { + // 导入重试或重复导入时,已有已缴押金不重复收取。 + } else if (existingDeposit) { + existingDeposit.amount = depositAmount; + existingDeposit.status = 'paid'; + existingDeposit.paidDate = checkInDate; + existingDeposit.refundDate = null as unknown as string; + existingDeposit.refundAmount = null as unknown as number; + existingDeposit.refundedBy = null; + existingDeposit.refundedAt = null; + existingDeposit.notes = '入住导入自动收取'; + await depositRepo.save(existingDeposit); + rowDepositsCreated++; + } else { + await depositRepo.save( + depositRepo.create({ + studentId: student.id, + amount: depositAmount, + paidDate: checkInDate, + status: 'paid', + notes: '入住导入自动收取', + }), + ); + rowDepositsCreated++; + } + } + + return { depositsCreated: rowDepositsCreated }; + }); imported++; + depositsCreated += result.depositsCreated; } catch (e: any) { - errors.push(`第${rowNum}行: ${row.name} 导入失败 - ${e.message}`); + errors.push( + e instanceof ImportRowSkipped + ? e.message + : `第${rowNum}行: ${row.name} 导入失败 - ${e.message}`, + ); skipped++; } } @@ -623,6 +685,15 @@ export class OccupanciesService { }; } + private normalizePositiveMoney(value: number, label: string): number { + const amount = Number(value); + if (!Number.isFinite(amount) || Math.abs(amount * 100 - Math.round(amount * 100)) > 1e-8) { + throw new BadRequestException(`${label}最多保留两位小数`); + } + if (amount <= 0) throw new BadRequestException(`${label}必须大于0`); + return Number(amount.toFixed(2)); + } + private assertDateOnly(value: string, label: string): void { if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { throw new BadRequestException(`${label}必须为有效的 YYYY-MM-DD 日期`); diff --git a/apps/server/src/occupancies/occupancy-import-template.spec.ts b/apps/server/src/occupancies/occupancy-import-template.spec.ts index c65a5cc..ea377f0 100644 --- a/apps/server/src/occupancies/occupancy-import-template.spec.ts +++ b/apps/server/src/occupancies/occupancy-import-template.spec.ts @@ -5,7 +5,7 @@ import { } from './occupancy-import-template'; describe('occupancy import template', () => { - it('includes the current occupancy fields including bed and locker numbers', () => { + it('includes the current occupancy fields including separate student and ID numbers', () => { const workbook = createOccupancyImportTemplateWorkbook(); const ws = workbook.getWorksheet('入住名单导入模板')!; const headers = ws.getRow(1).values as unknown[]; @@ -18,11 +18,14 @@ describe('occupancy import template', () => { '柜子号', '计费起始日', '电话', + '学号', + '身份证号', '入住类型', '备注', ]), ); expect(headers).not.toContain('所属机构'); + expect(headers).not.toContain('学号/身份证'); expect(ws.columnCount).toBe(OCCUPANCY_IMPORT_COLUMNS.length); }); @@ -32,6 +35,7 @@ describe('occupancy import template', () => { const instructions = helpWs.getColumn(1).values.join('\n'); expect(instructions).toContain('按手机号关联已有学生'); + expect(instructions).toContain('学号和身份证号为两个独立字段'); expect(instructions).toContain('所属机构自动取学生档案'); expect(instructions).toContain('导入时自动收押金'); expect(instructions).toContain('历史入住不会自动收取'); @@ -40,9 +44,9 @@ describe('occupancy import template', () => { it('keeps Excel Date cells on the same local calendar day', () => { const workbook = createOccupancyImportTemplateWorkbook(); const ws = workbook.getWorksheet('入住名单导入模板')!; - ws.getCell('J2').value = new Date(2026, 3, 21); - ws.getCell('K2').value = new Date(2026, 3, 22); - ws.getCell('L2').value = new Date(2026, 3, 30); + ws.getCell('K2').value = new Date(2026, 3, 21); + ws.getCell('L2').value = new Date(2026, 3, 22); + ws.getCell('M2').value = new Date(2026, 3, 30); expect(parseOccupancyImportWorksheet(ws)[0]).toMatchObject({ checkInDate: '2026-04-21', @@ -62,6 +66,8 @@ describe('occupancy import template', () => { bedNumber: '1号床', lockerNumber: 'A01', name: '张三', + studentNo: '2024001', + idNumber: '11010120060101001X', checkInDate: '2026-04-21', billingStartDate: '2026-04-21', stayType: 'short', @@ -69,6 +75,8 @@ describe('occupancy import template', () => { expect(rows[1]).toMatchObject({ bedNumber: '2号床', lockerNumber: 'A02', + studentNo: '2024002', + idNumber: '11010120060202002X', stayType: 'long', }); }); diff --git a/apps/server/src/occupancies/occupancy-import-template.ts b/apps/server/src/occupancies/occupancy-import-template.ts index df43fe3..4fbe5ff 100644 --- a/apps/server/src/occupancies/occupancy-import-template.ts +++ b/apps/server/src/occupancies/occupancy-import-template.ts @@ -9,6 +9,7 @@ export interface OccupancyImportRow { gender?: string; ethnicity?: string; phone?: string; + studentNo?: string; idNumber?: string; checkInDate: string; billingStartDate?: string; @@ -29,7 +30,8 @@ export const OCCUPANCY_IMPORT_COLUMNS = [ { header: '性别', key: 'gender', width: 8 }, { header: '民族', key: 'ethnicity', width: 10 }, { header: '电话', key: 'phone', width: 15 }, - { header: '学号/身份证', key: 'idNumber', width: 22 }, + { header: '学号', key: 'studentNo', width: 15 }, + { header: '身份证号', key: 'idNumber', width: 22 }, { header: '入住时间', key: 'checkInDate', width: 14 }, { header: '计费起始日', key: 'billingStartDate', width: 14 }, { header: '离宿时间', key: 'checkOutDate', width: 14 }, @@ -49,7 +51,8 @@ const HEADER_ALIASES: Record = { gender: ['性别'], ethnicity: ['民族'], phone: ['电话', '手机号'], - idNumber: ['学号/身份证', '学号', '身份证号'], + studentNo: ['学号'], + idNumber: ['身份证号', '身份证'], checkInDate: ['入住时间', '入住日期'], billingStartDate: ['计费起始日', '计费开始日'], checkOutDate: ['离宿时间', '退宿时间', '退宿日期'], @@ -127,6 +130,7 @@ export function parseOccupancyImportWorksheet(ws: ExcelJS.Worksheet): OccupancyI gender: cellText(getCell(row, 'gender')) || undefined, ethnicity: cellText(getCell(row, 'ethnicity')) || undefined, phone: cellText(getCell(row, 'phone')) || undefined, + studentNo: cellText(getCell(row, 'studentNo')) || undefined, idNumber: cellText(getCell(row, 'idNumber')) || undefined, checkInDate: parseDate(getCell(row, 'checkInDate')), billingStartDate: parseDate(getCell(row, 'billingStartDate')) || undefined, @@ -162,7 +166,8 @@ export function createOccupancyImportTemplateWorkbook(): ExcelJS.Workbook { gender: '男', ethnicity: '汉族', phone: '13800138000', - idNumber: '2024001', + studentNo: '2024001', + idNumber: '11010120060101001X', checkInDate: '2026-04-21', billingStartDate: '2026-04-21', checkOutDate: '', @@ -181,7 +186,8 @@ export function createOccupancyImportTemplateWorkbook(): ExcelJS.Workbook { gender: '男', ethnicity: '汉族', phone: '13800138001', - idNumber: '2024002', + studentNo: '2024002', + idNumber: '11010120060202002X', checkInDate: '2026-04-21', billingStartDate: '2026-04-22', checkOutDate: '', @@ -210,11 +216,12 @@ export function createOccupancyImportTemplateWorkbook(): ExcelJS.Workbook { '3. 宿舍不存在时会自动创建;宿舍号可智能解析楼栋、楼层和房间类型,楼栋列可用于补充楼栋名称。', '4. 同一宿舍号的连续多行可以合并或留空,系统会继承上一行宿舍号。', '5. 入住类型可填“短租”或“长租”;计费起始日不填时默认等于入住时间。', - '6. 系统按手机号关联已有学生,入住记录的所属机构自动取学生档案;未找到时会新建学生。', - '7. 已有在住记录的学生会自动跳过,不会重复入住。', - '8. 填写离宿时间的记录会作为历史入住导入,床位和柜子不会被标记为占用。', - '9. 押金不在表格中逐行填写;请在上传前使用页面上的“导入时自动收押金”和金额设置,历史入住不会自动收取,已有已缴押金不会重复创建。', - '10. 模板中的两行示例数据仅用于说明,正式导入前请删除或替换。', + '6. 学号和身份证号为两个独立字段,请分别填写。', + '7. 系统按手机号关联已有学生,入住记录的所属机构自动取学生档案;未找到时会新建学生。', + '8. 已有在住记录的学生会自动跳过,不会重复入住。', + '9. 填写离宿时间的记录会作为历史入住导入,床位和柜子不会被标记为占用。', + '10. 押金不在表格中逐行填写;请在上传前使用页面上的“导入时自动收押金”和金额设置,历史入住不会自动收取,已有已缴押金不会重复创建。', + '11. 模板中的两行示例数据仅用于说明,正式导入前请删除或替换。', ]; instructions.forEach((instruction) => helpWs.addRow([instruction])); helpWs.getRow(1).font = { bold: true, size: 14 }; diff --git a/docker/mysql/init.sql b/docker/mysql/init.sql index 506290f..880233a 100644 --- a/docker/mysql/init.sql +++ b/docker/mysql/init.sql @@ -348,8 +348,8 @@ INSERT INTO `permissions` (`id`, `code`, `name`, `group`) VALUES ('89', 'department:delete', '删除部门', 'department'), ('90', 'ai:config:read', '查看 AI 配置', 'ai'), ('91', 'ai:config:write', '修改 AI 配置', 'ai'), -('92', 'ai:config:test', '测试 AI 连接', 'ai') -ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `group` = VALUES(`group`); +('92', 'ai:config:test', '测试 AI 连接', 'ai') AS new_values +ON DUPLICATE KEY UPDATE `name` = new_values.`name`, `group` = new_values.`group`; INSERT INTO `roles` (`id`, `name`, `code`, `description`, `is_system`, `status`) VALUES ('1', '超级管理员', 'super_admin', '系统初始化、应急维护和全局权限处理', '1', '1'), @@ -357,8 +357,8 @@ INSERT INTO `roles` (`id`, `name`, `code`, `description`, `is_system`, `status`) ('3', '教务管理员', 'academic', '管理学生、班级、教师、全局排课和历史考勤', '1', '1'), ('4', '住宿运营管理员', 'accommodation_operations', '管理宿舍、入住、住宿费用、账单、押金和退宿结算', '1', '1'), ('5', '教室运营管理员', 'classroom_operations', '管理教室、教室排期、外部机构和租赁订单', '1', '1'), -('6', '系统管理员', 'system_admin', '管理账号、角色、日志、同步和系统配置', '1', '1') -ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `description` = VALUES(`description`), `is_system` = VALUES(`is_system`), `status` = VALUES(`status`); +('6', '系统管理员', 'system_admin', '管理账号、角色、日志、同步和系统配置', '1', '1') AS new_values +ON DUPLICATE KEY UPDATE `name` = new_values.`name`, `description` = new_values.`description`, `is_system` = new_values.`is_system`, `status` = new_values.`status`; INSERT IGNORE INTO `role_permissions` (`role_id`, `permission_id`) VALUES ('1', '1'), @@ -571,8 +571,8 @@ ON DUPLICATE KEY UPDATE `username` = `username`; INSERT IGNORE INTO `user_roles` (`user_id`, `role_id`) VALUES (1, 1); INSERT INTO `organizations` (`id`, `public_id`, `code`, `name`, `is_host`, `color`, `notes`, `status`) VALUES -('1', '01900000-0000-7000-8000-000000000001', 'HOST', '本机构', '1', '#1677ff', '系统默认运营主体', 'active') -ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `is_host` = VALUES(`is_host`), `status` = VALUES(`status`); +('1', '01900000-0000-7000-8000-000000000001', 'HOST', '本机构', '1', '#1677ff', '系统默认运营主体', 'active') AS new_values +ON DUPLICATE KEY UPDATE `name` = new_values.`name`, `is_host` = new_values.`is_host`, `status` = new_values.`status`; INSERT INTO `expense_types` (`code`, `name`, `category`, `sort_order`, `enabled`) VALUES ('water', '水费', 'room', '1', '1'), @@ -583,16 +583,16 @@ INSERT INTO `expense_types` (`code`, `name`, `category`, `sort_order`, `enabled` ('key', '钥匙费', 'personal', '6', '1'), ('remote', '空调遥控器', 'personal', '7', '1'), ('deposit_deduction', '押金扣除', 'personal', '8', '1'), -('other', '其他', 'both', '99', '1') -ON DUPLICATE KEY UPDATE `name` = VALUES(`name`), `category` = VALUES(`category`), `sort_order` = VALUES(`sort_order`), `enabled` = VALUES(`enabled`); +('other', '其他', 'both', '99', '1') AS new_values +ON DUPLICATE KEY UPDATE `name` = new_values.`name`, `category` = new_values.`category`, `sort_order` = new_values.`sort_order`, `enabled` = new_values.`enabled`; INSERT INTO `ai_config` (`singleton_key`, `provider`, `enabled`, `timeout_ms`, `verified`) VALUES ('GLOBAL', 'OPENAI', '0', '30000', '0') ON DUPLICATE KEY UPDATE `singleton_key` = `singleton_key`; INSERT INTO `integration_config` (`id`, `type`, `is_sync`) VALUES -('1', 'THIRD', '0') -ON DUPLICATE KEY UPDATE `type` = VALUES(`type`); +('1', 'THIRD', '0') AS new_values +ON DUPLICATE KEY UPDATE `type` = new_values.`type`; -- 让后续 AUTO_INCREMENT 从安全位置继续。 ALTER TABLE `permissions` AUTO_INCREMENT = 1000;