forked from wangziqi/gongxue-base
350 lines
13 KiB
TypeScript
350 lines
13 KiB
TypeScript
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,
|
||
};
|
||
}
|
||
}
|