Files
gongxue-base/apps/server/src/classroom-rentals/classroom-rentals.service.ts

350 lines
13 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
Injectable,
NotFoundException,
BadRequestException,
ConflictException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Not } from 'typeorm';
import { ClassroomRental } from '../entities/classroom-rental.entity';
import { Classroom } from '../entities/classroom.entity';
import { Tenant } from '../entities/tenant.entity';
import { ClassSchedule } from '../entities/class-schedule.entity';
import { CreateRentalDto, UpdateRentalDto } from './dto/rental.dto';
import { CampusScope } from '../common/campus-scope';
import * as path from 'path';
import * as fs from 'fs';
// 预设色板(与 tenants.service 保持一致,作为颜色兜底)
const COLOR_PALETTE = [
'#ff7875',
'#ffa940',
'#ffc53d',
'#73d13d',
'#36cfc9',
'#40a9ff',
'#597ef7',
'#9254de',
'#f759ab',
'#8c8c8c',
];
@Injectable()
export class ClassroomRentalsService {
constructor(
@InjectRepository(ClassroomRental) private repo: Repository<ClassroomRental>,
@InjectRepository(Classroom) private classroomRepo: Repository<Classroom>,
@InjectRepository(Tenant) private tenantRepo: Repository<Tenant>,
@InjectRepository(ClassSchedule) private scheduleRepo: Repository<ClassSchedule>,
private readonly scope: CampusScope,
) {}
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;
tenantId?: number;
month?: string;
includeEnded?: boolean;
}) {
const scopeIds = await this.scope.getScopeDepartmentIds();
const qb = this.repo
.createQueryBuilder('r')
.leftJoinAndSelect('r.classroom', 'classroom')
.leftJoinAndSelect('r.tenant', 'tenant')
.orderBy('r.startDate', 'DESC');
if (scopeIds) qb.andWhere('r.departmentId IN (:...scopeIds)', { scopeIds });
if (query?.classroomId) qb.andWhere('r.classroomId = :cid', { cid: query.classroomId });
if (query?.tenantId) qb.andWhere('r.tenantId = :tid', { tid: query.tenantId });
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', 'tenant'] });
if (!rental) throw new NotFoundException('租赁订单不存在');
return rental;
}
/**
* 查找与给定区间冲突的租赁订单
* 重叠判定start1 <= end2 AND start2 <= end1
*/
async findConflicts(classroomId: number, startDate: string, endDate: string, excludeId?: number) {
const qb = this.repo
.createQueryBuilder('r')
.leftJoinAndSelect('r.tenant', 'tenant')
.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 });
return qb.getMany();
}
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 tenant = await this.tenantRepo.findOne({ where: { id: dto.tenantId } });
if (!tenant) throw new NotFoundException('租赁方不存在');
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,
tenantName: c.tenant?.name,
})),
});
}
const rental = this.repo.create({ ...dto, createdBy: userId, status: 'active' });
rental.departmentId = classroom.departmentId;
return this.repo.save(rental);
}
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,
tenantName: c.tenant?.name,
})),
});
}
}
await this.repo.update(id, dto);
return this.findOne(id);
}
async remove(id: number) {
const rental = await this.findOne(id);
// 同时删除合同文件
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: '删除成功' };
}
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.tenant', 'tenant')
.leftJoinAndSelect('r.classroom', 'classroom')
.where('r.status != :cancelled', { cancelled: 'cancelled' })
.andWhere('r.startDate <= :last AND r.endDate >= :first', { first, last })
.getMany();
const tenantMap = new Map<number, any>();
const matrix: Record<number, Record<number, any>> = {};
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.tenant && !tenantMap.has(rental.tenant.id)) {
tenantMap.set(rental.tenant.id, {
id: rental.tenant.id,
name: rental.tenant.name,
color: rental.tenant.color || COLOR_PALETTE[rental.tenant.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,
tenantId: rental.tenantId,
tenantName: rental.tenant?.name || '未知',
color:
rental.tenant?.color || COLOR_PALETTE[(rental.tenantId || 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,
})),
tenants: Array.from(tenantMap.values()),
matrix,
summary,
};
}
}