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 { Deposit } from '../entities/deposit.entity'; import { CheckInDto, CheckOutDto, TransferRoomDto } from './dto/occupancy.dto'; import { RoomsService } from '../rooms/rooms.service'; import { CampusScope } from '../common/campus-scope'; @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, private dataSource: DataSource, private readonly scope: CampusScope, ) {} async findAll(query?: { roomId?: number; studentId?: number; active?: boolean }) { const qb = this.repo .createQueryBuilder('o') .leftJoinAndSelect('o.student', 'student') .leftJoinAndSelect('o.room', 'room') .orderBy('o.checkInDate', 'DESC'); const scopeIds = await this.scope.getScopeDepartmentIds(); if (scopeIds) { qb.andWhere('o.departmentId IN (:...scopeIds)', { scopeIds }); } 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) { // 检查学生是否已有活跃入住 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('宿舍不存在'); 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 (student.gender && room.gender && student.gender !== room.gender) { throw new BadRequestException( `该宿舍当前为${room.gender}生寝室,${student.gender}生无法入住`, ); } const occ = this.repo.create({ studentId: dto.studentId, roomId: dto.roomId, checkInDate: dto.checkInDate, billingStartDate: dto.billingStartDate || dto.checkInDate, rentalType: dto.rentalType, tenantId: dto.tenantId, notes: dto.notes, }); occ.departmentId = room.departmentId; const saved = await this.repo.save(occ); // 首位入住者确定房间性别 if (student.gender && !room.gender) { await this.roomRepo.update(room.id, { gender: student.gender }); } // 更新宿舍状态 if (count + 1 >= room.capacity) { await this.roomRepo.update(room.id, { status: 'full' }); } 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('该记录已退宿'); occ.checkOutDate = dto.checkOutDate; occ.billingEndDate = dto.billingEndDate || dto.checkOutDate; occ.checkOutReason = dto.checkOutReason || ''; await this.repo.save(occ); // 更新宿舍状态 await this.roomRepo.update(occ.roomId, { status: 'available' }); // 如果房间已无在住人员,重置房间性别 const remaining = await this.repo.count({ where: { roomId: occ.roomId, checkOutDate: IsNull() }, }); if (remaining === 0) { await this.roomRepo.update(occ.roomId, { gender: null as any }); } 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('该记录已退宿'); // 退旧房 oldOcc.checkOutDate = dto.transferDate; oldOcc.billingEndDate = dto.oldBillingEndDate || dto.transferDate; oldOcc.checkOutReason = dto.reason || '换房'; await runner.manager.save(oldOcc); await runner.manager.update(Room, oldOcc.roomId, { status: 'available' }); // 旧房如果已无在住人员,重置性别 const oldRemaining = await runner.manager.count(Occupancy, { where: { roomId: oldOcc.roomId, checkOutDate: IsNull() }, }); if (oldRemaining === 0) { await runner.manager.update(Room, oldOcc.roomId, { gender: null as any }); } // 检查新房容量 const newRoom = await runner.manager.findOne(Room, { where: { id: dto.newRoomId } }); if (!newRoom) throw new NotFoundException('目标宿舍不存在'); const count = await runner.manager.count(Occupancy, { where: { roomId: dto.newRoomId, checkOutDate: IsNull() }, }); if (count >= newRoom.capacity) throw new BadRequestException('目标宿舍已满'); // 换房性别约束检查 const student = await runner.manager.findOne(Student, { where: { id: oldOcc.studentId } }); if (student?.gender && newRoom.gender && student.gender !== newRoom.gender) { throw new BadRequestException(`目标宿舍为${newRoom.gender}生寝室,无法换入`); } // 计算新房计费起始日:默认为换房日期次日 const transferDate = new Date(dto.transferDate); const nextDay = new Date(transferDate); nextDay.setDate(nextDay.getDate() + 1); const defaultBillingStart = nextDay.toISOString().split('T')[0]; // 入住新房 const newOcc = runner.manager.create(Occupancy, { studentId: oldOcc.studentId, roomId: dto.newRoomId, checkInDate: dto.transferDate, billingStartDate: dto.newBillingStartDate || defaultBillingStart, notes: `从${oldOcc.roomId}号房换入`, }); await runner.manager.save(newOcc); // 首位入住者确定新房性别 if (student?.gender && !newRoom.gender) { await runner.manager.update(Room, newRoom.id, { gender: student.gender }); } 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('在住记录不能删除,请先办理退宿'); await this.repo.delete(id); 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 deleted = 0; if (deletableIds.length > 0) { const result = await this.repo .createQueryBuilder() .delete() .where('id IN (:...ids)', { ids: deletableIds }) .execute(); deleted = result.affected || 0; } const message = skipped.length > 0 ? `成功删除 ${deleted} 条;${skipped.length} 条在住记录被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''}),请先办理退宿` : `批量删除成功,共 ${deleted} 条`; return { message, deleted, 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; } 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' }); // 如果房间已无在住人员,重置性别 const remaining = await runner.manager.count(Occupancy, { where: { roomId: occ.roomId, checkOutDate: IsNull() }, }); if (remaining === 0) { await runner.manager.update(Room, occ.roomId, { gender: null as any }); } 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; organization?: string; supervisor?: string; roomNumber: string; building?: string; checkInDate: string; checkOutDate?: 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. 查找或创建学生 let student = await this.studentRepo.findOne({ where: { name: row.name.trim() } }); if (!student) { student = await this.studentRepo.save( this.studentRepo.create({ name: row.name.trim(), phone: row.phone?.trim() || undefined, 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, organization: row.organization?.trim() || undefined, supervisor: row.supervisor?.trim() || undefined, }), ); } else { // 更新已有学生的缺失信息 const updates: any = {}; if (!student.phone && row.phone?.trim()) updates.phone = row.phone.trim(); 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.organization && row.organization?.trim()) updates.organization = row.organization.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, }), ); } // 3. 检查是否已有活跃入住 const existing = await this.repo.findOne({ where: { studentId: student.id, checkOutDate: IsNull() }, relations: ['room'], }); if (existing) { 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 (count >= room.capacity) { errors.push( `第${rowNum}行: 宿舍 ${row.roomNumber} 已满(${count}/${room.capacity}),跳过 ${row.name}`, ); skipped++; continue; } // 5. 房间级别性别约束 if (student.gender && room.gender && student.gender !== room.gender) { errors.push( `第${rowNum}行: 宿舍 ${row.roomNumber} 为${room.gender}生寝室,${row.name}(${student.gender})无法入住,跳过`, ); skipped++; continue; } // 6. 创建入住记录 const checkInDate = row.checkInDate?.trim() || new Date().toISOString().split('T')[0]; const occData: any = { studentId: student.id, roomId: room.id, checkInDate, billingStartDate: checkInDate, }; // 如果有退宿日期,直接记录 if (row.checkOutDate?.trim()) { occData.checkOutDate = row.checkOutDate.trim(); occData.billingEndDate = row.checkOutDate.trim(); } await this.repo.save(this.repo.create(occData)); // 7. 首位入住者确定房间性别 if (student.gender && !room.gender) { await this.roomRepo.update(room.id, { gender: student.gender }); room.gender = student.gender; } // 8. 更新宿舍状态 if (!row.checkOutDate?.trim() && count + 1 >= room.capacity) { await this.roomRepo.update(room.id, { status: 'full' }); } // 9. 自动收取押金(仅对新入住且非历史记录的学生) if (options?.autoDeposit && !row.checkOutDate?.trim()) { const existingDeposit = await this.depositRepo.findOne({ where: { studentId: student.id, status: 'paid' }, }); if (!existingDeposit) { 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, }; } }