feat: 拆分学号/身份证字段 + 考勤教师展示 + 代码优化

- 入住导入模板:学号和身份证号拆为独立字段,前后端对齐
- 排课查询关联教师,考勤归档页展示教师姓名
- 抽查时段增加 IsIn 校验
- 抽取 withPessimisticWriteLock 去重悲观锁查询
- import 增加文件空 buffer 校验
- 测试 mock 补全,适配事务 manager
- MySQL init.sql VALUES() 语法兼容修复
This commit is contained in:
2026-07-20 11:54:01 +08:00
parent 375c7ec60b
commit c7ba2799b9
12 changed files with 772 additions and 365 deletions

View File

@@ -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,

View File

@@ -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 }) =>
<Avatar></Avatar>
<div>
<span></span>
<strong></strong>
<strong>{headTeacherNames}</strong>
</div>
</div>
<div className="student-teacher-item is-muted">
<div className="student-teacher-item">
<Avatar></Avatar>
<div>
<span></span>
<strong></strong>
<strong>{lifeTeacherNames}</strong>
</div>
</div>
<div className="student-teacher-item">
<Avatar></Avatar>
<div>
<span></span>
<strong>{session ? sessionMap[session] : '全部时段'}</strong>
<span></span>
<strong>{subjectTeacherNames}</strong>
</div>
</div>
</div>

View File

@@ -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 };

View File

@@ -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,
);
}

View File

@@ -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 ──

View File

@@ -64,6 +64,7 @@ export class AttendanceRecordItem {
attendanceDate: string;
@IsString()
@IsIn(['morning_reading', 'morning', 'afternoon', 'evening_study', 'night_check'])
@IsNotEmpty()
session: string;

View File

@@ -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,

View File

@@ -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<T> = {
where: jest.Mock;
andWhere: jest.Mock;
setLock: jest.Mock;
getOne: jest.Mock<Promise<T | null>, []>;
};
function createQueryBuilderMock<T>(result: T | null): QueryBuilderMock<T> {
const qb = {
where: jest.fn(),
andWhere: jest.fn(),
setLock: jest.fn(),
getOne: jest.fn().mockResolvedValue(result),
} as QueryBuilderMock<T>;
qb.where.mockReturnValue(qb);
qb.andWhere.mockReturnValue(qb);
qb.setLock.mockReturnValue(qb);
return qb;
}
function createTransactionDataSource(manager: Record<string, unknown>): DataSource {
return {
options: { type: 'sqlite' },
transaction: jest.fn(async (fn: (manager: Record<string, unknown>) => unknown) => fn(manager)),
} as any as DataSource;
}
function createCheckInManager(options?: {
existingOccupancy?: Occupancy | null;
room?: Partial<Room> | null;
student?: Partial<Student> | null;
bed?: Partial<Bed> | null;
locker?: Partial<Locker> | 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<Occupancy>;
roomRepo: Repository<Room>;
studentRepo: Repository<Student>;
depositRepo: Repository<Deposit>;
bedRepo: Repository<Bed>;
lockerRepo: Repository<Locker>;
organizationRepo: Repository<any>;
}): 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<Occupancy>;
const roomRepo = {
findOne: jest.fn().mockResolvedValue({ id: 2, capacity: 4 }),
update: jest.fn(),
} as any as Repository<Room>;
const studentRepo = {
findOne: jest.fn().mockResolvedValue({ id: 3, gender: '男', organizationId: 7 }),
} as any as Repository<Student>;
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<Occupancy>,
{} as Repository<Room>,
{} as Repository<Student>,
{} as Repository<Deposit>,
{
findOne: jest.fn().mockResolvedValue({ id: 4, roomId: 2, status: 'available' }),
update: jest.fn(),
} as any as Repository<Bed>,
{} as Repository<Bed>,
{} as Repository<Locker>,
{} as Repository<any>,
{} 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<Occupancy>;
const roomRepo = {
findOne: jest.fn().mockResolvedValue({ id: 2, capacity: 4 }),
update: jest.fn(),
} as any as Repository<Room>;
const studentRepo = {
findOne: jest.fn().mockResolvedValue({ id: 3, organizationId: 7 }),
} as any as Repository<Student>;
const depositRepo = {
findOne: jest.fn().mockResolvedValue(existingDeposit),
create: jest.fn((value) => value),
save: jest.fn(async (value) => ({ ...value, id: 20 })),
} as any as Repository<Deposit>;
const bedRepo = {
findOne: jest.fn().mockResolvedValue({ id: 4, roomId: 2, status: 'available' }),
update: jest.fn(),
} as any as Repository<Bed>;
const manager = createCheckInManager({
bed: { id: 4, roomId: 2, status: 'available' },
deposit: existingDeposit,
});
return {
service: new OccupanciesService(
occupancyRepo,
roomRepo,
studentRepo,
depositRepo,
bedRepo,
{} as Repository<Occupancy>,
{} as Repository<Room>,
{} as Repository<Student>,
{} as Repository<Deposit>,
{} as Repository<Bed>,
{} as Repository<Locker>,
{} as Repository<any>,
{} 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<Locker>,
organizationRepo,
{} as DataSource,
createImportTransactionDataSource({
occupancyRepo,
roomRepo,
studentRepo,
depositRepo: { findOne: jest.fn(), create: jest.fn(), save: jest.fn() } as any as Repository<Deposit>,
bedRepo,
lockerRepo: { findOne: jest.fn() } as any as Repository<Locker>,
organizationRepo,
}),
);
const result = await service.batchImportCheckIn([
@@ -261,7 +334,15 @@ describe('OccupanciesService — import student matching', () => {
bedRepo,
{ findOne: jest.fn() } as any as Repository<Locker>,
organizationRepo,
{} as DataSource,
createImportTransactionDataSource({
occupancyRepo,
roomRepo,
studentRepo,
depositRepo: { findOne: jest.fn() } as any as Repository<Deposit>,
bedRepo,
lockerRepo: { findOne: jest.fn() } as any as Repository<Locker>,
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<Occupancy>;
const roomRepo = { update: jest.fn() } as any as Repository<Room>;
const bedRepo = { update: jest.fn() } as any as Repository<Bed>;
const lockerRepo = { update: jest.fn() } as any as Repository<Locker>;
const manager = createCheckOutManager(occupancy);
const service = new OccupanciesService(
occupancyRepo,
roomRepo,
{} as Repository<Student>,
{} as Repository<Deposit>,
bedRepo,
lockerRepo,
{} as Repository<any>,
{} 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<Occupancy>;
const service = new OccupanciesService(
occupancyRepo,
{
findOne: jest.fn().mockResolvedValue({ id: 2, capacity: 4, status: 'maintenance' }),
} as any,
{} as Repository<Occupancy>,
{} as Repository<Room>,
{} as Repository<Student>,
{} as Repository<Deposit>,
{} as Repository<Bed>,
{} as Repository<Locker>,
{} as Repository<any>,
{} 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<Occupancy>,
{} as Repository<Room>,
{} as Repository<Student>,
{} as Repository<Deposit>,
{} as Repository<Bed>,
{} as Repository<Locker>,
{} as Repository<any>,
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<Occupancy>;
const roomRepo = {
findOne: jest.fn().mockResolvedValue({ id: 2, roomNumber: '4-102', capacity: 4 }),
update: jest.fn(),
} as any as Repository<Room>;
const studentRepo = {
findOne: jest.fn().mockResolvedValue({ id: 3, name: '张三', organizationId: 7 }),
} as any as Repository<Student>;
const bedRepo = {
findOne: jest.fn().mockResolvedValue({ id: 4, roomId: 2, status: 'available' }),
update: jest.fn(),
} as any as Repository<Bed>;
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<Deposit>;
return {
service: new OccupanciesService(
occupancyRepo,
roomRepo,
studentRepo,
depositRepo,
bedRepo,
{ findOne: jest.fn() } as any as Repository<Locker>,
{} as Repository<any>,
createImportTransactionDataSource({
occupancyRepo,
roomRepo,
studentRepo,
depositRepo,
bedRepo,
lockerRepo: { findOne: jest.fn() } as any as Repository<Locker>,
organizationRepo: {} as Repository<any>,
}),
),
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,
}),
);
});
});

View File

@@ -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<T extends ObjectLiteral>(
qb: SelectQueryBuilder<T>,
): SelectQueryBuilder<T> {
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 日期`);

View File

@@ -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',
});
});

View File

@@ -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<keyof OccupancyImportRow, string[]> = {
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 };

View File

@@ -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;