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;