feat: 拆分学号/身份证字段 + 考勤教师展示 + 代码优化
- 入住导入模板:学号和身份证号拆为独立字段,前后端对齐 - 排课查询关联教师,考勤归档页展示教师姓名 - 抽查时段增加 IsIn 校验 - 抽取 withPessimisticWriteLock 去重悲观锁查询 - import 增加文件空 buffer 校验 - 测试 mock 补全,适配事务 manager - MySQL init.sql VALUES() 语法兼容修复
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 ──
|
||||
|
||||
@@ -64,6 +64,7 @@ export class AttendanceRecordItem {
|
||||
attendanceDate: string;
|
||||
|
||||
@IsString()
|
||||
@IsIn(['morning_reading', 'morning', 'afternoon', 'evening_study', 'night_check'])
|
||||
@IsNotEmpty()
|
||||
session: string;
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 日期`);
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user