import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, DataSource, IsNull, Between, LessThanOrEqual, MoreThanOrEqual, 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 { Deposit } from '../entities/deposit.entity'; import { Organization } from '../entities/organization.entity'; import { CheckInDto, CheckOutDto, TransferRoomDto } from './dto/occupancy.dto'; import { RoomsService } from '../rooms/rooms.service'; @Injectable() export class OccupanciesService { 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, ) {} async findAll(query?: { roomId?: number; studentId?: number; active?: boolean }) { const qb = this.repo .createQueryBuilder('o') .leftJoinAndSelect('o.student', 'student') .leftJoinAndSelect('o.room', 'room') .leftJoinAndSelect('o.bed', 'bed') .leftJoinAndSelect('o.locker', 'locker') .where('o.status = :status', { status: 'active' }) .orderBy('o.checkInDate', 'DESC'); if (query?.roomId) qb.andWhere('o.roomId = :roomId', { roomId: query.roomId }); if (query?.studentId) qb.andWhere('o.studentId = :studentId', { studentId: query.studentId }); if (query?.active) qb.andWhere('o.checkOutDate IS NULL'); return qb.getMany(); } async checkIn(dto: CheckInDto, userId?: number) { this.assertDateOrder(dto.checkInDate, dto.billingStartDate, '计费起始日不能早于入住日期'); // 检查学生是否已有活跃入住 const existing = await this.repo.findOne({ where: { studentId: dto.studentId, checkOutDate: IsNull() }, }); if (existing) throw new BadRequestException('该学生已有在住记录,请先办理退宿'); // 检查宿舍容量 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('宿舍已满'); const student = await this.studentRepo.findOne({ where: { id: dto.studentId } }); if (!student) throw new NotFoundException('学生不存在'); // 床位校验 if (dto.bedId) { const bed = await this.bedRepo.findOne({ where: { id: dto.bedId, roomId: dto.roomId } }); if (!bed) throw new BadRequestException('床位不存在或不属于该宿舍'); if (bed.status !== 'available') throw new BadRequestException('该床位已被占用或维修中'); } // 柜子校验 if (dto.lockerId) { const locker = await this.lockerRepo.findOne({ where: { id: dto.lockerId, roomId: dto.roomId }, }); if (!locker) throw new BadRequestException('柜子不存在或不属于该宿舍'); if (locker.status !== 'available') throw new BadRequestException('该柜子已被占用或维修中'); } const occ = this.repo.create({ studentId: dto.studentId, roomId: dto.roomId, checkInDate: dto.checkInDate, billingStartDate: dto.billingStartDate || dto.checkInDate, stayType: dto.stayType, responsibleOrganizationId: student.organizationId, notes: dto.notes, bedId: dto.bedId, lockerId: dto.lockerId, }); const saved = await this.repo.save(occ); // 更新床位/柜子状态 if (dto.bedId) { await this.bedRepo.update(dto.bedId, { status: 'occupied' }); } if (dto.lockerId) { await this.lockerRepo.update(dto.lockerId, { status: 'occupied' }); } // 更新宿舍状态 if (count + 1 >= room.capacity) { await this.roomRepo.update(room.id, { status: 'full' }); } if (dto.collectDeposit) { const existingDeposit = await this.depositRepo.findOne({ where: { studentId: dto.studentId }, }); if (existingDeposit) { existingDeposit.amount = Number( (Number(existingDeposit.amount || 0) + Number(dto.depositAmount ?? 500)).toFixed(2), ); existingDeposit.status = 'paid'; existingDeposit.paidDate = dto.checkInDate; existingDeposit.recordedBy = userId ?? null; existingDeposit.notes = '入住登记自动收取'; await this.depositRepo.save(existingDeposit); } else { await this.depositRepo.save( this.depositRepo.create({ studentId: dto.studentId, amount: dto.depositAmount ?? 500, paidDate: dto.checkInDate, status: 'paid', recordedBy: userId, notes: '入住登记自动收取', }), ); } } return saved; } async checkOut(occupancyId: number, dto: CheckOutDto) { 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; occ.checkOutReason = dto.checkOutReason || ''; await this.repo.save(occ); // 释放床位/柜子 if (occ.bedId) { await this.bedRepo.update(occ.bedId, { status: 'available' }); } if (occ.lockerId) { await this.lockerRepo.update(occ.lockerId, { status: 'available' }); } // 更新宿舍状态 await this.roomRepo.update(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 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; 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' }); } await runner.manager.update(Room, oldOcc.roomId, { status: 'available' }); // 检查新房容量 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() }, }); if (count >= newRoom.capacity) throw new BadRequestException('目标宿舍已满'); // 新床位校验 if (dto.newBedId) { const newBed = await runner.manager.findOne(Bed, { where: { id: dto.newBedId, roomId: dto.newRoomId }, }); if (!newBed) throw new BadRequestException('目标床位不存在或不属于目标宿舍'); if (newBed.status !== 'available') throw new BadRequestException('目标床位已被占用'); } if (dto.newLockerId) { const newLocker = await runner.manager.findOne(Locker, { where: { id: dto.newLockerId, roomId: dto.newRoomId }, }); 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 = nextDay.toISOString().split('T')[0]; 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) { 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 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); // 更新房间状态 await runner.manager.update(Room, occ.roomId, { status: 'available' }); // 释放床位/柜子 if (occ.bedId) await runner.manager.update(Bed, occ.bedId, { status: 'available' }); if (occ.lockerId) await runner.manager.update(Locker, occ.lockerId, { 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; 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 }, ) { let imported = 0; let skipped = 0; let depositsCreated = 0; const errors: string[] = []; for (let i = 0; i < rows.length; i++) { const row = rows[i]; const rowNum = i + 2; // Excel第2行开始(第1行是表头) if (!row.name?.trim() || !row.roomNumber?.trim()) { skipped++; continue; } try { // 1. 通过手机号关联学生;未找到时创建学生并归入本机构 const phone = row.phone?.trim(); if (!phone) throw new BadRequestException('手机号不能为空,无法关联学生'); let student = await this.studentRepo.findOne({ where: { phone } }); if (!student) { const hostOrganization = await this.organizationRepo.findOne({ where: { isHost: true, status: 'active' }, }); if (!hostOrganization) throw new BadRequestException('尚未配置本机构'); student = await this.studentRepo.save( this.studentRepo.create({ name: row.name.trim(), phone, idNumber: row.idNumber?.trim() || undefined, gender: row.gender?.trim() || undefined, ethnicity: row.ethnicity?.trim() || undefined, emergencyContact: row.emergencyContact?.trim() || undefined, emergencyPhone: row.emergencyPhone?.trim() || undefined, organizationId: hostOrganization.id, supervisor: row.supervisor?.trim() || undefined, }), ); } else { // 更新已有学生的缺失信息 const updates: any = {}; if (!student.idNumber && row.idNumber?.trim()) updates.idNumber = row.idNumber.trim(); if (!student.gender && row.gender?.trim()) updates.gender = row.gender.trim(); if (!student.ethnicity && row.ethnicity?.trim()) updates.ethnicity = row.ethnicity.trim(); if (!student.emergencyContact && row.emergencyContact?.trim()) updates.emergencyContact = row.emergencyContact.trim(); if (!student.emergencyPhone && row.emergencyPhone?.trim()) updates.emergencyPhone = row.emergencyPhone.trim(); if (!student.supervisor && row.supervisor?.trim()) updates.supervisor = row.supervisor.trim(); if (Object.keys(updates).length > 0) { await this.studentRepo.update(student.id, updates); Object.assign(student, updates); } } // 2. 查找或创建宿舍(使用智能解析) let room = await this.roomRepo.findOne({ where: { roomNumber: row.roomNumber.trim() } }); if (!room) { const parsed = RoomsService.parseRoomNumber(row.roomNumber.trim()); room = await this.roomRepo.save( this.roomRepo.create({ roomNumber: row.roomNumber.trim(), building: row.building?.trim() || parsed.building || undefined, floor: parsed.floor || undefined, capacity: parsed.capacity || 4, roomType: parsed.roomType || undefined, }), ); } 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 && !isHistoricalRecord) { errors.push( `第${rowNum}行: ${row.name} 已在住(${existing.room?.roomNumber || '房间' + existing.roomId}),跳过`, ); skipped++; continue; } // 4. 检查宿舍容量 const count = await this.repo.count({ where: { roomId: room.id, checkOutDate: IsNull() } }); if (!isHistoricalRecord && count >= room.capacity) { errors.push( `第${rowNum}行: 宿舍 ${row.roomNumber} 已满(${count}/${room.capacity}),跳过 ${row.name}`, ); skipped++; continue; } // 5. 匹配或创建床位、柜子,并校验是否可用 let bed: Bed | null = null; if (row.bedNumber?.trim()) { const bedNumber = row.bedNumber.trim(); bed = await this.bedRepo.findOne({ where: { roomId: room.id, bedNumber } }); if (!bed) { const existingBedCount = await this.bedRepo.count({ where: { roomId: room.id } }); if (existingBedCount >= room.capacity) { throw new BadRequestException( `宿舍 ${room.roomNumber} 已有 ${existingBedCount} 张床位,不能超过额定人数 ${room.capacity}`, ); } bed = await this.bedRepo.save( this.bedRepo.create({ roomId: room.id, bedNumber, status: 'available' }), ); } if (!isHistoricalRecord && bed.status !== 'available') { throw new BadRequestException(`床位 ${bedNumber} 已被占用或维修中`); } } let locker: Locker | null = null; if (row.lockerNumber?.trim()) { const lockerNumber = row.lockerNumber.trim(); locker = await this.lockerRepo.findOne({ where: { roomId: room.id, lockerNumber } }); if (!locker) { locker = await this.lockerRepo.save( this.lockerRepo.create({ roomId: room.id, lockerNumber, status: 'available' }), ); } if (!isHistoricalRecord && locker.status !== 'available') { throw new BadRequestException(`柜子 ${lockerNumber} 已被占用或维修中`); } } // 6. 创建入住记录 const occData: any = { studentId: student.id, roomId: room.id, checkInDate, billingStartDate, stayType: row.stayType || undefined, responsibleOrganizationId: student.organizationId, notes: row.notes || undefined, bedId: bed?.id, lockerId: locker?.id, }; // 如果有退宿日期,直接记录 if (checkOutDate) { occData.checkOutDate = checkOutDate; occData.billingEndDate = checkOutDate; } await this.repo.save(this.repo.create(occData)); // 7. 更新床位、柜子和宿舍状态 if (!isHistoricalRecord) { if (bed) await this.bedRepo.update(bed.id, { status: 'occupied' }); if (locker) await this.lockerRepo.update(locker.id, { status: 'occupied' }); if (count + 1 >= room.capacity) { await this.roomRepo.update(room.id, { status: 'full' }); } } // 9. 自动收取押金(仅对新入住且非历史记录的学生) 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, ), ); existingDeposit.status = 'paid'; existingDeposit.paidDate = checkInDate; existingDeposit.notes = '入住导入自动收取'; await this.depositRepo.save(existingDeposit); depositsCreated++; } else { await this.depositRepo.save( this.depositRepo.create({ studentId: student.id, amount: options.depositAmount || 500, paidDate: checkInDate, status: 'paid', notes: '入住导入自动收取', }), ); depositsCreated++; } } imported++; } catch (e: any) { errors.push(`第${rowNum}行: ${row.name} 导入失败 - ${e.message}`); skipped++; } } const depositMsg = depositsCreated > 0 ? `,自动收取 ${depositsCreated} 笔押金` : ''; return { message: `成功导入 ${imported} 条入住记录,跳过 ${skipped} 条${depositMsg}`, imported, skipped, depositsCreated, 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); } }