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, @InjectRepository(ClassroomRental) private rentalRepo: Repository, @InjectRepository(ClassSchedule) private scheduleRepo: Repository, ) {} async findAll(query?: { building?: string; roomType?: string; includeArchived?: boolean }) { const where: Record = {}; 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 = { 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> = {}; const scheduleDaysByRoom: Record> = {}; 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', }; }); } }