Files
gongxue-base/apps/server/src/occupancies/occupancy-operations.service.ts
wangziqi f50301148d fix(correctness): 并发/事务/实体/时区/状态一致性修复
由 OCR(open-codereview.ai,deepseek-v4-flash)审查驱动修复:
- wallets 原子扣款防 double-spend;refund 条件更新幂等;findTransactions 分页
- financial/imports/occupancies/attendance 事务与 advisory lock;重复生成/提交幂等
- 矛盾校验器、日期区间、实体双映射/DECIMAL/nullable、时区统一(china-time)
- rbac-seed 防重激活、exam 权限恢复、状态一致性、路由顺序、N+1/IN 分块等性能项

Reviewed-by: OCR (open-codereview.ai)
2026-08-09 21:29:54 +08:00

442 lines
18 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Injectable, NotFoundException, BadRequestException, Optional } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource, IsNull, In } from 'typeorm';
import { Occupancy } from '../entities/occupancy.entity';
import { Room } from '../entities/room.entity';
import { Student } from '../entities/student.entity';
import { Bed } from '../entities/bed.entity';
import { Locker } from '../entities/locker.entity';
import dayjs from '../common/dayjs';
import { Deposit } from '../entities/deposit.entity';
import { Organization } from '../entities/organization.entity';
import { RoomInspectionDetail } from '../entities/room-inspection-detail.entity';
import { CheckOutDto, TransferRoomDto } from './dto/occupancy.dto';
import { OccupancyImportService } from './occupancy-import.service';
import { withPessimisticWriteLock } from './occupancy-lock';
@Injectable()
export class OccupancyOperationsService {
constructor(
@InjectRepository(Occupancy) private repo: Repository<Occupancy>,
@InjectRepository(Room) private roomRepo: Repository<Room>,
@InjectRepository(Student) private studentRepo: Repository<Student>,
@InjectRepository(Deposit) private depositRepo: Repository<Deposit>,
@InjectRepository(Bed) private bedRepo: Repository<Bed>,
@InjectRepository(Locker) private lockerRepo: Repository<Locker>,
@InjectRepository(Organization) private organizationRepo: Repository<Organization>,
private dataSource: DataSource,
@InjectRepository(RoomInspectionDetail)
private inspectionDetailRepo: Repository<RoomInspectionDetail>,
@Optional() private imports?: OccupancyImportService,
) {}
private get imp(): OccupancyImportService {
if (!this.imports) this.imports = new OccupancyImportService(this.dataSource);
return this.imports;
}
private normalizePositiveMoney(value: number, label: string): number {
if (!Number.isFinite(value) || value < 0) {
throw new BadRequestException(`${label}必须为非负数字`);
}
return Math.round(value * 100) / 100;
}
private assertDateOnly(value: string, label: string): void {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) {
throw new BadRequestException(`${label}格式错误,应为 YYYY-MM-DD`);
}
const date = new Date(`${value}T00:00:00Z`);
if (Number.isNaN(date.getTime())) throw new BadRequestException(`${label}不是有效日期`);
}
private assertDateOrder(start: string, end: string | undefined, message: string): void {
if (end && start > end) throw new BadRequestException(message);
}
async checkOut(occupancyId: number, dto: CheckOutDto) {
return this.dataSource.transaction(async (manager) => {
const occ = await withPessimisticWriteLock(
manager
.createQueryBuilder(Occupancy, 'occupancy')
.where('occupancy.id = :id', { id: occupancyId }),
this.dataSource).getOne();
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;
occ.checkOutReason = dto.checkOutReason || '';
await manager.save(occ);
if (occ.bedId) await manager.update(Bed, occ.bedId, { status: 'available' });
if (occ.lockerId) await manager.update(Locker, occ.lockerId, { status: 'available' });
// 多床位房间不能因为单条退宿就置为 available
// 只有该房间不再有任何在住记录时才更新房间状态。
const remainingActive = await manager.count(Occupancy, {
where: { roomId: occ.roomId, checkOutDate: IsNull() },
});
if (remainingActive === 0) {
await manager.update(Room, occ.roomId, { status: 'available' });
}
return occ;
});
}
async transferRoom(occupancyId: number, dto: TransferRoomDto) {
const runner = this.dataSource.createQueryRunner();
await runner.connect();
await runner.startTransaction();
try {
const oldOcc = await withPessimisticWriteLock(
runner.manager
.createQueryBuilder(Occupancy, 'occupancy')
.where('occupancy.id = :id', { id: occupancyId }),
this.dataSource).getOne();
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;
oldOcc.billingEndDate = dto.oldBillingEndDate || dto.transferDate;
oldOcc.checkOutReason = dto.reason || '换房';
await runner.manager.save(oldOcc);
// 释放旧床位/柜子
if (oldOcc.bedId) {
await runner.manager.update(Bed, oldOcc.bedId, { status: 'available' });
}
if (oldOcc.lockerId) {
await runner.manager.update(Locker, oldOcc.lockerId, { status: 'available' });
}
// 多床位房间不能因为单次换房就置为 available
// 只有原房间不再有任何在住记录时才更新房间状态。
const oldRoomRemaining = await runner.manager.count(Occupancy, {
where: { roomId: oldOcc.roomId, checkOutDate: IsNull() },
});
if (oldRoomRemaining === 0) {
await runner.manager.update(Room, oldOcc.roomId, { status: 'available' });
}
// 检查新房容量
const newRoom = await withPessimisticWriteLock(
runner.manager
.createQueryBuilder(Room, 'room')
.where('room.id = :roomId', { roomId: dto.newRoomId }),
this.dataSource).getOne();
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() },
});
if (count >= (newRoom.capacity ?? 0)) throw new BadRequestException('目标宿舍已满');
// 新床位校验
if (dto.newBedId) {
const newBed = await withPessimisticWriteLock(
runner.manager
.createQueryBuilder(Bed, 'bed')
.where('bed.id = :bedId AND bed.roomId = :roomId', {
bedId: dto.newBedId,
roomId: dto.newRoomId,
}),
this.dataSource).getOne();
if (!newBed) throw new BadRequestException('目标床位不存在或不属于目标宿舍');
if (newBed.status !== 'available') throw new BadRequestException('目标床位已被占用');
}
if (dto.newLockerId) {
const newLocker = await withPessimisticWriteLock(
runner.manager
.createQueryBuilder(Locker, 'locker')
.where('locker.id = :lockerId AND locker.roomId = :roomId', {
lockerId: dto.newLockerId,
roomId: dto.newRoomId,
}),
this.dataSource).getOne();
if (!newLocker) throw new BadRequestException('目标柜子不存在或不属于目标宿舍');
if (newLocker.status !== 'available') throw new BadRequestException('目标柜子已被占用');
}
// 计算新房计费起始日:默认为换房日期次日
const transferDate = new Date(dto.transferDate);
const nextDay = new Date(transferDate);
nextDay.setDate(nextDay.getDate() + 1);
const defaultBillingStart = dayjs(nextDay).utcOffset(8).format('YYYY-MM-DD');
this.assertDateOrder(
dto.transferDate,
dto.newBillingStartDate || defaultBillingStart,
'新宿舍计费起始日不能早于换房日期',
);
// 入住新房
const newOcc = runner.manager.create(Occupancy, {
studentId: oldOcc.studentId,
roomId: dto.newRoomId,
checkInDate: dto.transferDate,
billingStartDate: dto.newBillingStartDate || defaultBillingStart,
stayType: oldOcc.stayType,
responsibleOrganizationId: oldOcc.responsibleOrganizationId,
notes: `${oldOcc.roomId}号房换入`,
bedId: dto.newBedId,
lockerId: dto.newLockerId,
});
await runner.manager.save(newOcc);
// 更新新床位/柜子状态
if (dto.newBedId) {
await runner.manager.update(Bed, dto.newBedId, { status: 'occupied' });
}
if (dto.newLockerId) {
await runner.manager.update(Locker, dto.newLockerId, { status: 'occupied' });
}
if (count + 1 >= (newRoom.capacity ?? 0)) {
await runner.manager.update(Room, newRoom.id, { status: 'full' });
}
await runner.commitTransaction();
return { oldOccupancy: oldOcc, newOccupancy: newOcc };
} catch (err) {
await runner.rollbackTransaction();
throw err;
} finally {
await runner.release();
}
}
// 获取某宿舍在指定时间段内的入住记录(用于计费)
async getRoomOccupanciesInPeriod(roomId: number, periodStart: string, periodEnd: string) {
return this.repo
.createQueryBuilder('o')
.leftJoinAndSelect('o.student', 'student')
.where('o.roomId = :roomId', { roomId })
.andWhere('o.billingStartDate <= :periodEnd', { periodEnd })
.andWhere('(o.billingEndDate IS NULL OR o.billingEndDate >= :periodStart)', { periodStart })
.getMany();
}
async remove(id: number) {
const occ = await this.repo.findOne({ where: { id } });
if (!occ) throw new NotFoundException('入住记录不存在');
if (!occ.checkOutDate) throw new BadRequestException('在住记录不能归档,请先办理退宿');
if (occ.status === 'archived') throw new BadRequestException('入住记录已归档');
await this.repo.update(id, { status: 'archived' });
return { message: '已归档' };
}
async batchRemove(ids: number[]) {
if (!ids || ids.length === 0) throw new BadRequestException('请选择要归档的记录');
const records = await this.repo.find({ where: { id: In(ids) }, relations: ['student'] });
const skipped: string[] = [];
const deletableIds: number[] = [];
for (const occ of records) {
if (!occ.checkOutDate) {
skipped.push(occ.student?.name || `记录${occ.id}`);
} else {
deletableIds.push(occ.id);
}
}
let archived = 0;
if (deletableIds.length > 0) {
const result = await this.repo
.createQueryBuilder()
.update()
.set({ status: 'archived' })
.where('id IN (:...ids)', { ids: deletableIds })
.execute();
archived = result.affected || 0;
}
const message =
skipped.length > 0
? `成功归档 ${archived} 条;${skipped.length} 条在住记录被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''}),请先办理退宿`
: `批量归档成功,共 ${archived}`;
return { message, archived, skipped: skipped.length };
}
async batchRestore(ids: number[]) {
const uniqueIds = [...new Set(ids || [])];
if (uniqueIds.length === 0) throw new BadRequestException('请选择要恢复的记录');
if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) {
throw new BadRequestException('入住记录 ID 无效');
}
const records = await this.repo.find({ where: { id: In(uniqueIds) } });
if (records.length !== uniqueIds.length) throw new NotFoundException('部分入住记录不存在');
if (records.some((record) => record.status === 'archived' && !record.checkOutDate)) {
throw new BadRequestException('选中记录包含未退宿的异常归档记录');
}
const targetIds = records
.filter((record) => record.status === 'archived')
.map((record) => record.id);
const skipped = records.length - targetIds.length;
let restored = 0;
if (targetIds.length > 0) {
const result = await this.repo
.createQueryBuilder()
.update()
.set({ status: 'active' })
.where('id IN (:...ids)', { ids: targetIds })
.execute();
restored = result.affected || 0;
}
return { message: `已批量恢复 ${restored} 条入住记录`, restored, skipped };
}
async purge(id: number) {
const occ = await this.repo.findOne({ where: { id } });
if (!occ) throw new NotFoundException('入住记录不存在');
if (occ.status !== 'archived')
throw new BadRequestException('仅已归档入住记录可以永久删除,请先归档');
const detailCount = await this.inspectionDetailRepo.count({ where: { occupancyId: id } });
if (detailCount > 0) throw new BadRequestException('该入住记录已被查寝记录引用,无法永久删除');
await this.repo.delete(id);
return { message: '已永久删除入住记录(不可恢复)' };
}
async batchPurge(ids: number[]) {
const uniqueIds = [...new Set(ids || [])];
if (uniqueIds.length === 0) throw new BadRequestException('请选择要永久删除的入住记录');
if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) {
throw new BadRequestException('入住记录 ID 无效');
}
const records = await this.repo.find({ where: { id: In(uniqueIds) }, relations: ['student'] });
if (records.length !== uniqueIds.length) throw new NotFoundException('部分入住记录不存在');
const deleted: number[] = [];
const skipped: string[] = [];
for (const occ of records) {
if (occ.status !== 'archived') {
skipped.push(`${occ.student?.name || `记录${occ.id}`}(未归档)`);
continue;
}
const detailCount = await this.inspectionDetailRepo.count({ where: { occupancyId: occ.id } });
if (detailCount > 0) {
skipped.push(`${occ.student?.name || `记录${occ.id}`}(存在关联数据)`);
continue;
}
await this.repo.delete(occ.id);
deleted.push(occ.id);
}
const message =
skipped.length > 0
? `已永久删除 ${deleted.length} 条;${skipped.length} 条被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''}`
: `已永久删除 ${deleted.length} 条入住记录(不可恢复)`;
return { message, deleted: deleted.length, skipped: skipped.length };
}
async batchCheckOut(dto: {
ids: number[];
checkOutDate: string;
billingEndDate?: string;
checkOutReason?: string;
}) {
if (!dto.ids || dto.ids.length === 0) {
throw new BadRequestException('请选择要退宿的记录');
}
const runner = this.dataSource.createQueryRunner();
await runner.connect();
await runner.startTransaction();
let success = 0;
const errors: string[] = [];
try {
for (const id of dto.ids) {
const occ = await runner.manager.findOne(Occupancy, {
where: { id },
relations: ['student'],
});
if (!occ) {
errors.push(`记录${id}不存在`);
continue;
}
if (occ.checkOutDate) {
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 || '';
await runner.manager.save(occ);
// 释放床位/柜子
if (occ.bedId) await runner.manager.update(Bed, occ.bedId, { status: 'available' });
if (occ.lockerId)
await runner.manager.update(Locker, occ.lockerId, { status: 'available' });
// 多床位房间不能因为单条退宿就置为 available
// 只有该房间不再有任何在住记录时才更新房间状态。
const remainingActive = await runner.manager.count(Occupancy, {
where: { roomId: occ.roomId, checkOutDate: IsNull() },
});
if (remainingActive === 0) {
await runner.manager.update(Room, occ.roomId, { status: 'available' });
}
success++;
}
await runner.commitTransaction();
} catch (err) {
await runner.rollbackTransaction();
throw err;
} finally {
await runner.release();
}
return {
success,
failed: errors.length,
message: `已成功退宿 ${success}${errors.length > 0 ? `${errors.length} 条失败` : ''}`,
errors: errors.length > 0 ? errors : undefined,
};
}
/**
* 一键导入入住名单
* 每行数据:姓名、电话、学号、房间号、楼栋、入住日期
* 自动创建不存在的学生和宿舍,并登记入住
*/
async batchImportCheckIn(
rows: {
name: string;
phone?: string;
studentNo?: string;
idNumber?: string;
gender?: string;
ethnicity?: string;
emergencyContact?: string;
emergencyPhone?: string;
supervisor?: string;
roomNumber: string;
building?: string;
checkInDate: string;
billingStartDate?: string;
checkOutDate?: string;
bedNumber?: string;
lockerNumber?: string;
stayType?: string;
notes?: string;
}[],
options?: { autoDeposit?: boolean; depositAmount?: number },
) {
return this.imp.batchImportCheckIn(rows, options);
}
}