test: harden business boundary conditions

This commit is contained in:
2026-07-15 00:03:55 +08:00
parent 17a5046ea0
commit b1f35f9d1a
65 changed files with 2311 additions and 293 deletions

View File

@@ -47,6 +47,8 @@ export class OccupanciesService {
}
async checkIn(dto: CheckInDto, userId?: number) {
this.assertDateOrder(dto.checkInDate, dto.billingStartDate, '计费起始日不能早于入住日期');
// 检查学生是否已有活跃入住
const existing = await this.repo.findOne({
where: { studentId: dto.studentId, checkOutDate: IsNull() },
@@ -56,6 +58,9 @@ export class OccupanciesService {
// 检查宿舍容量
const room = await this.roomRepo.findOne({ where: { id: dto.roomId } });
if (!room) throw new NotFoundException('宿舍不存在');
if (room.status === 'archived' || room.status === 'maintenance') {
throw new BadRequestException('该宿舍当前不可入住');
}
const count = await this.repo.count({ where: { roomId: dto.roomId, checkOutDate: IsNull() } });
if (count >= room.capacity) throw new BadRequestException('宿舍已满');
@@ -138,6 +143,12 @@ export class OccupanciesService {
const occ = await this.repo.findOne({ where: { id: occupancyId } });
if (!occ) throw new NotFoundException('入住记录不存在');
if (occ.checkOutDate) throw new BadRequestException('该记录已退宿');
this.assertDateOrder(occ.checkInDate, dto.checkOutDate, '退宿日期不能早于入住日期');
this.assertDateOrder(
occ.billingStartDate || occ.checkInDate,
dto.billingEndDate || dto.checkOutDate,
'计费截止日不能早于计费起始日',
);
occ.checkOutDate = dto.checkOutDate;
occ.billingEndDate = dto.billingEndDate || dto.checkOutDate;
@@ -166,6 +177,14 @@ export class OccupanciesService {
const oldOcc = await runner.manager.findOne(Occupancy, { where: { id: occupancyId } });
if (!oldOcc) throw new NotFoundException('入住记录不存在');
if (oldOcc.checkOutDate) throw new BadRequestException('该记录已退宿');
if (oldOcc.roomId === dto.newRoomId)
throw new BadRequestException('目标宿舍不能与当前宿舍相同');
this.assertDateOrder(oldOcc.checkInDate, dto.transferDate, '换房日期不能早于原入住日期');
this.assertDateOrder(
oldOcc.billingStartDate || oldOcc.checkInDate,
dto.oldBillingEndDate || dto.transferDate,
'原宿舍计费截止日不能早于计费起始日',
);
// 退旧房
oldOcc.checkOutDate = dto.transferDate;
@@ -183,6 +202,9 @@ export class OccupanciesService {
// 检查新房容量
const newRoom = await runner.manager.findOne(Room, { where: { id: dto.newRoomId } });
if (!newRoom) throw new NotFoundException('目标宿舍不存在');
if (newRoom.status === 'archived' || newRoom.status === 'maintenance') {
throw new BadRequestException('目标宿舍当前不可入住');
}
const count = await runner.manager.count(Occupancy, {
where: { roomId: dto.newRoomId, checkOutDate: IsNull() },
});
@@ -209,6 +231,11 @@ export class OccupanciesService {
const nextDay = new Date(transferDate);
nextDay.setDate(nextDay.getDate() + 1);
const defaultBillingStart = nextDay.toISOString().split('T')[0];
this.assertDateOrder(
dto.transferDate,
dto.newBillingStartDate || defaultBillingStart,
'新宿舍计费起始日不能早于换房日期',
);
// 入住新房
const newOcc = runner.manager.create(Occupancy, {
@@ -321,6 +348,17 @@ export class OccupanciesService {
errors.push(`${occ.student?.name || id}已退宿`);
continue;
}
try {
this.assertDateOrder(occ.checkInDate, dto.checkOutDate, '退宿日期不能早于入住日期');
this.assertDateOrder(
occ.billingStartDate || occ.checkInDate,
dto.billingEndDate || dto.checkOutDate,
'计费截止日不能早于计费起始日',
);
} catch (error) {
errors.push(`${occ.student?.name || id}: ${(error as BadRequestException).message}`);
continue;
}
occ.checkOutDate = dto.checkOutDate;
occ.billingEndDate = dto.billingEndDate || dto.checkOutDate;
occ.checkOutReason = dto.checkOutReason || '';
@@ -447,12 +485,25 @@ export class OccupanciesService {
);
}
// 3. 检查是否已有活跃入住
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) {
if (existing && !isHistoricalRecord) {
errors.push(
`${rowNum}行: ${row.name} 已在住(${existing.room?.roomNumber || '房间' + existing.roomId}),跳过`,
);
@@ -462,7 +513,7 @@ export class OccupanciesService {
// 4. 检查宿舍容量
const count = await this.repo.count({ where: { roomId: room.id, checkOutDate: IsNull() } });
if (count >= room.capacity) {
if (!isHistoricalRecord && count >= room.capacity) {
errors.push(
`${rowNum}行: 宿舍 ${row.roomNumber} 已满(${count}/${room.capacity}),跳过 ${row.name}`,
);
@@ -471,7 +522,6 @@ export class OccupanciesService {
}
// 5. 匹配或创建床位、柜子,并校验是否可用
const isHistoricalRecord = Boolean(row.checkOutDate?.trim());
let bed: Bed | null = null;
if (row.bedNumber?.trim()) {
const bedNumber = row.bedNumber.trim();
@@ -507,12 +557,11 @@ export class OccupanciesService {
}
// 6. 创建入住记录
const checkInDate = row.checkInDate?.trim() || new Date().toISOString().split('T')[0];
const occData: any = {
studentId: student.id,
roomId: room.id,
checkInDate,
billingStartDate: row.billingStartDate?.trim() || checkInDate,
billingStartDate,
stayType: row.stayType || undefined,
responsibleOrganizationId: student.organizationId,
notes: row.notes || undefined,
@@ -520,9 +569,9 @@ export class OccupanciesService {
lockerId: locker?.id,
};
// 如果有退宿日期,直接记录
if (row.checkOutDate?.trim()) {
occData.checkOutDate = row.checkOutDate.trim();
occData.billingEndDate = row.checkOutDate.trim();
if (checkOutDate) {
occData.checkOutDate = checkOutDate;
occData.billingEndDate = checkOutDate;
}
await this.repo.save(this.repo.create(occData));
@@ -536,13 +585,15 @@ export class OccupanciesService {
}
// 9. 自动收取押金(仅对新入住且非历史记录的学生)
if (options?.autoDeposit && !row.checkOutDate?.trim()) {
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),
(Number(existingDeposit.amount || 0) + Number(options.depositAmount || 500)).toFixed(
2,
),
);
existingDeposit.status = 'paid';
existingDeposit.paidDate = checkInDate;
@@ -579,4 +630,26 @@ export class OccupanciesService {
errors: errors.length > 0 ? errors : undefined,
};
}
private assertDateOnly(value: string, label: string): void {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) {
throw new BadRequestException(`${label}必须为有效的 YYYY-MM-DD 日期`);
}
const [year, month, day] = value.split('-').map(Number);
const date = new Date(Date.UTC(year, month - 1, day));
if (
date.getUTCFullYear() !== year ||
date.getUTCMonth() + 1 !== month ||
date.getUTCDate() !== day
) {
throw new BadRequestException(`${label}必须为有效的 YYYY-MM-DD 日期`);
}
}
private assertDateOrder(start: string, end: string | undefined, message: string): void {
this.assertDateOnly(start, '起始日期');
if (!end) return;
this.assertDateOnly(end, '结束日期');
if (end < start) throw new BadRequestException(message);
}
}