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, @InjectRepository(Room) private roomRepo: Repository, @InjectRepository(Student) private studentRepo: Repository, @InjectRepository(Deposit) private depositRepo: Repository, @InjectRepository(Bed) private bedRepo: Repository, @InjectRepository(Locker) private lockerRepo: Repository, @InjectRepository(Organization) private organizationRepo: Repository, private dataSource: DataSource, @InjectRepository(RoomInspectionDetail) private inspectionDetailRepo: Repository, @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); } }