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

482 lines
19 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 } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { 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<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>,
) {}
/**
* 智能解析房间号,自动推导楼栋、楼层、宿舍类型
* "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 floor = parseInt(roomPart.charAt(0), 10) || undefined;
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 floor = parseInt(roomPart.charAt(0), 10) || undefined;
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 {};
}
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,
});
return this.repo.save(entity);
}
async update(id: number, dto: UpdateRoomDto) {
const room = await this.findOne(id);
if (Object.prototype.hasOwnProperty.call(dto, 'gender') && dto.gender !== room.gender) {
const activeCount = await this.occRepo.count({
where: { roomId: id, checkOutDate: IsNull() },
});
if (activeCount > 0) throw new BadRequestException('该宿舍有在住人员,无法修改宿舍性别');
}
await this.repo.update(id, dto);
return this.repo.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<number, any[]>();
// 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<number, { total: number; occupied: number }>();
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;
gender?: '男' | '女';
}[],
) {
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());
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,
gender: row.gender ?? undefined,
}),
);
imported++;
}
return {
message: `成功导入 ${imported} 间宿舍,跳过 ${skipped} 条(重复或空行)`,
imported,
skipped,
};
}
// ── 床位管理 ──
async getRoomBeds(roomId: number): Promise<Bed[]> {
const room = await this.repo.findOne({ where: { id: roomId } });
if (!room) throw new NotFoundException('宿舍不存在');
return this.bedRepo.find({ where: { roomId }, order: { bedNumber: 'ASC' } });
}
async getRoomAvailableBeds(roomId: number): Promise<Bed[]> {
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<Bed> {
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.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<Bed> {
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<void> {
const bed = await this.bedRepo.findOne({ where: { id, roomId } });
if (!bed) throw new NotFoundException('床位不存在');
if (bed.status === 'occupied') throw new BadRequestException('该床位有人入住,无法删除');
await this.bedRepo.remove(bed);
}
async batchCreateBeds(roomId: number, dto: BatchCreateBedDto): Promise<Bed[]> {
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 }, order: { bedNumber: 'ASC' } });
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);
}
// ── 柜子管理 ──
async getRoomLockers(roomId: number): Promise<Locker[]> {
const room = await this.repo.findOne({ where: { id: roomId } });
if (!room) throw new NotFoundException('宿舍不存在');
return this.lockerRepo.find({ where: { roomId }, order: { lockerNumber: 'ASC' } });
}
async getRoomAvailableLockers(roomId: number): Promise<Locker[]> {
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<Locker> {
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<Locker> {
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<void> {
const locker = await this.lockerRepo.findOne({ where: { id, roomId } });
if (!locker) throw new NotFoundException('柜子不存在');
if (locker.status === 'occupied') throw new BadRequestException('该柜子有人占用,无法删除');
await this.lockerRepo.remove(locker);
}
async batchCreateLockers(roomId: number, dto: BatchCreateLockerDto): Promise<Locker[]> {
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 },
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);
}
}