forked from wangziqi/gongxue-base
510 lines
19 KiB
TypeScript
510 lines
19 KiB
TypeScript
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 { Bed } from '../entities/bed.entity';
|
||
import { Locker } from '../entities/locker.entity';
|
||
import { Deposit } from '../entities/deposit.entity';
|
||
import { Organization } from '../entities/organization.entity';
|
||
import { uuidV7 } from '../common/uuid-v7';
|
||
import { CheckInDto, CheckOutDto, TransferRoomDto } from './dto/occupancy.dto';
|
||
import { RoomsService } from '../rooms/rooms.service';
|
||
|
||
@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>,
|
||
@InjectRepository(Bed) private bedRepo: Repository<Bed>,
|
||
@InjectRepository(Locker) private lockerRepo: Repository<Locker>,
|
||
@InjectRepository(Organization) private organizationRepo: Repository<Organization>,
|
||
private dataSource: DataSource,
|
||
) {}
|
||
|
||
async findAll(query?: { roomId?: number; studentId?: number; active?: boolean }) {
|
||
const qb = this.repo
|
||
.createQueryBuilder('o')
|
||
.leftJoinAndSelect('o.student', 'student')
|
||
.leftJoinAndSelect('o.room', 'room')
|
||
.leftJoinAndSelect('o.bed', 'bed')
|
||
.leftJoinAndSelect('o.locker', 'locker')
|
||
.leftJoinAndSelect('o.responsibleOrganization', 'responsibleOrganization')
|
||
.orderBy('o.checkInDate', 'DESC');
|
||
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 (dto.bedId) {
|
||
const bed = await this.bedRepo.findOne({ where: { id: dto.bedId, roomId: dto.roomId } });
|
||
if (!bed) throw new BadRequestException('床位不存在或不属于该宿舍');
|
||
if (bed.status !== 'available') throw new BadRequestException('该床位已被占用或维修中');
|
||
}
|
||
|
||
// 柜子校验
|
||
if (dto.lockerId) {
|
||
const locker = await this.lockerRepo.findOne({
|
||
where: { id: dto.lockerId, roomId: dto.roomId },
|
||
});
|
||
if (!locker) throw new BadRequestException('柜子不存在或不属于该宿舍');
|
||
if (locker.status !== 'available') throw new BadRequestException('该柜子已被占用或维修中');
|
||
}
|
||
|
||
const occ = this.repo.create({
|
||
studentId: dto.studentId,
|
||
roomId: dto.roomId,
|
||
checkInDate: dto.checkInDate,
|
||
billingStartDate: dto.billingStartDate || dto.checkInDate,
|
||
stayType: dto.stayType,
|
||
responsibleOrganizationId: dto.responsibleOrganizationId ?? student.organizationId,
|
||
notes: dto.notes,
|
||
bedId: dto.bedId,
|
||
lockerId: dto.lockerId,
|
||
});
|
||
const saved = await this.repo.save(occ);
|
||
|
||
// 更新床位/柜子状态
|
||
if (dto.bedId) {
|
||
await this.bedRepo.update(dto.bedId, { status: 'occupied' });
|
||
}
|
||
if (dto.lockerId) {
|
||
await this.lockerRepo.update(dto.lockerId, { status: 'occupied' });
|
||
}
|
||
|
||
// 更新宿舍状态
|
||
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);
|
||
|
||
// 释放床位/柜子
|
||
if (occ.bedId) {
|
||
await this.bedRepo.update(occ.bedId, { status: 'available' });
|
||
}
|
||
if (occ.lockerId) {
|
||
await this.lockerRepo.update(occ.lockerId, { status: 'available' });
|
||
}
|
||
|
||
// 更新宿舍状态
|
||
await this.roomRepo.update(occ.roomId, { status: 'available' });
|
||
|
||
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);
|
||
// 释放旧床位/柜子
|
||
if (oldOcc.bedId) {
|
||
await runner.manager.update(Bed, oldOcc.bedId, { status: 'available' });
|
||
}
|
||
if (oldOcc.lockerId) {
|
||
await runner.manager.update(Locker, oldOcc.lockerId, { status: 'available' });
|
||
}
|
||
await runner.manager.update(Room, oldOcc.roomId, { status: 'available' });
|
||
// 检查新房容量
|
||
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('目标宿舍已满');
|
||
|
||
// 新床位校验
|
||
if (dto.newBedId) {
|
||
const newBed = await runner.manager.findOne(Bed, {
|
||
where: { id: dto.newBedId, roomId: dto.newRoomId },
|
||
});
|
||
if (!newBed) throw new BadRequestException('目标床位不存在或不属于目标宿舍');
|
||
if (newBed.status !== 'available') throw new BadRequestException('目标床位已被占用');
|
||
}
|
||
if (dto.newLockerId) {
|
||
const newLocker = await runner.manager.findOne(Locker, {
|
||
where: { id: dto.newLockerId, roomId: dto.newRoomId },
|
||
});
|
||
if (!newLocker) throw new BadRequestException('目标柜子不存在或不属于目标宿舍');
|
||
if (newLocker.status !== 'available') throw new BadRequestException('目标柜子已被占用');
|
||
}
|
||
|
||
// 计算新房计费起始日:默认为换房日期次日
|
||
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,
|
||
stayType: oldOcc.stayType,
|
||
responsibleOrganizationId: oldOcc.responsibleOrganizationId,
|
||
notes: `从${oldOcc.roomId}号房换入`,
|
||
bedId: dto.newBedId,
|
||
lockerId: dto.newLockerId,
|
||
});
|
||
await runner.manager.save(newOcc);
|
||
|
||
// 更新新床位/柜子状态
|
||
if (dto.newBedId) {
|
||
await runner.manager.update(Bed, dto.newBedId, { status: 'occupied' });
|
||
}
|
||
if (dto.newLockerId) {
|
||
await runner.manager.update(Locker, dto.newLockerId, { status: 'occupied' });
|
||
}
|
||
|
||
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' });
|
||
// 释放床位/柜子
|
||
if (occ.bedId) await runner.manager.update(Bed, occ.bedId, { status: 'available' });
|
||
if (occ.lockerId)
|
||
await runner.manager.update(Locker, occ.lockerId, { status: 'available' });
|
||
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 organization = row.organization?.trim()
|
||
? await this.organizationRepo.findOne({ where: { name: row.organization.trim() } })
|
||
: await this.organizationRepo.findOne({ where: { isHost: true, status: 'active' } });
|
||
if (!organization && row.organization?.trim()) {
|
||
organization = await this.organizationRepo.save(
|
||
this.organizationRepo.create({
|
||
publicId: uuidV7(),
|
||
code: `ORG_${Date.now()}_${i}`,
|
||
name: row.organization.trim(),
|
||
isHost: false,
|
||
status: 'active',
|
||
}),
|
||
);
|
||
}
|
||
if (!organization) throw new BadRequestException('尚未配置本机构');
|
||
|
||
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,
|
||
organizationId: organization.id,
|
||
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.organizationId) updates.organizationId = organization.id;
|
||
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;
|
||
}
|
||
|
||
// 6. 创建入住记录
|
||
const checkInDate = row.checkInDate?.trim() || new Date().toISOString().split('T')[0];
|
||
const occData: any = {
|
||
studentId: student.id,
|
||
roomId: room.id,
|
||
checkInDate,
|
||
billingStartDate: checkInDate,
|
||
responsibleOrganizationId: student.organizationId || organization.id,
|
||
};
|
||
// 如果有退宿日期,直接记录
|
||
if (row.checkOutDate?.trim()) {
|
||
occData.checkOutDate = row.checkOutDate.trim();
|
||
occData.billingEndDate = row.checkOutDate.trim();
|
||
}
|
||
await this.repo.save(this.repo.create(occData));
|
||
|
||
// 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,
|
||
};
|
||
}
|
||
}
|