import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { DataSource, Repository, Like, IsNull, Not, In, LessThanOrEqual, MoreThanOrEqual, } from 'typeorm'; import { Room } from '../entities/room.entity'; import { Occupancy } from '../entities/occupancy.entity'; import { RoomExpense } from '../entities/room-expense.entity'; import { Bed } from '../entities/bed.entity'; import { Locker } from '../entities/locker.entity'; import { CreateRoomDto, UpdateRoomDto } from './dto/room.dto'; import { CreateBedDto, UpdateBedDto, BatchCreateBedDto } from './dto/bed.dto'; import { CreateLockerDto, UpdateLockerDto, BatchCreateLockerDto } from './dto/locker.dto'; @Injectable() export class RoomsService { constructor( @InjectRepository(Room) private repo: Repository, @InjectRepository(Occupancy) private occRepo: Repository, @InjectRepository(RoomExpense) private roomExpRepo: Repository, @InjectRepository(Bed) private bedRepo: Repository, @InjectRepository(Locker) private lockerRepo: Repository, private dataSource: DataSource, ) {} /** * 智能解析房间号,自动推导楼栋、楼层、宿舍类型 * "4-102" → building:"4号楼", floor:1, roomType:"四人间" * "1-2-101" → building:"1-2栋", floor:1, roomType:"家庭房" * "3-301" → building:"3号楼", floor:3, roomType:"四人间" * "8-102" → building:"8号楼", floor:1, roomType:"爆改房" */ static parseRoomNumber(roomNumber: string): { building?: string; floor?: number; roomType?: string; capacity?: number; } { const cleaned = roomNumber.replace(/[((].*?[))]/g, '').trim(); // 家庭房: X-Y-ZZZ 格式 const familyMatch = cleaned.match(/^(\d+)-(\d+)-(\d+)$/); if (familyMatch) { const bldg = `${familyMatch[1]}-${familyMatch[2]}栋`; const roomPart = familyMatch[3]; const rawFloor = parseInt(roomPart.charAt(0), 10); const floor = Number.isNaN(rawFloor) ? undefined : rawFloor; return { building: bldg, floor, roomType: '家庭房', capacity: 4 }; } // 标准: X-YZZ 格式 const stdMatch = cleaned.match(/^(\d+)-(\d+)$/); if (stdMatch) { const bldgNum = stdMatch[1]; const roomPart = stdMatch[2]; const rawFloor = parseInt(roomPart.charAt(0), 10); const floor = Number.isNaN(rawFloor) ? undefined : rawFloor; const building = `${bldgNum}号楼`; let roomType = '四人间'; let capacity = 4; if (bldgNum === '2') { roomType = '单人间'; capacity = 1; } else if (bldgNum === '8') { roomType = '爆改房'; capacity = 2; } return { building, floor, roomType, capacity }; } return { capacity: 4, roomType: '四人间' }; } async findAll(query?: { building?: string; includeArchived?: boolean }) { const where: any = {}; if (query?.building) where.building = query.building; if (!query?.includeArchived) where.status = Not('archived'); return this.repo.find({ where, order: { roomNumber: 'ASC' } }); } async findOne(id: number) { const room = await this.repo.findOne({ where: { id } }); if (!room) throw new NotFoundException('宿舍不存在'); return room; } async findOneWithOccupants(id: number) { const room = await this.findOne(id); const occupants = await this.occRepo.find({ where: { roomId: id, checkOutDate: IsNull() }, relations: ['student'], order: { checkInDate: 'ASC' }, }); return { ...room, currentOccupants: occupants }; } async getRoomOverview(query?: { includeArchived?: boolean }) { const where: any = {}; if (!query?.includeArchived) where.status = Not('archived'); const rooms = await this.repo.find({ where, order: { building: 'ASC', roomNumber: 'ASC' } }); const result: any[] = []; for (const room of rooms) { const count = await this.occRepo.count({ where: { roomId: room.id, checkOutDate: IsNull() }, }); result.push({ ...room, currentCount: count }); } return result; } async create(dto: CreateRoomDto) { const parsed = RoomsService.parseRoomNumber(dto.roomNumber); const entity = this.repo.create({ ...dto, building: dto.building ?? parsed.building, floor: dto.floor ?? parsed.floor, roomType: dto.roomType ?? parsed.roomType, capacity: dto.capacity ?? parsed.capacity, }); const room = await this.repo.save(entity); await this.createDefaultBeds(room.id, room.capacity); return room; } async update(id: number, dto: UpdateRoomDto) { return this.dataSource.transaction(async (manager) => { const roomRepo = manager.getRepository(Room); const bedRepo = manager.getRepository(Bed); const occupancyRepo = manager.getRepository(Occupancy); const room = await roomRepo.findOne({ where: { id } }); if (!room) throw new NotFoundException('宿舍不存在'); if (dto.capacity !== undefined) { const [beds, activeOccupantCount] = await Promise.all([ bedRepo.find({ where: { roomId: id }, order: { bedNumber: 'ASC' } }), occupancyRepo.count({ where: { roomId: id, checkOutDate: IsNull() } }), ]); if (dto.capacity < activeOccupantCount) { throw new BadRequestException( `额定人数不能少于当前入住人数,当前有 ${activeOccupantCount} 人入住`, ); } if (dto.capacity < beds.length) { throw new BadRequestException( `额定人数不能少于现有床位数,当前有 ${beds.length} 张床位,请先删除多余的空闲床位`, ); } if (dto.capacity > beds.length) { const countToCreate = dto.capacity - beds.length; const start = this.getNextBedNumber(beds); const newBeds = Array.from({ length: countToCreate }, (_, index) => bedRepo.create({ roomId: id, bedNumber: `${start + index}号床` }), ); await bedRepo.save(newBeds); } if ( dto.status === undefined && room.status !== 'maintenance' && room.status !== 'archived' ) { dto = { ...dto, status: activeOccupantCount >= dto.capacity ? 'full' : 'available', }; } } await roomRepo.update(id, dto); return roomRepo.findOne({ where: { id } }); }); } async remove(id: number) { const room = await this.findOne(id); // 检查是否有在住人员 const activeCount = await this.occRepo.count({ where: { roomId: id, checkOutDate: IsNull() } }); if (activeCount > 0) throw new BadRequestException('该宿舍有在住人员,无法归档'); if (room.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 rooms = await this.repo.find({ where: { id: In(ids) } }); const skipped: string[] = []; const targetIds: number[] = []; for (const r of rooms) { if (r.status === 'archived') { skipped.push(`${r.roomNumber}(已归档)`); continue; } const activeCount = await this.occRepo.count({ where: { roomId: r.id, checkOutDate: IsNull() }, }); if (activeCount > 0) { skipped.push(`${r.roomNumber}(有在住人员)`); continue; } targetIds.push(r.id); } let affected = 0; if (targetIds.length > 0) { const result = await this.repo .createQueryBuilder() .update() .set({ status: 'archived' }) .where('id IN (:...ids)', { ids: targetIds }) .execute(); affected = result.affected || 0; } const message = skipped.length > 0 ? `成功归档 ${affected} 间;${skipped.length} 间被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''})` : `已批量归档 ${affected} 间宿舍(数据已保留,可随时恢复)`; return { message, archived: affected, skipped: skipped.length }; } async restore(id: number) { const room = await this.findOne(id); if (room.status !== 'archived') throw new BadRequestException('该宿舍未被归档'); await this.repo.update(id, { status: 'available' }); return { message: '已恢复' }; } async getRoomVisual(asOf?: string) { // asOf 为空 = 实时(今天)。带 asOf = 还原该日期结束时的历史入住快照。 const isHistorical = !!asOf; const targetDate = asOf || new Date().toISOString().slice(0, 10); // 实时视图排除已归档房间;历史视图不排除——当时有人住的房间即使现在已归档也应显示。 const rooms = await this.repo.find({ where: isHistorical ? {} : { status: Not('archived') }, order: { building: 'ASC', roomNumber: 'ASC' }, }); const occupancies = await this.occRepo.find({ where: isHistorical ? [ { checkInDate: LessThanOrEqual(targetDate), checkOutDate: IsNull() }, { checkInDate: LessThanOrEqual(targetDate), checkOutDate: MoreThanOrEqual(targetDate) }, ] : { checkOutDate: IsNull() }, relations: ['student', 'student.organization', 'responsibleOrganization'], order: { checkInDate: 'ASC' }, }); // 按roomId分组入住记录 const occMap = new Map(); // days(已住天数)相对目标日期计算,而非固定今天,历史视图才准确。 const refTime = new Date(targetDate).getTime(); for (const occ of occupancies) { if (!occMap.has(occ.roomId)) occMap.set(occ.roomId, []); const checkIn = new Date(occ.checkInDate); const days = Math.max(1, Math.ceil((refTime - checkIn.getTime()) / (1000 * 60 * 60 * 24))); occMap.get(occ.roomId)!.push({ studentId: occ.studentId, studentName: occ.student?.name || '未知', checkInDate: occ.checkInDate, billingStartDate: occ.billingStartDate, days, organization: occ.student?.organization?.name || null, supervisor: occ.student?.supervisor || null, organizationId: occ.responsibleOrganizationId || null, organizationName: occ.responsibleOrganization?.name || null, organizationColor: occ.responsibleOrganization?.color || null, }); } // 获取各楼栋列表 const buildings = [...new Set(rooms.map((r) => r.building).filter(Boolean))]; // 历史视图纳入了已归档房间,但只保留当时确实有人住的归档房间,避免空归档房间刷屏。 const visibleRooms = isHistorical ? rooms.filter((r) => r.status !== 'archived' || (occMap.get(r.id)?.length ?? 0) > 0) : rooms; // 批量获取床位统计 const allBeds = await this.bedRepo.find({ where: { roomId: In(visibleRooms.map((r) => r.id)) }, }); const bedMap = new Map(); for (const bed of allBeds) { if (!bedMap.has(bed.roomId)) bedMap.set(bed.roomId, { total: 0, occupied: 0 }); const entry = bedMap.get(bed.roomId)!; entry.total++; if (bed.status === 'occupied') entry.occupied++; } return { buildings, rooms: visibleRooms.map((room) => { const occ = occMap.get(room.id) || []; const orgs = [...new Set(occ.map((o: any) => o.organization).filter(Boolean))]; let orgLabel: string | null = null; if (orgs.length > 0 && occ.length > 0) { const allSameOrg = occ.every((o: any) => o.organization && o.organization === orgs[0]); orgLabel = allSameOrg ? `均为${orgs[0]}人员` : `存在${orgs.join('、')}人员`; } const organizationColors = [ ...new Set(occ.map((o: any) => o.organizationColor).filter(Boolean)), ]; const organizationColor: string | null = organizationColors.length === 1 ? organizationColors[0] : null; const organizationIds = [...new Set(occ.map((o: any) => o.organizationId).filter(Boolean))]; return { id: room.id, roomNumber: room.roomNumber, building: room.building, floor: room.floor, capacity: room.capacity, status: room.status, currentCount: occ.length, totalBeds: bedMap.get(room.id)?.total ?? 0, occupiedBeds: bedMap.get(room.id)?.occupied ?? 0, occupants: occ, orgLabel, organizationColor, organizationIds, }; }), // 当前视图内出现过的负责机构,供筛选下拉使用 organizations: [ ...new Map( occupancies .filter((o) => o.responsibleOrganizationId && o.responsibleOrganization) .map((o) => [ o.responsibleOrganizationId, { id: o.responsibleOrganizationId, name: o.responsibleOrganization.name, color: o.responsibleOrganization.color || null, }, ]), ).values(), ].sort((a, b) => a.name.localeCompare(b.name)), }; } async batchImport( rows: { roomNumber: string; building?: string; floor?: number; capacity?: number; roomType?: string; rentalCategory?: string; monthlyRate?: number; }[], ) { let imported = 0; let skipped = 0; for (const row of rows) { if (!row.roomNumber || !row.roomNumber.trim()) { skipped++; continue; } const exists = await this.repo.findOne({ where: { roomNumber: row.roomNumber.trim() } }); if (exists) { skipped++; continue; } // 智能解析房间号 const parsed = RoomsService.parseRoomNumber(row.roomNumber.trim()); const room = await this.repo.save( this.repo.create({ roomNumber: row.roomNumber.trim(), building: row.building?.trim() || parsed.building || undefined, floor: row.floor ?? parsed.floor, capacity: row.capacity ?? parsed.capacity ?? 4, roomType: row.roomType || parsed.roomType || undefined, rentalCategory: row.rentalCategory || undefined, monthlyRate: row.monthlyRate ?? undefined, }), ); await this.createDefaultBeds(room.id, room.capacity); imported++; } return { message: `成功导入 ${imported} 间宿舍,跳过 ${skipped} 条(重复或空行)`, imported, skipped, }; } // ── 床位管理 ── async getRoomBeds(roomId: number): Promise { const room = await this.repo.findOne({ where: { id: roomId } }); if (!room) throw new NotFoundException('宿舍不存在'); return this.bedRepo.find({ where: { roomId, status: Not('archived') }, order: { bedNumber: 'ASC' } }); } async getRoomAvailableBeds(roomId: number): Promise { const room = await this.repo.findOne({ where: { id: roomId } }); if (!room) throw new NotFoundException('宿舍不存在'); return this.bedRepo.find({ where: { roomId, status: 'available' }, order: { bedNumber: 'ASC' }, }); } async createBed(roomId: number, dto: CreateBedDto): Promise { const room = await this.repo.findOne({ where: { id: roomId } }); if (!room) throw new NotFoundException('宿舍不存在'); if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加床位'); await this.assertCanAddBeds(room, 1); const existing = await this.bedRepo.findOne({ where: { roomId, bedNumber: dto.bedNumber } }); if (existing) throw new BadRequestException('该床位编号已存在'); const bed = this.bedRepo.create({ ...dto, roomId }); return this.bedRepo.save(bed); } async updateBed(roomId: number, id: number, dto: UpdateBedDto): Promise { const bed = await this.bedRepo.findOne({ where: { id, roomId } }); if (!bed) throw new NotFoundException('床位不存在'); // 不允许将 occupied 的床位改为 maintenance if (dto.status === 'maintenance' && bed.status === 'occupied') { throw new BadRequestException('该床位有人入住,请先退宿'); } // 编号唯一性检查 if (dto.bedNumber && dto.bedNumber !== bed.bedNumber) { const dup = await this.bedRepo.findOne({ where: { roomId, bedNumber: dto.bedNumber } }); if (dup) throw new BadRequestException('该床位编号已存在'); } Object.assign(bed, dto); return this.bedRepo.save(bed); } async deleteBed(roomId: number, id: number): Promise { const bed = await this.bedRepo.findOne({ where: { id, roomId } }); if (!bed) throw new NotFoundException('床位不存在'); if (bed.status === 'occupied') throw new BadRequestException('该床位有人入住,无法归档'); if (bed.status === 'archived') throw new BadRequestException('该床位已归档'); await this.bedRepo.update(id, { status: 'archived' }); } async batchCreateBeds(roomId: number, dto: BatchCreateBedDto): Promise { const room = await this.repo.findOne({ where: { id: roomId } }); if (!room) throw new NotFoundException('宿舍不存在'); if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加床位'); const existing = await this.bedRepo.find({ where: { roomId, status: Not('archived') }, order: { bedNumber: 'ASC' } }); this.assertCanAddBedsFromCount(room, existing.length, dto.count); const numbers = existing.map((b) => { const match = b.bedNumber.match(/^\d+/); return match ? parseInt(match[0]) : 0; }); const start = numbers.length > 0 ? Math.max(...numbers) + 1 : 1; const beds: Bed[] = []; for (let i = 0; i < dto.count; i++) { beds.push(this.bedRepo.create({ roomId, bedNumber: `${start + i}号床` })); } return this.bedRepo.save(beds); } private async createDefaultBeds(roomId: number, capacity: number): Promise { const count = Math.max(capacity ?? 0, 0); if (count === 0) return; const beds = Array.from({ length: count }, (_, index) => this.bedRepo.create({ roomId, bedNumber: `${index + 1}号床` }), ); await this.bedRepo.save(beds); } private getNextBedNumber(beds: Pick[]): number { const numbers = beds.map((bed) => { const match = bed.bedNumber.match(/^\d+/); return match ? parseInt(match[0], 10) : 0; }); return numbers.length > 0 ? Math.max(...numbers) + 1 : 1; } private async assertCanAddBeds(room: Room, count: number): Promise { const existingCount = await this.bedRepo.count({ where: { roomId: room.id } }); this.assertCanAddBedsFromCount(room, existingCount, count); } private assertCanAddBedsFromCount(room: Room, existingCount: number, count: number): void { const remaining = Math.max((room.capacity ?? 0) - existingCount, 0); if (count > remaining) { throw new BadRequestException( `床位不能超过额定人数,当前已有 ${existingCount} 张,额定 ${room.capacity} 张,最多还能添加 ${remaining} 张`, ); } } // ── 柜子管理 ── async getRoomLockers(roomId: number): Promise { const room = await this.repo.findOne({ where: { id: roomId } }); if (!room) throw new NotFoundException('宿舍不存在'); return this.lockerRepo.find({ where: { roomId, status: Not('archived') }, order: { lockerNumber: 'ASC' } }); } async getRoomAvailableLockers(roomId: number): Promise { const room = await this.repo.findOne({ where: { id: roomId } }); if (!room) throw new NotFoundException('宿舍不存在'); return this.lockerRepo.find({ where: { roomId, status: 'available' }, order: { lockerNumber: 'ASC' }, }); } async createLocker(roomId: number, dto: CreateLockerDto): Promise { const room = await this.repo.findOne({ where: { id: roomId } }); if (!room) throw new NotFoundException('宿舍不存在'); if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加柜子'); const existing = await this.lockerRepo.findOne({ where: { roomId, lockerNumber: dto.lockerNumber }, }); if (existing) throw new BadRequestException('该柜子编号已存在'); const locker = this.lockerRepo.create({ ...dto, roomId }); return this.lockerRepo.save(locker); } async updateLocker(roomId: number, id: number, dto: UpdateLockerDto): Promise { const locker = await this.lockerRepo.findOne({ where: { id, roomId } }); if (!locker) throw new NotFoundException('柜子不存在'); if (dto.status === 'maintenance' && locker.status === 'occupied') { throw new BadRequestException('该柜子有人占用,请先释放'); } if (dto.lockerNumber && dto.lockerNumber !== locker.lockerNumber) { const dup = await this.lockerRepo.findOne({ where: { roomId, lockerNumber: dto.lockerNumber }, }); if (dup) throw new BadRequestException('该柜子编号已存在'); } Object.assign(locker, dto); return this.lockerRepo.save(locker); } async deleteLocker(roomId: number, id: number): Promise { const locker = await this.lockerRepo.findOne({ where: { id, roomId } }); if (!locker) throw new NotFoundException('柜子不存在'); if (locker.status === 'occupied') throw new BadRequestException('该柜子有人占用,无法归档'); if (locker.status === 'archived') throw new BadRequestException('该柜子已归档'); await this.lockerRepo.update(id, { status: 'archived' }); } async batchCreateLockers(roomId: number, dto: BatchCreateLockerDto): Promise { const room = await this.repo.findOne({ where: { id: roomId } }); if (!room) throw new NotFoundException('宿舍不存在'); if (room.status === 'archived') throw new BadRequestException('已归档宿舍不能添加柜子'); const existing = await this.lockerRepo.find({ where: { roomId, status: Not('archived') }, order: { lockerNumber: 'ASC' }, }); const numbers = existing.map((b) => { const match = b.lockerNumber.match(/^\d+/); return match ? parseInt(match[0]) : 0; }); const start = numbers.length > 0 ? Math.max(...numbers) + 1 : 1; const lockers: Locker[] = []; for (let i = 0; i < dto.count; i++) { lockers.push(this.lockerRepo.create({ roomId, lockerNumber: `${start + i}号柜` })); } return this.lockerRepo.save(lockers); } }