test: harden business boundary conditions
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user