feat(task1): restructure directories for turborepo monorepo
- Move backend/ to apps/server/ via git mv - Move frontend/ to apps/admin/ via git mv - Create packages/typescript-config/ with base, nestjs, and react-vite presets
This commit is contained in:
224
apps/server/src/rooms/rooms.service.ts
Normal file
224
apps/server/src/rooms/rooms.service.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, Like, 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 { CreateRoomDto, UpdateRoomDto } from './dto/room.dto';
|
||||
|
||||
@Injectable()
|
||||
export class RoomsService {
|
||||
constructor(
|
||||
@InjectRepository(Room) private repo: Repository<Room>,
|
||||
@InjectRepository(Occupancy) private occRepo: Repository<Occupancy>,
|
||||
@InjectRepository(RoomExpense) private roomExpRepo: Repository<RoomExpense>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 智能解析房间号,自动推导楼栋、楼层、宿舍类型
|
||||
* "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 = roomPart.length >= 3 ? parseInt(roomPart.charAt(0), 10) : parseInt(roomPart.charAt(0), 10);
|
||||
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) {
|
||||
return this.repo.save(this.repo.create(dto));
|
||||
}
|
||||
|
||||
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() {
|
||||
const rooms = await this.repo.find({ where: { status: Not('archived') }, order: { building: 'ASC', roomNumber: 'ASC' } });
|
||||
const occupancies = await this.occRepo.find({
|
||||
where: { checkOutDate: IsNull() },
|
||||
relations: ['student'],
|
||||
order: { checkInDate: 'ASC' },
|
||||
});
|
||||
|
||||
// 按roomId分组入住记录
|
||||
const occMap = new Map<number, any[]>();
|
||||
for (const occ of occupancies) {
|
||||
if (!occMap.has(occ.roomId)) occMap.set(occ.roomId, []);
|
||||
const now = new Date();
|
||||
const checkIn = new Date(occ.checkInDate);
|
||||
const days = Math.max(1, Math.ceil((now.getTime() - 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 || null,
|
||||
supervisor: occ.student?.supervisor || null,
|
||||
});
|
||||
}
|
||||
|
||||
// 获取各楼栋列表
|
||||
const buildings = [...new Set(rooms.map((r) => r.building).filter(Boolean))];
|
||||
|
||||
return {
|
||||
buildings,
|
||||
rooms: rooms.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]);
|
||||
if (allSameOrg) {
|
||||
orgLabel = `均为${orgs[0]}人员`;
|
||||
} else {
|
||||
orgLabel = `存在${orgs.join('、')}人员`;
|
||||
}
|
||||
}
|
||||
return {
|
||||
id: room.id,
|
||||
roomNumber: room.roomNumber,
|
||||
building: room.building,
|
||||
floor: room.floor,
|
||||
capacity: room.capacity,
|
||||
status: room.status,
|
||||
currentCount: occ.length,
|
||||
occupants: occ,
|
||||
orgLabel,
|
||||
};
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
async batchImport(rows: { roomNumber: string; building?: string; floor?: number; capacity?: number; roomType?: string }[]) {
|
||||
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 || undefined,
|
||||
capacity: row.capacity || parsed.capacity || 4,
|
||||
roomType: row.roomType || parsed.roomType || undefined,
|
||||
}));
|
||||
imported++;
|
||||
}
|
||||
return { message: `成功导入 ${imported} 间宿舍,跳过 ${skipped} 条(重复或空行)`, imported, skipped };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user