Files
gongxue-base/apps/server/src/occupancies/occupancies.service.ts

478 lines
18 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 } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import {
Repository,
DataSource,
IsNull,
Between,
LessThanOrEqual,
MoreThanOrEqual,
In,
} from 'typeorm';
import { Occupancy } from '../entities/occupancy.entity';
import { Room } from '../entities/room.entity';
import { Student } from '../entities/student.entity';
import { Deposit } from '../entities/deposit.entity';
import { CheckInDto, CheckOutDto, TransferRoomDto } from './dto/occupancy.dto';
import { RoomsService } from '../rooms/rooms.service';
import { CampusScope } from '../common/campus-scope';
@Injectable()
export class OccupanciesService {
constructor(
@InjectRepository(Occupancy) private repo: Repository<Occupancy>,
@InjectRepository(Room) private roomRepo: Repository<Room>,
@InjectRepository(Student) private studentRepo: Repository<Student>,
@InjectRepository(Deposit) private depositRepo: Repository<Deposit>,
private dataSource: DataSource,
private readonly scope: CampusScope,
) {}
async findAll(query?: { roomId?: number; studentId?: number; active?: boolean }) {
const qb = this.repo
.createQueryBuilder('o')
.leftJoinAndSelect('o.student', 'student')
.leftJoinAndSelect('o.room', 'room')
.orderBy('o.checkInDate', 'DESC');
const scopeIds = await this.scope.getScopeDepartmentIds();
if (scopeIds) {
qb.andWhere('o.departmentId IN (:...scopeIds)', { scopeIds });
}
if (query?.roomId) qb.andWhere('o.roomId = :roomId', { roomId: query.roomId });
if (query?.studentId) qb.andWhere('o.studentId = :studentId', { studentId: query.studentId });
if (query?.active) qb.andWhere('o.checkOutDate IS NULL');
return qb.getMany();
}
async checkIn(dto: CheckInDto) {
// 检查学生是否已有活跃入住
const existing = await this.repo.findOne({
where: { studentId: dto.studentId, checkOutDate: IsNull() },
});
if (existing) throw new BadRequestException('该学生已有在住记录,请先办理退宿');
// 检查宿舍容量
const room = await this.roomRepo.findOne({ where: { id: dto.roomId } });
if (!room) throw new NotFoundException('宿舍不存在');
const count = await this.repo.count({ where: { roomId: dto.roomId, checkOutDate: IsNull() } });
if (count >= room.capacity) throw new BadRequestException('宿舍已满');
// 房间级别性别约束
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
if (!student) throw new NotFoundException('学生不存在');
if (student.gender && room.gender && student.gender !== room.gender) {
throw new BadRequestException(
`该宿舍当前为${room.gender}生寝室,${student.gender}生无法入住`,
);
}
const occ = this.repo.create({
studentId: dto.studentId,
roomId: dto.roomId,
checkInDate: dto.checkInDate,
billingStartDate: dto.billingStartDate || dto.checkInDate,
rentalType: dto.rentalType,
tenantId: dto.tenantId,
notes: dto.notes,
});
occ.departmentId = room.departmentId;
const saved = await this.repo.save(occ);
// 首位入住者确定房间性别
if (student.gender && !room.gender) {
await this.roomRepo.update(room.id, { gender: student.gender });
}
// 更新宿舍状态
if (count + 1 >= room.capacity) {
await this.roomRepo.update(room.id, { status: 'full' });
}
return saved;
}
async checkOut(occupancyId: number, dto: CheckOutDto) {
const occ = await this.repo.findOne({ where: { id: occupancyId } });
if (!occ) throw new NotFoundException('入住记录不存在');
if (occ.checkOutDate) throw new BadRequestException('该记录已退宿');
occ.checkOutDate = dto.checkOutDate;
occ.billingEndDate = dto.billingEndDate || dto.checkOutDate;
occ.checkOutReason = dto.checkOutReason || '';
await this.repo.save(occ);
// 更新宿舍状态
await this.roomRepo.update(occ.roomId, { status: 'available' });
// 如果房间已无在住人员,重置房间性别
const remaining = await this.repo.count({
where: { roomId: occ.roomId, checkOutDate: IsNull() },
});
if (remaining === 0) {
await this.roomRepo.update(occ.roomId, { gender: null as any });
}
return occ;
}
async transferRoom(occupancyId: number, dto: TransferRoomDto) {
const runner = this.dataSource.createQueryRunner();
await runner.connect();
await runner.startTransaction();
try {
const oldOcc = await runner.manager.findOne(Occupancy, { where: { id: occupancyId } });
if (!oldOcc) throw new NotFoundException('入住记录不存在');
if (oldOcc.checkOutDate) throw new BadRequestException('该记录已退宿');
// 退旧房
oldOcc.checkOutDate = dto.transferDate;
oldOcc.billingEndDate = dto.oldBillingEndDate || dto.transferDate;
oldOcc.checkOutReason = dto.reason || '换房';
await runner.manager.save(oldOcc);
await runner.manager.update(Room, oldOcc.roomId, { status: 'available' });
// 旧房如果已无在住人员,重置性别
const oldRemaining = await runner.manager.count(Occupancy, {
where: { roomId: oldOcc.roomId, checkOutDate: IsNull() },
});
if (oldRemaining === 0) {
await runner.manager.update(Room, oldOcc.roomId, { gender: null as any });
}
// 检查新房容量
const newRoom = await runner.manager.findOne(Room, { where: { id: dto.newRoomId } });
if (!newRoom) throw new NotFoundException('目标宿舍不存在');
const count = await runner.manager.count(Occupancy, {
where: { roomId: dto.newRoomId, checkOutDate: IsNull() },
});
if (count >= newRoom.capacity) throw new BadRequestException('目标宿舍已满');
// 换房性别约束检查
const student = await runner.manager.findOne(Student, { where: { id: oldOcc.studentId } });
if (student?.gender && newRoom.gender && student.gender !== newRoom.gender) {
throw new BadRequestException(`目标宿舍为${newRoom.gender}生寝室,无法换入`);
}
// 计算新房计费起始日:默认为换房日期次日
const transferDate = new Date(dto.transferDate);
const nextDay = new Date(transferDate);
nextDay.setDate(nextDay.getDate() + 1);
const defaultBillingStart = nextDay.toISOString().split('T')[0];
// 入住新房
const newOcc = runner.manager.create(Occupancy, {
studentId: oldOcc.studentId,
roomId: dto.newRoomId,
checkInDate: dto.transferDate,
billingStartDate: dto.newBillingStartDate || defaultBillingStart,
notes: `${oldOcc.roomId}号房换入`,
});
await runner.manager.save(newOcc);
// 首位入住者确定新房性别
if (student?.gender && !newRoom.gender) {
await runner.manager.update(Room, newRoom.id, { gender: student.gender });
}
if (count + 1 >= newRoom.capacity) {
await runner.manager.update(Room, newRoom.id, { status: 'full' });
}
await runner.commitTransaction();
return { oldOccupancy: oldOcc, newOccupancy: newOcc };
} catch (err) {
await runner.rollbackTransaction();
throw err;
} finally {
await runner.release();
}
}
// 获取某宿舍在指定时间段内的入住记录(用于计费)
async getRoomOccupanciesInPeriod(roomId: number, periodStart: string, periodEnd: string) {
return this.repo
.createQueryBuilder('o')
.leftJoinAndSelect('o.student', 'student')
.where('o.roomId = :roomId', { roomId })
.andWhere('o.billingStartDate <= :periodEnd', { periodEnd })
.andWhere('(o.billingEndDate IS NULL OR o.billingEndDate >= :periodStart)', { periodStart })
.getMany();
}
async remove(id: number) {
const occ = await this.repo.findOne({ where: { id } });
if (!occ) throw new NotFoundException('入住记录不存在');
if (!occ.checkOutDate) throw new BadRequestException('在住记录不能删除,请先办理退宿');
await this.repo.delete(id);
return { message: '删除成功' };
}
async batchRemove(ids: number[]) {
if (!ids || ids.length === 0) throw new BadRequestException('请选择要删除的记录');
const records = await this.repo.find({ where: { id: In(ids) }, relations: ['student'] });
const skipped: string[] = [];
const deletableIds: number[] = [];
for (const occ of records) {
if (!occ.checkOutDate) {
skipped.push(occ.student?.name || `记录${occ.id}`);
} else {
deletableIds.push(occ.id);
}
}
let deleted = 0;
if (deletableIds.length > 0) {
const result = await this.repo
.createQueryBuilder()
.delete()
.where('id IN (:...ids)', { ids: deletableIds })
.execute();
deleted = result.affected || 0;
}
const message =
skipped.length > 0
? `成功删除 ${deleted} 条;${skipped.length} 条在住记录被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''}),请先办理退宿`
: `批量删除成功,共 ${deleted}`;
return { message, deleted, skipped: skipped.length };
}
async batchCheckOut(dto: {
ids: number[];
checkOutDate: string;
billingEndDate?: string;
checkOutReason?: string;
}) {
if (!dto.ids || dto.ids.length === 0) {
throw new BadRequestException('请选择要退宿的记录');
}
const runner = this.dataSource.createQueryRunner();
await runner.connect();
await runner.startTransaction();
let success = 0;
const errors: string[] = [];
try {
for (const id of dto.ids) {
const occ = await runner.manager.findOne(Occupancy, {
where: { id },
relations: ['student'],
});
if (!occ) {
errors.push(`记录${id}不存在`);
continue;
}
if (occ.checkOutDate) {
errors.push(`${occ.student?.name || id}已退宿`);
continue;
}
occ.checkOutDate = dto.checkOutDate;
occ.billingEndDate = dto.billingEndDate || dto.checkOutDate;
occ.checkOutReason = dto.checkOutReason || '';
await runner.manager.save(occ);
// 更新房间状态
await runner.manager.update(Room, occ.roomId, { status: 'available' });
// 如果房间已无在住人员,重置性别
const remaining = await runner.manager.count(Occupancy, {
where: { roomId: occ.roomId, checkOutDate: IsNull() },
});
if (remaining === 0) {
await runner.manager.update(Room, occ.roomId, { gender: null as any });
}
success++;
}
await runner.commitTransaction();
} catch (err) {
await runner.rollbackTransaction();
throw err;
} finally {
await runner.release();
}
return {
success,
failed: errors.length,
message: `已成功退宿 ${success}${errors.length > 0 ? `${errors.length} 条失败` : ''}`,
errors: errors.length > 0 ? errors : undefined,
};
}
/**
* 一键导入入住名单
* 每行数据:姓名、电话、学号、房间号、楼栋、入住日期
* 自动创建不存在的学生和宿舍,并登记入住
*/
async batchImportCheckIn(
rows: {
name: string;
phone?: string;
idNumber?: string;
gender?: string;
ethnicity?: string;
emergencyContact?: string;
emergencyPhone?: string;
organization?: string;
supervisor?: string;
roomNumber: string;
building?: string;
checkInDate: string;
checkOutDate?: string;
}[],
options?: { autoDeposit?: boolean; depositAmount?: number },
) {
let imported = 0;
let skipped = 0;
let depositsCreated = 0;
const errors: string[] = [];
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const rowNum = i + 2; // Excel第2行开始第1行是表头
if (!row.name?.trim() || !row.roomNumber?.trim()) {
skipped++;
continue;
}
try {
// 1. 查找或创建学生
let student = await this.studentRepo.findOne({ where: { name: row.name.trim() } });
if (!student) {
student = await this.studentRepo.save(
this.studentRepo.create({
name: row.name.trim(),
phone: row.phone?.trim() || undefined,
idNumber: row.idNumber?.trim() || undefined,
gender: row.gender?.trim() || undefined,
ethnicity: row.ethnicity?.trim() || undefined,
emergencyContact: row.emergencyContact?.trim() || undefined,
emergencyPhone: row.emergencyPhone?.trim() || undefined,
organization: row.organization?.trim() || undefined,
supervisor: row.supervisor?.trim() || undefined,
}),
);
} else {
// 更新已有学生的缺失信息
const updates: any = {};
if (!student.phone && row.phone?.trim()) updates.phone = row.phone.trim();
if (!student.idNumber && row.idNumber?.trim()) updates.idNumber = row.idNumber.trim();
if (!student.gender && row.gender?.trim()) updates.gender = row.gender.trim();
if (!student.ethnicity && row.ethnicity?.trim()) updates.ethnicity = row.ethnicity.trim();
if (!student.emergencyContact && row.emergencyContact?.trim())
updates.emergencyContact = row.emergencyContact.trim();
if (!student.emergencyPhone && row.emergencyPhone?.trim())
updates.emergencyPhone = row.emergencyPhone.trim();
if (!student.organization && row.organization?.trim())
updates.organization = row.organization.trim();
if (!student.supervisor && row.supervisor?.trim())
updates.supervisor = row.supervisor.trim();
if (Object.keys(updates).length > 0) {
await this.studentRepo.update(student.id, updates);
Object.assign(student, updates);
}
}
// 2. 查找或创建宿舍(使用智能解析)
let room = await this.roomRepo.findOne({ where: { roomNumber: row.roomNumber.trim() } });
if (!room) {
const parsed = RoomsService.parseRoomNumber(row.roomNumber.trim());
room = await this.roomRepo.save(
this.roomRepo.create({
roomNumber: row.roomNumber.trim(),
building: row.building?.trim() || parsed.building || undefined,
floor: parsed.floor || undefined,
capacity: parsed.capacity || 4,
roomType: parsed.roomType || undefined,
}),
);
}
// 3. 检查是否已有活跃入住
const existing = await this.repo.findOne({
where: { studentId: student.id, checkOutDate: IsNull() },
relations: ['room'],
});
if (existing) {
errors.push(
`${rowNum}行: ${row.name} 已在住(${existing.room?.roomNumber || '房间' + existing.roomId}),跳过`,
);
skipped++;
continue;
}
// 4. 检查宿舍容量
const count = await this.repo.count({ where: { roomId: room.id, checkOutDate: IsNull() } });
if (count >= room.capacity) {
errors.push(
`${rowNum}行: 宿舍 ${row.roomNumber} 已满(${count}/${room.capacity}),跳过 ${row.name}`,
);
skipped++;
continue;
}
// 5. 房间级别性别约束
if (student.gender && room.gender && student.gender !== room.gender) {
errors.push(
`${rowNum}行: 宿舍 ${row.roomNumber}${room.gender}生寝室,${row.name}(${student.gender})无法入住,跳过`,
);
skipped++;
continue;
}
// 6. 创建入住记录
const checkInDate = row.checkInDate?.trim() || new Date().toISOString().split('T')[0];
const occData: any = {
studentId: student.id,
roomId: room.id,
checkInDate,
billingStartDate: checkInDate,
};
// 如果有退宿日期,直接记录
if (row.checkOutDate?.trim()) {
occData.checkOutDate = row.checkOutDate.trim();
occData.billingEndDate = row.checkOutDate.trim();
}
await this.repo.save(this.repo.create(occData));
// 7. 首位入住者确定房间性别
if (student.gender && !room.gender) {
await this.roomRepo.update(room.id, { gender: student.gender });
room.gender = student.gender;
}
// 8. 更新宿舍状态
if (!row.checkOutDate?.trim() && count + 1 >= room.capacity) {
await this.roomRepo.update(room.id, { status: 'full' });
}
// 9. 自动收取押金(仅对新入住且非历史记录的学生)
if (options?.autoDeposit && !row.checkOutDate?.trim()) {
const existingDeposit = await this.depositRepo.findOne({
where: { studentId: student.id, status: 'paid' },
});
if (!existingDeposit) {
await this.depositRepo.save(
this.depositRepo.create({
studentId: student.id,
amount: options.depositAmount || 500,
paidDate: checkInDate,
status: 'paid',
notes: '入住导入自动收取',
}),
);
depositsCreated++;
}
}
imported++;
} catch (e: any) {
errors.push(`${rowNum}行: ${row.name} 导入失败 - ${e.message}`);
skipped++;
}
}
const depositMsg = depositsCreated > 0 ? `,自动收取 ${depositsCreated} 笔押金` : '';
return {
message: `成功导入 ${imported} 条入住记录,跳过 ${skipped}${depositMsg}`,
imported,
skipped,
depositsCreated,
errors: errors.length > 0 ? errors : undefined,
};
}
}