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