474 lines
19 KiB
TypeScript
474 lines
19 KiB
TypeScript
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) {
|
||
await this.findOne(id);
|
||
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;
|
||
}[],
|
||
) {
|
||
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,
|
||
}),
|
||
);
|
||
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);
|
||
}
|
||
}
|