feat: 重构各业务模块管理页面与服务
This commit is contained in:
420
apps/server/src/occupancies/occupancy-operations.service.ts
Normal file
420
apps/server/src/occupancies/occupancy-operations.service.ts
Normal file
@@ -0,0 +1,420 @@
|
||||
import { Injectable, NotFoundException, BadRequestException, Optional } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, DataSource, IsNull, 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 { RoomInspectionDetail } from '../entities/room-inspection-detail.entity';
|
||||
import { CheckOutDto, TransferRoomDto } from './dto/occupancy.dto';
|
||||
import { OccupancyImportService } from './occupancy-import.service';
|
||||
import { withPessimisticWriteLock } from './occupancy-lock';
|
||||
|
||||
@Injectable()
|
||||
export class OccupancyOperationsService {
|
||||
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,
|
||||
@InjectRepository(RoomInspectionDetail)
|
||||
private inspectionDetailRepo: Repository<RoomInspectionDetail>,
|
||||
@Optional() private imports?: OccupancyImportService,
|
||||
) {}
|
||||
|
||||
private get imp(): OccupancyImportService {
|
||||
if (!this.imports) this.imports = new OccupancyImportService(this.dataSource);
|
||||
return this.imports;
|
||||
}
|
||||
|
||||
private normalizePositiveMoney(value: number, label: string): number {
|
||||
if (!Number.isFinite(value) || value < 0) {
|
||||
throw new BadRequestException(`${label}必须为非负数字`);
|
||||
}
|
||||
return Math.round(value * 100) / 100;
|
||||
}
|
||||
|
||||
private assertDateOnly(value: string, label: string): void {
|
||||
if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) {
|
||||
throw new BadRequestException(`${label}格式错误,应为 YYYY-MM-DD`);
|
||||
}
|
||||
const date = new Date(`${value}T00:00:00Z`);
|
||||
if (Number.isNaN(date.getTime())) throw new BadRequestException(`${label}不是有效日期`);
|
||||
}
|
||||
|
||||
private assertDateOrder(start: string, end: string | undefined, message: string): void {
|
||||
if (end && start > end) throw new BadRequestException(message);
|
||||
}
|
||||
|
||||
async checkOut(occupancyId: number, dto: CheckOutDto) {
|
||||
return this.dataSource.transaction(async (manager) => {
|
||||
const occ = await withPessimisticWriteLock(
|
||||
manager
|
||||
.createQueryBuilder(Occupancy, 'occupancy')
|
||||
.where('occupancy.id = :id', { id: occupancyId }),
|
||||
this.dataSource).getOne();
|
||||
if (!occ) throw new NotFoundException('入住记录不存在');
|
||||
if (occ.checkOutDate) throw new BadRequestException('该记录已退宿');
|
||||
this.assertDateOrder(occ.checkInDate, dto.checkOutDate, '退宿日期不能早于入住日期');
|
||||
this.assertDateOrder(
|
||||
occ.billingStartDate || occ.checkInDate,
|
||||
dto.billingEndDate || dto.checkOutDate,
|
||||
'计费截止日不能早于计费起始日',
|
||||
);
|
||||
occ.checkOutDate = dto.checkOutDate;
|
||||
occ.billingEndDate = dto.billingEndDate || dto.checkOutDate;
|
||||
occ.checkOutReason = dto.checkOutReason || '';
|
||||
await manager.save(occ);
|
||||
if (occ.bedId) await manager.update(Bed, occ.bedId, { status: 'available' });
|
||||
if (occ.lockerId) await manager.update(Locker, occ.lockerId, { status: 'available' });
|
||||
await manager.update(Room, 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 withPessimisticWriteLock(
|
||||
runner.manager
|
||||
.createQueryBuilder(Occupancy, 'occupancy')
|
||||
.where('occupancy.id = :id', { id: occupancyId }),
|
||||
this.dataSource).getOne();
|
||||
if (!oldOcc) throw new NotFoundException('入住记录不存在');
|
||||
if (oldOcc.checkOutDate) throw new BadRequestException('该记录已退宿');
|
||||
if (oldOcc.roomId === dto.newRoomId)
|
||||
throw new BadRequestException('目标宿舍不能与当前宿舍相同');
|
||||
this.assertDateOrder(oldOcc.checkInDate, dto.transferDate, '换房日期不能早于原入住日期');
|
||||
this.assertDateOrder(
|
||||
oldOcc.billingStartDate || oldOcc.checkInDate,
|
||||
dto.oldBillingEndDate || dto.transferDate,
|
||||
'原宿舍计费截止日不能早于计费起始日',
|
||||
);
|
||||
|
||||
// 退旧房
|
||||
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 withPessimisticWriteLock(
|
||||
runner.manager
|
||||
.createQueryBuilder(Room, 'room')
|
||||
.where('room.id = :roomId', { roomId: dto.newRoomId }),
|
||||
this.dataSource).getOne();
|
||||
if (!newRoom) throw new NotFoundException('目标宿舍不存在');
|
||||
if (newRoom.status === 'archived' || newRoom.status === 'maintenance') {
|
||||
throw new BadRequestException('目标宿舍当前不可入住');
|
||||
}
|
||||
const count = await runner.manager.count(Occupancy, {
|
||||
where: { roomId: dto.newRoomId, checkOutDate: IsNull() },
|
||||
});
|
||||
if (count >= (newRoom.capacity ?? 0)) throw new BadRequestException('目标宿舍已满');
|
||||
|
||||
// 新床位校验
|
||||
if (dto.newBedId) {
|
||||
const newBed = await withPessimisticWriteLock(
|
||||
runner.manager
|
||||
.createQueryBuilder(Bed, 'bed')
|
||||
.where('bed.id = :bedId AND bed.roomId = :roomId', {
|
||||
bedId: dto.newBedId,
|
||||
roomId: dto.newRoomId,
|
||||
}),
|
||||
this.dataSource).getOne();
|
||||
if (!newBed) throw new BadRequestException('目标床位不存在或不属于目标宿舍');
|
||||
if (newBed.status !== 'available') throw new BadRequestException('目标床位已被占用');
|
||||
}
|
||||
if (dto.newLockerId) {
|
||||
const newLocker = await withPessimisticWriteLock(
|
||||
runner.manager
|
||||
.createQueryBuilder(Locker, 'locker')
|
||||
.where('locker.id = :lockerId AND locker.roomId = :roomId', {
|
||||
lockerId: dto.newLockerId,
|
||||
roomId: dto.newRoomId,
|
||||
}),
|
||||
this.dataSource).getOne();
|
||||
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];
|
||||
this.assertDateOrder(
|
||||
dto.transferDate,
|
||||
dto.newBillingStartDate || defaultBillingStart,
|
||||
'新宿舍计费起始日不能早于换房日期',
|
||||
);
|
||||
|
||||
// 入住新房
|
||||
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 ?? 0)) {
|
||||
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('在住记录不能归档,请先办理退宿');
|
||||
if (occ.status === 'archived') throw new BadRequestException('入住记录已归档');
|
||||
await this.repo.update(id, { status: 'archived' });
|
||||
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 archived = 0;
|
||||
if (deletableIds.length > 0) {
|
||||
const result = await this.repo
|
||||
.createQueryBuilder()
|
||||
.update()
|
||||
.set({ status: 'archived' })
|
||||
.where('id IN (:...ids)', { ids: deletableIds })
|
||||
.execute();
|
||||
archived = result.affected || 0;
|
||||
}
|
||||
const message =
|
||||
skipped.length > 0
|
||||
? `成功归档 ${archived} 条;${skipped.length} 条在住记录被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''}),请先办理退宿`
|
||||
: `批量归档成功,共 ${archived} 条`;
|
||||
return { message, archived, skipped: skipped.length };
|
||||
}
|
||||
|
||||
async batchRestore(ids: number[]) {
|
||||
const uniqueIds = [...new Set(ids || [])];
|
||||
if (uniqueIds.length === 0) throw new BadRequestException('请选择要恢复的记录');
|
||||
if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) {
|
||||
throw new BadRequestException('入住记录 ID 无效');
|
||||
}
|
||||
const records = await this.repo.find({ where: { id: In(uniqueIds) } });
|
||||
if (records.length !== uniqueIds.length) throw new NotFoundException('部分入住记录不存在');
|
||||
if (records.some((record) => record.status === 'archived' && !record.checkOutDate)) {
|
||||
throw new BadRequestException('选中记录包含未退宿的异常归档记录');
|
||||
}
|
||||
|
||||
const targetIds = records
|
||||
.filter((record) => record.status === 'archived')
|
||||
.map((record) => record.id);
|
||||
const skipped = records.length - targetIds.length;
|
||||
let restored = 0;
|
||||
if (targetIds.length > 0) {
|
||||
const result = await this.repo
|
||||
.createQueryBuilder()
|
||||
.update()
|
||||
.set({ status: 'active' })
|
||||
.where('id IN (:...ids)', { ids: targetIds })
|
||||
.execute();
|
||||
restored = result.affected || 0;
|
||||
}
|
||||
return { message: `已批量恢复 ${restored} 条入住记录`, restored, skipped };
|
||||
}
|
||||
|
||||
async purge(id: number) {
|
||||
const occ = await this.repo.findOne({ where: { id } });
|
||||
if (!occ) throw new NotFoundException('入住记录不存在');
|
||||
if (occ.status !== 'archived')
|
||||
throw new BadRequestException('仅已归档入住记录可以永久删除,请先归档');
|
||||
const detailCount = await this.inspectionDetailRepo.count({ where: { occupancyId: id } });
|
||||
if (detailCount > 0) throw new BadRequestException('该入住记录已被查寝记录引用,无法永久删除');
|
||||
await this.repo.delete(id);
|
||||
return { message: '已永久删除入住记录(不可恢复)' };
|
||||
}
|
||||
|
||||
async batchPurge(ids: number[]) {
|
||||
const uniqueIds = [...new Set(ids || [])];
|
||||
if (uniqueIds.length === 0) throw new BadRequestException('请选择要永久删除的入住记录');
|
||||
if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) {
|
||||
throw new BadRequestException('入住记录 ID 无效');
|
||||
}
|
||||
const records = await this.repo.find({ where: { id: In(uniqueIds) }, relations: ['student'] });
|
||||
if (records.length !== uniqueIds.length) throw new NotFoundException('部分入住记录不存在');
|
||||
|
||||
const deleted: number[] = [];
|
||||
const skipped: string[] = [];
|
||||
for (const occ of records) {
|
||||
if (occ.status !== 'archived') {
|
||||
skipped.push(`${occ.student?.name || `记录${occ.id}`}(未归档)`);
|
||||
continue;
|
||||
}
|
||||
const detailCount = await this.inspectionDetailRepo.count({ where: { occupancyId: occ.id } });
|
||||
if (detailCount > 0) {
|
||||
skipped.push(`${occ.student?.name || `记录${occ.id}`}(存在关联数据)`);
|
||||
continue;
|
||||
}
|
||||
await this.repo.delete(occ.id);
|
||||
deleted.push(occ.id);
|
||||
}
|
||||
const message =
|
||||
skipped.length > 0
|
||||
? `已永久删除 ${deleted.length} 条;${skipped.length} 条被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''})`
|
||||
: `已永久删除 ${deleted.length} 条入住记录(不可恢复)`;
|
||||
return { message, deleted: deleted.length, 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;
|
||||
}
|
||||
try {
|
||||
this.assertDateOrder(occ.checkInDate, dto.checkOutDate, '退宿日期不能早于入住日期');
|
||||
this.assertDateOrder(
|
||||
occ.billingStartDate || occ.checkInDate,
|
||||
dto.billingEndDate || dto.checkOutDate,
|
||||
'计费截止日不能早于计费起始日',
|
||||
);
|
||||
} catch (error) {
|
||||
errors.push(`${occ.student?.name || id}: ${(error as BadRequestException).message}`);
|
||||
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;
|
||||
studentNo?: string;
|
||||
idNumber?: string;
|
||||
gender?: string;
|
||||
ethnicity?: string;
|
||||
emergencyContact?: string;
|
||||
emergencyPhone?: string;
|
||||
supervisor?: string;
|
||||
roomNumber: string;
|
||||
building?: string;
|
||||
checkInDate: string;
|
||||
billingStartDate?: string;
|
||||
checkOutDate?: string;
|
||||
bedNumber?: string;
|
||||
lockerNumber?: string;
|
||||
stayType?: string;
|
||||
notes?: string;
|
||||
}[],
|
||||
options?: { autoDeposit?: boolean; depositAmount?: number },
|
||||
) {
|
||||
return this.imp.batchImportCheckIn(rows, options);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user