366 lines
14 KiB
TypeScript
366 lines
14 KiB
TypeScript
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);
|
||
}
|
||
|
||
} |