Files
gongxue-base/apps/server/src/rooms/rooms.service.ts

366 lines
14 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 { DataSource, Repository, IsNull, Not, In } 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';
import { RoomInspectionsService } from './room-inspections.service';
import { RoomQueryService } from './room-query.service';
import { RoomBedLockerService } from './room-bed-locker.service';
import { parseRoomNumber } from './room-number';
@Injectable()
export class RoomsService {
constructor(
@InjectRepository(Room) private repo: Repository<Room>,
@InjectRepository(Occupancy) private occRepo: Repository<Occupancy>,
@InjectRepository(RoomExpense) private roomExpRepo: Repository<RoomExpense>,
@InjectRepository(Bed) private bedRepo: Repository<Bed>,
@InjectRepository(Locker) private lockerRepo: Repository<Locker>,
private dataSource: DataSource,
private readonly inspectionsService: RoomInspectionsService,
@Optional() private queryService?: RoomQueryService,
@Optional() private beds?: RoomBedLockerService,
) {}
private get queries(): RoomQueryService {
if (!this.queryService) {
this.queryService = new RoomQueryService(this.repo, this.occRepo, this.bedRepo, this.inspectionsService);
}
return this.queryService;
}
private get bedOps(): RoomBedLockerService {
if (!this.beds) {
this.beds = new RoomBedLockerService(this.repo, this.bedRepo, this.lockerRepo);
}
return this.beds;
}
/**
* 智能解析房间号,自动推导楼栋、楼层、宿舍类型
* "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) {
return parseRoomNumber(roomNumber);
}
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 purge(id: number) {
const room = await this.findOne(id);
if (room.status !== 'archived')
throw new BadRequestException('仅已归档宿舍可以永久删除,请先归档');
const [occupancyCount, expenseCount] = await Promise.all([
this.occRepo.count({ where: { roomId: id } }),
this.roomExpRepo.count({ where: { roomId: id } }),
]);
if (occupancyCount > 0) throw new BadRequestException('该宿舍存在入住记录,无法永久删除');
if (expenseCount > 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 rooms = await this.repo.find({ where: { id: In(uniqueIds) } });
if (rooms.length !== uniqueIds.length) throw new NotFoundException('部分宿舍不存在');
const deleted: number[] = [];
const skipped: string[] = [];
for (const room of rooms) {
if (room.status !== 'archived') {
skipped.push(`${room.roomNumber}(未归档)`);
continue;
}
const [occupancyCount, expenseCount] = await Promise.all([
this.occRepo.count({ where: { roomId: room.id } }),
this.roomExpRepo.count({ where: { roomId: room.id } }),
]);
if (occupancyCount > 0 || expenseCount > 0) {
skipped.push(`${room.roomNumber}(存在关联数据)`);
continue;
}
await this.repo.delete(room.id);
deleted.push(room.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 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 rooms = await this.repo.find({ where: { id: In(uniqueIds) } });
if (rooms.length !== uniqueIds.length) throw new NotFoundException('部分宿舍不存在');
const targetIds = rooms.filter((room) => room.status === 'archived').map((room) => room.id);
const skipped = rooms.length - targetIds.length;
let restored = 0;
if (targetIds.length > 0) {
const result = await this.repo
.createQueryBuilder()
.update()
.set({ status: 'available' })
.where('id IN (:...ids)', { ids: targetIds })
.execute();
restored = result.affected || 0;
}
return { message: `已批量恢复 ${restored} 间宿舍`, restored, skipped };
}
async agentSearchRooms(query: { building?: string; keyword?: string; status?: string; limit?: number }) {
return this.queries.agentSearchRooms(query);
}
async agentGetRoomOccupancySummary(query: { date?: string; building?: string; limit?: number }) {
return this.queries.agentGetRoomOccupancySummary(query);
}
async getRoomVisual(asOf?: string) {
return this.queries.getRoomVisual(asOf);
}
async batchImport(
rows: {
roomNumber: string;
building?: string;
floor?: number;
capacity?: number;
roomType?: string;
rentalCategory?: string;
monthlyRate?: number;
}[],
) {
return this.queries.batchImport(rows);
}
private createDefaultBeds(roomId: number, capacity: number): Promise<void> {
return this.queries.createDefaultBeds(roomId, capacity);
}
private getNextBedNumber(beds: Pick<Bed, 'bedNumber'>[]): number {
return this.bedOps.getNextBedNumber(beds);
}
async getRoomBeds(roomId: number): Promise<Bed[]> {
return this.bedOps.getRoomBeds(roomId);
}
async getRoomAvailableBeds(roomId: number): Promise<Bed[]> {
return this.bedOps.getRoomAvailableBeds(roomId);
}
async createBed(roomId: number, dto: CreateBedDto): Promise<Bed> {
return this.bedOps.createBed(roomId, dto);
}
async updateBed(roomId: number, id: number, dto: UpdateBedDto): Promise<Bed> {
return this.bedOps.updateBed(roomId, id, dto);
}
async deleteBed(roomId: number, id: number): Promise<void> {
return this.bedOps.deleteBed(roomId, id);
}
async batchCreateBeds(roomId: number, dto: BatchCreateBedDto): Promise<Bed[]> {
return this.bedOps.batchCreateBeds(roomId, dto);
}
async getRoomLockers(roomId: number): Promise<Locker[]> {
return this.bedOps.getRoomLockers(roomId);
}
async getRoomAvailableLockers(roomId: number): Promise<Locker[]> {
return this.bedOps.getRoomAvailableLockers(roomId);
}
async createLocker(roomId: number, dto: CreateLockerDto): Promise<Locker> {
return this.bedOps.createLocker(roomId, dto);
}
async updateLocker(roomId: number, id: number, dto: UpdateLockerDto): Promise<Locker> {
return this.bedOps.updateLocker(roomId, id, dto);
}
async deleteLocker(roomId: number, id: number): Promise<void> {
return this.bedOps.deleteLocker(roomId, id);
}
async batchCreateLockers(roomId: number, dto: BatchCreateLockerDto): Promise<Locker[]> {
return this.bedOps.batchCreateLockers(roomId, dto);
}
}