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

341 lines
12 KiB
TypeScript

import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Not, MoreThanOrEqual } from 'typeorm';
import { Classroom, ClassroomStatus } from '../entities/classroom.entity';
import { ClassroomRental, ClassroomRentalStatus } from '../entities/classroom-rental.entity';
import { ClassSchedule } from '../entities/class-schedule.entity';
import { CreateClassroomDto, UpdateClassroomDto } from './dto/classroom.dto';
@Injectable()
export class ClassroomsService {
constructor(
@InjectRepository(Classroom) private repo: Repository<Classroom>,
@InjectRepository(ClassroomRental) private rentalRepo: Repository<ClassroomRental>,
@InjectRepository(ClassSchedule) private scheduleRepo: Repository<ClassSchedule>,
) {}
async findAll(query?: { building?: string; roomType?: string; includeArchived?: boolean }) {
const where: Record<string, unknown> = {};
if (query?.building) where.building = query.building;
if (query?.roomType) where.roomType = query.roomType;
if (!query?.includeArchived) where.status = Not('archived');
const list = await this.repo.find({ where, order: { building: 'ASC', name: 'ASC' } });
const usageMap = await this.getUsageForClassrooms(list.map((c) => c.id));
return list.map((classroom) => this.withEffectiveStatus(classroom, usageMap.get(classroom.id)));
}
async findOne(id: number) {
const cls = await this.repo.findOne({ where: { id } });
if (!cls) throw new NotFoundException('教室不存在');
const usageMap = await this.getUsageForClassrooms([id]);
return this.withEffectiveStatus(cls, usageMap.get(id));
}
async create(dto: CreateClassroomDto) {
const exists = await this.repo.findOne({ where: { name: dto.name } });
if (exists) throw new BadRequestException(`教室 ${dto.name} 已存在`);
return this.repo.save(this.repo.create({ ...dto, status: ClassroomStatus.AVAILABLE }));
}
async update(id: number, dto: UpdateClassroomDto) {
const classroom = await this.repo.findOne({ where: { id } });
if (!classroom) throw new NotFoundException('教室不存在');
if (dto.status === ClassroomStatus.MAINTENANCE && classroom.status !== dto.status) {
await this.assertNoActiveAllocations(id);
}
await this.repo.update(id, dto);
return this.repo.findOne({ where: { id } });
}
async remove(id: number) {
const classroom = await this.repo.findOne({ where: { id } });
if (!classroom) throw new NotFoundException('教室不存在');
await this.assertNoActiveAllocations(id);
await this.repo.update(id, { status: ClassroomStatus.ARCHIVED });
return { message: '已归档' };
}
async restore(id: number) {
const classroom = await this.repo.findOne({ where: { id } });
if (!classroom) throw new NotFoundException('教室不存在');
await this.repo.update(id, { status: ClassroomStatus.AVAILABLE });
return this.repo.findOne({ where: { id } });
}
private withEffectiveStatus(
classroom: Classroom,
usage?: {
state: 'in_use' | 'reserved';
currentUsage: {
type: 'schedule' | 'rental';
title: string;
startTime: string;
endTime: string;
} | null;
},
) {
const effectiveStatus =
classroom.status === ClassroomStatus.ARCHIVED ||
classroom.status === ClassroomStatus.MAINTENANCE
? classroom.status
: (usage?.state ?? ClassroomStatus.AVAILABLE);
return { ...classroom, currentUsage: usage?.currentUsage ?? null, effectiveStatus };
}
private async assertNoActiveAllocations(classroomId: number) {
const today = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(new Date());
const scheduleCount = await this.scheduleRepo
.createQueryBuilder('schedule')
.where('schedule.classroomId = :classroomId', { classroomId })
.andWhere('schedule.status = :active', { active: 'active' })
.andWhere('schedule.endDate >= :today', { today })
.getCount();
const rentalCount = await this.rentalRepo.count({
where: {
classroomId,
status: ClassroomRentalStatus.ACTIVE,
endDate: MoreThanOrEqual(today),
},
});
if (rentalCount > 0 || scheduleCount > 0) {
throw new BadRequestException('该教室存在有效排课或租赁,无法维护或归档');
}
}
private async getUsageForClassrooms(classroomIds: number[]): Promise<
Map<
number,
{
state: 'in_use' | 'reserved';
currentUsage: {
type: 'schedule' | 'rental';
title: string;
startTime: string;
endTime: string;
} | null;
}
>
> {
const result = new Map<
number,
{
state: 'in_use' | 'reserved';
currentUsage: {
type: 'schedule' | 'rental';
title: string;
startTime: string;
endTime: string;
} | null;
}
>();
if (classroomIds.length === 0) return result;
const now = new Date();
const todayStr = new Intl.DateTimeFormat('en-CA', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(now);
const currentTime = new Intl.DateTimeFormat('en-GB', {
timeZone: 'Asia/Shanghai',
hour: '2-digit',
minute: '2-digit',
hour12: false,
}).format(now);
const shanghaiParts = new Intl.DateTimeFormat('en-US', {
timeZone: 'Asia/Shanghai',
weekday: 'short',
}).format(now);
const weekDayMap: Record<string, number> = {
Mon: 1,
Tue: 2,
Wed: 3,
Thu: 4,
Fri: 5,
Sat: 6,
Sun: 7,
};
const weekDay = weekDayMap[shanghaiParts];
const schedules = await this.scheduleRepo
.createQueryBuilder('s')
.leftJoin('Class', 'c', 'c.id = s.classId')
.select('s.classroomId', 'classroomId')
.addSelect('s.startTime', 'startTime')
.addSelect('s.endTime', 'endTime')
.addSelect('s.startDate', 'startDate')
.addSelect('s.endDate', 'endDate')
.addSelect('s.weekDay', 'weekDay')
.addSelect('s.subject', 'subject')
.addSelect('c.name', 'className')
.where('s.classroomId IN (:...ids)', { ids: classroomIds })
.andWhere('s.status = :active', { active: 'active' })
.andWhere('s.scheduleType = :type', { type: 'INTERNAL' })
.andWhere('s.endDate >= :today', { today: todayStr })
.getRawMany();
for (const schedule of schedules) {
const classroomId = Number(schedule.classroomId);
const isCurrent =
String(schedule.startDate) <= todayStr &&
String(schedule.endDate) >= todayStr &&
Number(schedule.weekDay) === weekDay &&
String(schedule.startTime) <= currentTime &&
String(schedule.endTime) >= currentTime;
const existing = result.get(classroomId);
if (!existing || isCurrent) {
result.set(classroomId, {
state: isCurrent ? 'in_use' : 'reserved',
currentUsage: isCurrent
? {
type: 'schedule',
title: `${schedule.className || ''} ${schedule.subject || ''}`.trim() || '内部课程',
startTime: String(schedule.startTime),
endTime: String(schedule.endTime),
}
: null,
});
}
}
const rentals = await this.rentalRepo
.createQueryBuilder('r')
.leftJoin('Organization', 't', 't.id = r.lesseeOrganizationId')
.select('r.classroomId', 'classroomId')
.addSelect('r.startDate', 'startDate')
.addSelect('r.endDate', 'endDate')
.addSelect('t.name', 'tenantName')
.where('r.classroomId IN (:...ids)', { ids: classroomIds })
.andWhere('r.status = :active', { active: ClassroomRentalStatus.ACTIVE })
.andWhere('r.endDate >= :today', { today: todayStr })
.getRawMany();
for (const rental of rentals) {
const classroomId = Number(rental.classroomId);
const isCurrent = String(rental.startDate) <= todayStr && String(rental.endDate) >= todayStr;
const existing = result.get(classroomId);
if (!existing || isCurrent) {
result.set(classroomId, {
state: isCurrent ? 'in_use' : 'reserved',
currentUsage: isCurrent
? {
type: 'rental',
title: rental.tenantName ? `${rental.tenantName} 租赁` : '外部租赁',
startTime: '00:00',
endTime: '23:59',
}
: null,
});
}
}
return result;
}
async batchImport(
rows: {
name: string;
building?: string;
floor?: number;
capacity?: number;
roomType?: string;
}[],
) {
let imported = 0;
let skipped = 0;
const errors: string[] = [];
for (const row of rows) {
if (!row.name?.trim()) {
skipped++;
continue;
}
const exists = await this.repo.findOne({ where: { name: row.name.trim() } });
if (exists) {
errors.push(`教室 ${row.name} 已存在`);
skipped++;
continue;
}
await this.repo.save(this.repo.create({ ...row, capacity: row.capacity || 30 }));
imported++;
}
return {
message: `成功导入 ${imported} 间教室,跳过 ${skipped}`,
imported,
skipped,
errors: errors.length > 0 ? errors : undefined,
};
}
async getUsageReport(dateFrom: string, dateTo: string) {
const classrooms = await this.repo.find({
where: { status: Not(ClassroomStatus.ARCHIVED) },
order: { building: 'ASC', name: 'ASC' },
});
const rentals = await this.rentalRepo
.createQueryBuilder('r')
.where('r.status IN (:...statuses)', {
statuses: [ClassroomRentalStatus.ACTIVE, ClassroomRentalStatus.ENDED],
})
.andWhere('r.startDate <= :dateTo AND r.endDate >= :dateFrom', { dateFrom, dateTo })
.getMany();
const schedules = await this.scheduleRepo
.createQueryBuilder('s')
.where('s.status = :active', { active: 'active' })
.andWhere('s.scheduleType = :type', { type: 'INTERNAL' })
.andWhere('s.startDate <= :dateTo AND s.endDate >= :dateFrom', { dateFrom, dateTo })
.getMany();
const start = new Date(dateFrom);
const end = new Date(dateTo);
const totalDays = Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)) + 1;
const rentalDaysByRoom: Record<number, Set<string>> = {};
const scheduleDaysByRoom: Record<number, Set<string>> = {};
for (const r of rentals) {
if (!rentalDaysByRoom[r.classroomId]) rentalDaysByRoom[r.classroomId] = new Set();
const effStart = new Date(Math.max(new Date(r.startDate).getTime(), start.getTime()));
const effEnd = new Date(Math.min(new Date(r.endDate).getTime(), end.getTime()));
for (let d = new Date(effStart); d <= effEnd; d.setDate(d.getDate() + 1)) {
rentalDaysByRoom[r.classroomId].add(d.toISOString().slice(0, 10));
}
}
for (const s of schedules) {
if (!scheduleDaysByRoom[s.classroomId]) scheduleDaysByRoom[s.classroomId] = new Set();
const effStart = new Date(Math.max(new Date(s.startDate).getTime(), start.getTime()));
const effEnd = new Date(Math.min(new Date(s.endDate).getTime(), end.getTime()));
for (let d = new Date(effStart); d <= effEnd; d.setDate(d.getDate() + 1)) {
scheduleDaysByRoom[s.classroomId].add(d.toISOString().slice(0, 10));
}
}
return classrooms.map((c) => {
const rentalDays = rentalDaysByRoom[c.id]?.size || 0;
const scheduleDays = scheduleDaysByRoom[c.id]?.size || 0;
const usedDays = rentalDays + scheduleDays;
return {
id: c.id,
name: c.name,
building: c.building || '',
roomType: c.roomType || '',
capacity: c.capacity,
totalDays,
rentalDays,
scheduleDays,
usedDays,
idleDays: totalDays - usedDays,
occupancyRate: totalDays > 0 ? ((usedDays / totalDays) * 100).toFixed(1) : '0.0',
};
});
}
}