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

557 lines
20 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, 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<ClassroomRental>,
@InjectRepository(Classroom) private classroomRepo: Repository<Classroom>,
@InjectRepository(Organization) private organizationRepo: Repository<Organization>,
@InjectRepository(ClassSchedule) private scheduleRepo: Repository<ClassSchedule>,
) {}
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<string>();
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<string>, 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<string>,
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_schedulesschedule_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<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.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,
};
}
}