import { Injectable, NotFoundException, BadRequestException, ConflictException, } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository, Not, LessThanOrEqual, MoreThanOrEqual } from 'typeorm'; import { ClassroomRental } from '../entities/classroom-rental.entity'; import { Classroom } from '../entities/classroom.entity'; import { Organization } from '../entities/organization.entity'; import { ClassSchedule } from '../entities/class-schedule.entity'; import { CreateRentalDto, UpdateRentalDto } from './dto/rental.dto'; import * as path from 'path'; import * as fs from 'fs'; // 预设色板(与 organizations.service 保持一致,作为颜色兜底) const COLOR_PALETTE = [ '#ff7875', '#ffa940', '#ffc53d', '#73d13d', '#36cfc9', '#40a9ff', '#597ef7', '#9254de', '#f759ab', '#8c8c8c', ]; @Injectable() export class ClassroomRentalsService { constructor( @InjectRepository(ClassroomRental) private repo: Repository, @InjectRepository(Classroom) private classroomRepo: Repository, @InjectRepository(Organization) private organizationRepo: Repository, @InjectRepository(ClassSchedule) private scheduleRepo: Repository, ) {} get uploadDir(): string { const base = process.env.UPLOAD_DIR || './uploads'; return path.resolve(base, 'contracts'); } ensureUploadDir() { if (!fs.existsSync(this.uploadDir)) { fs.mkdirSync(this.uploadDir, { recursive: true }); } } async findAll(query?: { classroomId?: number; lesseeOrganizationId?: number; month?: string; includeEnded?: boolean; }) { const qb = this.repo .createQueryBuilder('r') .leftJoinAndSelect('r.classroom', 'classroom') .leftJoinAndSelect('r.lesseeOrganization', 'lesseeOrganization') .orderBy('r.startDate', 'DESC'); if (query?.classroomId) qb.andWhere('r.classroomId = :cid', { cid: query.classroomId }); if (query?.lesseeOrganizationId) qb.andWhere('r.lesseeOrganizationId = :oid', { oid: query.lesseeOrganizationId }); if (query?.month) { const [y, m] = query.month.split('-').map(Number); const first = `${y}-${String(m).padStart(2, '0')}-01`; const lastDay = new Date(y, m, 0).getDate(); const last = `${y}-${String(m).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`; qb.andWhere('r.startDate <= :last AND r.endDate >= :first', { first, last }); } if (!query?.includeEnded) qb.andWhere('r.status != :cancelled', { cancelled: 'cancelled' }); return qb.getMany(); } async findOne(id: number) { const rental = await this.repo.findOne({ where: { id }, relations: ['classroom', 'lessorOrganization', 'lesseeOrganization'], }); if (!rental) throw new NotFoundException('租赁订单不存在'); return rental; } async getUnavailableDates(classroomId: number, year: number, month: number, excludeId?: number) { const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate(); const monthStart = `${year}-${String(month).padStart(2, '0')}-01`; const monthEnd = `${year}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`; const [rentals, schedules] = await Promise.all([ this.repo.find({ where: { ...(excludeId ? { id: Not(excludeId) } : {}), classroomId, status: Not('cancelled'), startDate: LessThanOrEqual(monthEnd), endDate: MoreThanOrEqual(monthStart), }, }), this.scheduleRepo.find({ where: { classroomId, status: 'active', scheduleType: 'INTERNAL', startDate: LessThanOrEqual(monthEnd), endDate: MoreThanOrEqual(monthStart), }, }), ]); const unavailableDates = new Set(); for (const rental of rentals) { this.addDateRange( unavailableDates, rental.startDate > monthStart ? rental.startDate : monthStart, rental.endDate < monthEnd ? rental.endDate : monthEnd, ); } for (const schedule of schedules) { this.addScheduleOccurrences(unavailableDates, schedule, monthStart, monthEnd); } return { dates: Array.from(unavailableDates).sort() }; } /** * 查找与给定区间冲突的租赁订单,同时检测同一教室同一日期段的内部排课 * 重叠判定:start1 <= end2 AND start2 <= end1 */ async findConflicts(classroomId: number, startDate: string, endDate: string, excludeId?: number) { const qb = this.repo .createQueryBuilder('r') .leftJoinAndSelect('r.lesseeOrganization', 'lesseeOrganization') .where('r.classroomId = :cid', { cid: classroomId }) .andWhere('r.status != :cancelled', { cancelled: 'cancelled' }) .andWhere('r.startDate <= :end', { end: endDate }) .andWhere('r.endDate >= :start', { start: startDate }); if (excludeId) qb.andWhere('r.id != :excludeId', { excludeId }); const rentals = await qb.getMany(); // 检测同一教室同一日期段是否存在内部排课 const scheduleCandidates = await this.scheduleRepo .createQueryBuilder('cs') .where('cs.classroomId = :cid', { cid: classroomId }) .andWhere('cs.status = :status', { status: 'active' }) .andWhere('cs.scheduleType = :scheduleType', { scheduleType: 'INTERNAL' }) .andWhere('cs.startDate <= :end', { end: endDate }) .andWhere('cs.endDate >= :start', { start: startDate }) .getMany(); const scheduleConflicts = scheduleCandidates.filter((schedule) => this.hasScheduleOccurrence(schedule, startDate, endDate), ); if (scheduleConflicts.length > 0) { throw new ConflictException({ message: '该教室在此时间段已有排课', conflicts: scheduleConflicts.map((s) => ({ id: s.id, startDate: s.startDate, endDate: s.endDate, organizationName: `[内部排课] ${s.subject}`, })), }); } return rentals; } private hasScheduleOccurrence( schedule: ClassSchedule, startDate: string, endDate: string, ): boolean { const overlapStart = schedule.startDate > startDate ? schedule.startDate : startDate; const overlapEnd = schedule.endDate < endDate ? schedule.endDate : endDate; if (overlapStart > overlapEnd) return false; const startUtc = this.toUtcDate(overlapStart); const endUtc = this.toUtcDate(overlapEnd); const startWeekDay = startUtc.getUTCDay() || 7; const daysUntilOccurrence = (schedule.weekDay - startWeekDay + 7) % 7; startUtc.setUTCDate(startUtc.getUTCDate() + daysUntilOccurrence); return startUtc <= endUtc; } private toUtcDate(date: string): Date { const [year, month, day] = date.split('-').map(Number); return new Date(Date.UTC(year, month - 1, day)); } private addDateRange(dates: Set, startDate: string, endDate: string) { const current = this.toUtcDate(startDate); const end = this.toUtcDate(endDate); while (current <= end) { dates.add(current.toISOString().slice(0, 10)); current.setUTCDate(current.getUTCDate() + 1); } } private addScheduleOccurrences( dates: Set, schedule: ClassSchedule, startDate: string, endDate: string, ) { const overlapStart = schedule.startDate > startDate ? schedule.startDate : startDate; const overlapEnd = schedule.endDate < endDate ? schedule.endDate : endDate; if (overlapStart > overlapEnd) return; const current = this.toUtcDate(overlapStart); const end = this.toUtcDate(overlapEnd); const startWeekDay = current.getUTCDay() || 7; current.setUTCDate(current.getUTCDate() + ((schedule.weekDay - startWeekDay + 7) % 7)); while (current <= end) { dates.add(current.toISOString().slice(0, 10)); current.setUTCDate(current.getUTCDate() + 7); } } async create(dto: CreateRentalDto, userId?: number) { if (dto.startDate > dto.endDate) throw new BadRequestException('起始日期不能晚于结束日期'); const classroom = await this.classroomRepo.findOne({ where: { id: dto.classroomId } }); if (!classroom) throw new NotFoundException('教室不存在'); const lessorOrganization = dto.lessorOrganizationId ? await this.organizationRepo.findOne({ where: { id: dto.lessorOrganizationId, status: 'active' }, }) : await this.organizationRepo.findOne({ where: { isHost: true, status: 'active' } }); if (!lessorOrganization) throw new NotFoundException('出租机构不存在或未启用'); const lesseeOrganization = await this.organizationRepo.findOne({ where: { id: dto.lesseeOrganizationId, status: 'active' }, }); if (!lesseeOrganization) throw new NotFoundException('承租机构不存在或未启用'); if (lessorOrganization.id === lesseeOrganization.id) { throw new BadRequestException('出租机构和承租机构不能相同'); } const conflicts = await this.findConflicts(dto.classroomId, dto.startDate, dto.endDate); if (conflicts.length > 0) { throw new ConflictException({ message: '该教室在此时间段已有租赁', conflicts: conflicts.map((c) => ({ id: c.id, startDate: c.startDate, endDate: c.endDate, organizationName: c.lesseeOrganization?.name, })), }); } const rental = this.repo.create({ ...dto, lessorOrganizationId: lessorOrganization.id, lesseeOrganizationId: lesseeOrganization.id, createdBy: userId, status: 'active', }); const saved = await this.repo.save(rental); await this.syncScheduleFromRental(saved, lesseeOrganization.name); return saved; } async update(id: number, dto: UpdateRentalDto) { const rental = await this.findOne(id); // 若修改了教室/日期,重新冲突检查 const newClassroomId = dto.classroomId ?? rental.classroomId; const newStart = dto.startDate ?? rental.startDate; const newEnd = dto.endDate ?? rental.endDate; if (newStart > newEnd) throw new BadRequestException('起始日期不能晚于结束日期'); if (dto.classroomId || dto.startDate || dto.endDate) { const conflicts = await this.findConflicts(newClassroomId, newStart, newEnd, id); if (conflicts.length > 0) { throw new ConflictException({ message: '修改后时间段与已有租赁冲突', conflicts: conflicts.map((c) => ({ id: c.id, startDate: c.startDate, endDate: c.endDate, organizationName: c.lesseeOrganization?.name, })), }); } } const newLessorId = dto.lessorOrganizationId ?? rental.lessorOrganizationId; const newLesseeId = dto.lesseeOrganizationId ?? rental.lesseeOrganizationId; if (newLessorId === newLesseeId) { throw new BadRequestException('出租机构和承租机构不能相同'); } if (dto.lessorOrganizationId) { const lessor = await this.organizationRepo.findOne({ where: { id: dto.lessorOrganizationId, status: 'active' }, }); if (!lessor) throw new NotFoundException('出租机构不存在或未启用'); } if (dto.lesseeOrganizationId) { const lessee = await this.organizationRepo.findOne({ where: { id: dto.lesseeOrganizationId, status: 'active' }, }); if (!lessee) throw new NotFoundException('承租机构不存在或未启用'); } await this.repo.update(id, dto); const updated = await this.findOne(id); if (dto.status === 'cancelled') { await this.scheduleRepo.delete({ rentalId: id, scheduleType: 'RENTAL' }); } else { await this.syncScheduleFromRental(updated); } return updated; } async remove(id: number) { const rental = await this.findOne(id); // 同步删除对应排课记录 await this.scheduleRepo.delete({ rentalId: id, scheduleType: 'RENTAL' }); // 同时删除合同文件 if (rental.contractPath) { const full = path.join(this.uploadDir, rental.contractPath); if (fs.existsSync(full)) { try { fs.unlinkSync(full); } catch { /* ignore */ } } } await this.repo.delete(id); return { message: '删除成功' }; } /** * 同步租赁订单到 class_schedules(schedule_type = 'RENTAL') */ private async syncScheduleFromRental(rental: ClassroomRental, organizationName?: string) { const name = organizationName || rental.lesseeOrganization?.name || '承租机构'; const weekDay = this.dateToWeekDay(rental.startDate); let schedule = await this.scheduleRepo.findOne({ where: { rentalId: rental.id, scheduleType: 'RENTAL' }, }); const data = { classroomId: rental.classroomId, classId: null, weekDay, startTime: '00:00', endTime: '23:59', startDate: rental.startDate, endDate: rental.endDate, subject: `${name} 租赁`, teacherId: null, scheduleType: 'RENTAL', rentalId: rental.id, status: 'active', notes: rental.notes, }; if (schedule) { await this.scheduleRepo.update(schedule.id, data); } else { schedule = this.scheduleRepo.create(data); await this.scheduleRepo.save(schedule); } } private dateToWeekDay(date: string): number { const d = new Date(date); const day = d.getDay(); return day === 0 ? 7 : day; } async attachContract(id: number, file: Express.Multer.File) { const rental = await this.findOne(id); this.ensureUploadDir(); // 安全校验:MIME + 扩展名 if (file.mimetype !== 'application/pdf') { throw new BadRequestException('仅支持 PDF 文件'); } const ext = path.extname(file.originalname).toLowerCase(); if (ext !== '.pdf') throw new BadRequestException('文件扩展名必须为 .pdf'); // UUID 文件名 const uuid = (globalThis as any).crypto?.randomUUID?.() || require('crypto').randomBytes(16).toString('hex'); const filename = `${uuid}.pdf`; const fullPath = path.join(this.uploadDir, filename); // 路径遍历防护 if (!fullPath.startsWith(this.uploadDir)) throw new BadRequestException('路径非法'); // 删除旧文件 if (rental.contractPath) { const oldPath = path.join(this.uploadDir, rental.contractPath); if (fs.existsSync(oldPath)) { try { fs.unlinkSync(oldPath); } catch { /* ignore */ } } } fs.writeFileSync(fullPath, file.buffer); await this.repo.update(id, { contractPath: filename, contractOriginalName: file.originalname, }); return this.findOne(id); } async removeContract(id: number) { const rental = await this.findOne(id); if (!rental.contractPath) throw new BadRequestException('该租赁未上传合同'); const fullPath = path.join(this.uploadDir, rental.contractPath); if (fs.existsSync(fullPath)) { try { fs.unlinkSync(fullPath); } catch { /* ignore */ } } await this.repo.update(id, { contractPath: null as any, contractOriginalName: null as any }); return { message: '合同已删除' }; } /** * 获取合同文件的绝对路径(供控制器流式返回),严格校验路径安全 */ async getContractPath(id: number): Promise<{ fullPath: string; originalName: string }> { const rental = await this.findOne(id); if (!rental.contractPath) throw new NotFoundException('该租赁未上传合同'); const fullPath = path.join(this.uploadDir, rental.contractPath); if (!fullPath.startsWith(this.uploadDir)) throw new BadRequestException('路径非法'); if (!fs.existsSync(fullPath)) throw new NotFoundException('合同文件丢失'); return { fullPath, originalName: rental.contractOriginalName || 'contract.pdf' }; } /** * 获取月度排期矩阵 */ async getSchedule(year: number, month: number) { const lastDay = new Date(year, month, 0).getDate(); const first = `${year}-${String(month).padStart(2, '0')}-01`; const last = `${year}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`; const classrooms = await this.classroomRepo.find({ where: { status: Not('archived') }, order: { building: 'ASC', name: 'ASC' }, }); const rentals = await this.repo .createQueryBuilder('r') .leftJoinAndSelect('r.lesseeOrganization', 'lesseeOrganization') .leftJoinAndSelect('r.classroom', 'classroom') .where('r.status != :cancelled', { cancelled: 'cancelled' }) .andWhere('r.startDate <= :last AND r.endDate >= :first', { first, last }) .getMany(); const organizationMap = new Map(); const matrix: Record> = {}; const summary: Record< number, { totalDays: number; rentedDays: number; idleDays: number; occupancyRate: number } > = {}; for (const cls of classrooms) { matrix[cls.id] = {}; summary[cls.id] = { totalDays: lastDay, rentedDays: 0, idleDays: lastDay, occupancyRate: 0 }; } for (const rental of rentals) { const start = new Date(rental.startDate); const end = new Date(rental.endDate); const monthStart = new Date(first); const monthEnd = new Date(last); const effStart = start < monthStart ? monthStart : start; const effEnd = end > monthEnd ? monthEnd : end; if (rental.lesseeOrganization && !organizationMap.has(rental.lesseeOrganization.id)) { organizationMap.set(rental.lesseeOrganization.id, { id: rental.lesseeOrganization.id, name: rental.lesseeOrganization.name, color: rental.lesseeOrganization.color || COLOR_PALETTE[rental.lesseeOrganization.id % COLOR_PALETTE.length], }); } for (let d = new Date(effStart); d <= effEnd; d.setDate(d.getDate() + 1)) { const day = d.getDate(); if (!matrix[rental.classroomId]) continue; matrix[rental.classroomId][day] = { scheduleType: 'RENTAL', rentalId: rental.id, organizationId: rental.lesseeOrganizationId, organizationName: rental.lesseeOrganization?.name || '未知', color: rental.lesseeOrganization?.color || COLOR_PALETTE[(rental.lesseeOrganizationId || 0) % COLOR_PALETTE.length], hasContract: !!rental.contractPath, }; } } // ── Overlay internal class schedules ── const schedules = await this.scheduleRepo .createQueryBuilder('s') .leftJoinAndSelect('s.class', 'class') .leftJoinAndSelect('s.teacher', 'teacher') .where('s.status = :active', { active: 'active' }) .andWhere('s.scheduleType = :type', { type: 'INTERNAL' }) .andWhere('s.startDate <= :last AND s.endDate >= :first', { first, last }) .getMany(); for (const sched of schedules) { if (!sched.classroomId) continue; const schedStart = new Date( Math.max(new Date(sched.startDate).getTime(), new Date(first).getTime()), ); const schedEnd = new Date( Math.min(new Date(sched.endDate).getTime(), new Date(last).getTime()), ); for (let d = new Date(schedStart); d <= schedEnd; d.setDate(d.getDate() + 1)) { const dow = d.getDay() === 0 ? 7 : d.getDay(); if (dow !== sched.weekDay) continue; const day = d.getDate(); if (!matrix[sched.classroomId]) continue; matrix[sched.classroomId][day] = { scheduleType: 'INTERNAL', scheduleId: sched.id, className: (sched.class as any)?.name || '', subject: sched.subject, teacherName: (sched.teacher as any)?.name || '', startTime: sched.startTime, endTime: sched.endTime, color: '#52c41a', }; } } // 统计 for (const cls of classrooms) { const rented = Object.keys(matrix[cls.id]).length; summary[cls.id].rentedDays = rented; summary[cls.id].idleDays = lastDay - rented; summary[cls.id].occupancyRate = lastDay > 0 ? Math.round((rented / lastDay) * 100) / 100 : 0; } return { year, month, days: lastDay, classrooms: classrooms.map((c) => ({ id: c.id, name: c.name, building: c.building, floor: c.floor, roomType: c.roomType, capacity: c.capacity, supervisor: c.supervisor, })), organizations: Array.from(organizationMap.values()), matrix, summary, }; } }