feat(task1): restructure directories for turborepo monorepo
- Move backend/ to apps/server/ via git mv - Move frontend/ to apps/admin/ via git mv - Create packages/typescript-config/ with base, nestjs, and react-vite presets
This commit is contained in:
69
apps/server/src/occupancies/dto/occupancy.dto.ts
Normal file
69
apps/server/src/occupancies/dto/occupancy.dto.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { IsInt, IsString, IsOptional, IsArray } from 'class-validator';
|
||||
|
||||
export class CheckInDto {
|
||||
@IsInt()
|
||||
studentId: number;
|
||||
|
||||
@IsInt()
|
||||
roomId: number;
|
||||
|
||||
@IsString()
|
||||
checkInDate: string; // YYYY-MM-DD
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
billingStartDate?: string; // 默认=checkInDate,可调整
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
export class CheckOutDto {
|
||||
@IsString()
|
||||
checkOutDate: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
billingEndDate?: string; // 默认=checkOutDate
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
checkOutReason?: string;
|
||||
}
|
||||
|
||||
export class TransferRoomDto {
|
||||
@IsInt()
|
||||
newRoomId: number;
|
||||
|
||||
@IsString()
|
||||
transferDate: string; // YYYY-MM-DD
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
oldBillingEndDate?: string; // 旧房计费截止日,默认=transferDate
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
newBillingStartDate?: string; // 新房计费起始日,默认=transferDate次日
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
export class BatchCheckOutDto {
|
||||
@IsArray()
|
||||
ids: number[];
|
||||
|
||||
@IsString()
|
||||
checkOutDate: string; // YYYY-MM-DD
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
billingEndDate?: string; // 默认=checkOutDate
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
checkOutReason?: string;
|
||||
}
|
||||
231
apps/server/src/occupancies/occupancies.controller.ts
Normal file
231
apps/server/src/occupancies/occupancies.controller.ts
Normal file
@@ -0,0 +1,231 @@
|
||||
import { Controller, Get, Post, Put, Delete, Param, Body, Query, UseGuards, Request, Res, UseInterceptors, UploadedFile } from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import type { Response } from 'express';
|
||||
import { OccupanciesService } from './occupancies.service';
|
||||
import { CheckInDto, CheckOutDto, TransferRoomDto, BatchCheckOutDto } from './dto/occupancy.dto';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||
import { extractRequestInfo } from '../common/request-utils';
|
||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||
import * as ExcelJS from 'exceljs';
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('occupancies')
|
||||
export class OccupanciesController {
|
||||
constructor(private service: OccupanciesService, private logService: OperationLogsService) {}
|
||||
|
||||
@Get()
|
||||
@RequirePermission('occupancy:view')
|
||||
findAll(
|
||||
@Query('roomId') roomId?: string,
|
||||
@Query('studentId') studentId?: string,
|
||||
@Query('active') active?: string,
|
||||
) {
|
||||
return this.service.findAll({
|
||||
roomId: roomId ? +roomId : undefined,
|
||||
studentId: studentId ? +studentId : undefined,
|
||||
active: active === 'true',
|
||||
});
|
||||
}
|
||||
|
||||
@Post('batch-check-out')
|
||||
@RequirePermission('occupancy:checkout')
|
||||
async batchCheckOut(@Body() dto: BatchCheckOutDto, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.batchCheckOut(dto);
|
||||
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '入住', action: '批量退宿', detail: `退宿 ${dto.ids.length} 人,日期 ${dto.checkOutDate}`, ipAddress, userAgent });
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post('check-in')
|
||||
@RequirePermission('occupancy:checkin')
|
||||
async checkIn(@Body() dto: CheckInDto, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.checkIn(dto);
|
||||
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '入住', action: '入住登记', targetId: result.id, targetType: 'occupancy', detail: `学生${dto.studentId} 入住房间${dto.roomId}`, ipAddress, userAgent });
|
||||
return result;
|
||||
}
|
||||
|
||||
@Put(':id/check-out')
|
||||
@RequirePermission('occupancy:checkout')
|
||||
async checkOut(@Param('id') id: string, @Body() dto: CheckOutDto, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.checkOut(+id, dto);
|
||||
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '入住', action: '退宿', targetId: +id, targetType: 'occupancy', ipAddress, userAgent });
|
||||
return result;
|
||||
}
|
||||
|
||||
@Put(':id/transfer')
|
||||
@RequirePermission('occupancy:transfer')
|
||||
async transferRoom(@Param('id') id: string, @Body() dto: TransferRoomDto, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.transferRoom(+id, dto);
|
||||
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '入住', action: '换房', targetId: +id, targetType: 'occupancy', detail: `换到房间${dto.newRoomId}`, ipAddress, userAgent });
|
||||
return result;
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermission('occupancy:view')
|
||||
async remove(@Param('id') id: string, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.remove(+id);
|
||||
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '入住', action: '删除入住记录', targetId: +id, targetType: 'occupancy', ipAddress, userAgent });
|
||||
return result;
|
||||
}
|
||||
|
||||
@Post('batch-delete')
|
||||
@RequirePermission('occupancy:view')
|
||||
async batchRemove(@Body() body: { ids: number[] }, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.batchRemove(body.ids || []);
|
||||
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '入住', action: '批量删除入住记录', detail: `IDs: ${(body.ids || []).join(',')}`, ipAddress, userAgent });
|
||||
return result;
|
||||
}
|
||||
|
||||
@Get('export')
|
||||
@RequirePermission('occupancy:view')
|
||||
async exportExcel(@Query('active') active?: string, @Res() res?: Response) {
|
||||
const records = await this.service.findAll({ active: active === 'true' });
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const ws = workbook.addWorksheet('入住记录');
|
||||
ws.columns = [
|
||||
{ header: '宿舍号', key: 'roomNumber', width: 12 },
|
||||
{ header: '楼栋', key: 'building', width: 12 },
|
||||
{ header: '学生姓名', key: 'studentName', width: 12 },
|
||||
{ header: '性别', key: 'gender', width: 8 },
|
||||
{ header: '电话', key: 'phone', width: 18 },
|
||||
{ header: '学号/身份证', key: 'idNumber', width: 22 },
|
||||
{ header: '所属机构', key: 'organization', width: 18 },
|
||||
{ header: '负责人/班主任', key: 'supervisor', width: 15 },
|
||||
{ header: '入住日期', key: 'checkInDate', width: 14 },
|
||||
{ header: '退宿日期', key: 'checkOutDate', width: 14 },
|
||||
{ header: '计费起始', key: 'billingStartDate', width: 14 },
|
||||
{ header: '计费截止', key: 'billingEndDate', width: 14 },
|
||||
{ header: '退宿原因', key: 'checkOutReason', width: 12 },
|
||||
];
|
||||
ws.getRow(1).font = { bold: true };
|
||||
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
|
||||
for (const r of records) {
|
||||
ws.addRow({
|
||||
roomNumber: r.room?.roomNumber || '',
|
||||
building: r.room?.building || '',
|
||||
studentName: r.student?.name || '',
|
||||
gender: r.student?.gender || '',
|
||||
phone: r.student?.phone || '',
|
||||
idNumber: r.student?.idNumber || '',
|
||||
organization: r.student?.organization || '',
|
||||
supervisor: r.student?.supervisor || '',
|
||||
checkInDate: r.checkInDate || '',
|
||||
checkOutDate: r.checkOutDate || '',
|
||||
billingStartDate: r.billingStartDate || '',
|
||||
billingEndDate: r.billingEndDate || '',
|
||||
checkOutReason: r.checkOutReason || '',
|
||||
});
|
||||
}
|
||||
res!.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
res!.setHeader('Content-Disposition', 'attachment; filename=occupancies.xlsx');
|
||||
await workbook.xlsx.write(res!);
|
||||
res!.end();
|
||||
}
|
||||
|
||||
@Get('template')
|
||||
@RequirePermission('occupancy:view')
|
||||
async downloadTemplate(@Res() res: Response) {
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const ws = workbook.addWorksheet('入住名单导入模板');
|
||||
ws.columns = [
|
||||
{ header: '宿舍号', key: 'roomNumber', width: 12 },
|
||||
{ header: '床位号', key: 'bedNumber', width: 8 },
|
||||
{ header: '姓名', key: 'name', width: 12 },
|
||||
{ header: '性别', key: 'gender', width: 8 },
|
||||
{ header: '民族', key: 'ethnicity', width: 10 },
|
||||
{ header: '电话', key: 'phone', width: 15 },
|
||||
{ header: '学号/身份证', key: 'idNumber', width: 22 },
|
||||
{ header: '入住时间', key: 'checkInDate', width: 14 },
|
||||
{ header: '离宿时间', key: 'checkOutDate', width: 14 },
|
||||
{ header: '紧急联系人', key: 'emergencyContact', width: 15 },
|
||||
{ header: '紧急联系人电话', key: 'emergencyPhone', width: 18 },
|
||||
{ header: '所属机构', key: 'organization', width: 18 },
|
||||
{ header: '负责人/班主任', key: 'supervisor', width: 15 },
|
||||
];
|
||||
ws.getRow(1).font = { bold: true };
|
||||
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
|
||||
// 添加说明行
|
||||
ws.addRow({ roomNumber: '4-102', bedNumber: 1, name: '张三', gender: '男', ethnicity: '汉族', phone: '13800138000', idNumber: '2024001', checkInDate: '2026-04-21', checkOutDate: '', emergencyContact: '张父', emergencyPhone: '13900000000', organization: '', supervisor: '' });
|
||||
ws.addRow({ roomNumber: '4-102', bedNumber: 2, name: '李四', gender: '男', ethnicity: '汉族', phone: '13800138001', idNumber: '2024002', checkInDate: '2026-04-21', checkOutDate: '', emergencyContact: '', emergencyPhone: '', organization: 'XXX教育科技', supervisor: '王老师' });
|
||||
// 添加使用说明sheet
|
||||
const helpWs = workbook.addWorksheet('使用说明');
|
||||
helpWs.getColumn(1).width = 60;
|
||||
helpWs.addRow(['【入住名单导入说明】']);
|
||||
helpWs.addRow(['1. 导入入住名单会自动创建不存在的学生和宿舍,无需单独导入学生或宿舍']);
|
||||
helpWs.addRow(['2. 宿舍号会智能解析楼栋、楼层和房间类型(如4-102自动识别为4号楼1层四人间)']);
|
||||
helpWs.addRow(['3. 同一宿舍号的多个学生可合并宿舍号单元格,系统会自动继承上一行的宿舍号']);
|
||||
helpWs.addRow(['4. 已存在的学生(按姓名匹配)会自动补充缺失信息(性别、民族等)']);
|
||||
helpWs.addRow(['5. 已有在住记录的学生会自动跳过,不会重复入住']);
|
||||
helpWs.addRow(['6. 填了离宿时间的记录会直接标记为已退宿(用于导入历史数据)']);
|
||||
helpWs.addRow(['7. 性别约束:同一宿舍只能住同性别学生,首位入住者确定宿舍性别']);
|
||||
helpWs.addRow(['8. 床位号仅做标识参考,不影响入住逻辑']);
|
||||
helpWs.getRow(1).font = { bold: true, size: 14 };
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
res.setHeader('Content-Disposition', 'attachment; filename=checkin_template.xlsx');
|
||||
await workbook.xlsx.write(res);
|
||||
res.end();
|
||||
}
|
||||
|
||||
@Post('import')
|
||||
@RequirePermission('occupancy:checkin')
|
||||
@UseInterceptors(FileInterceptor('file'))
|
||||
async importCheckIn(@UploadedFile() file: Express.Multer.File, @Request() req: any, @Query('autoDeposit') autoDeposit?: string, @Query('depositAmount') depositAmount?: string) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.load(file.buffer as any);
|
||||
const ws = workbook.worksheets[0];
|
||||
const rows: any[] = [];
|
||||
let lastRoomNumber = '';
|
||||
|
||||
ws.eachRow((row, idx) => {
|
||||
if (idx === 1) return; // 跳过表头
|
||||
|
||||
// 宿舍号可能是合并单元格,需要继承上一行
|
||||
const roomNumberVal = row.getCell(1).value;
|
||||
const roomNumber = roomNumberVal ? String(roomNumberVal).trim() : '';
|
||||
if (roomNumber) lastRoomNumber = roomNumber;
|
||||
|
||||
const name = String(row.getCell(3).value || '').trim();
|
||||
if (!name) return; // 无姓名则跳过空行
|
||||
|
||||
// 解析日期
|
||||
const parseDate = (cell: any): string => {
|
||||
const val = cell.value;
|
||||
if (!val) return '';
|
||||
if (val instanceof Date) return val.toISOString().split('T')[0];
|
||||
const s = String(val).trim();
|
||||
// 处理 "YYYY/MM/DD" 或 "YYYY-MM-DD" 或 "YYYY.MM.DD"
|
||||
const m = s.match(/(\d{4})[\/\-\.](\d{1,2})[\/\-\.](\d{1,2})/);
|
||||
if (m) return `${m[1]}-${m[2].padStart(2, '0')}-${m[3].padStart(2, '0')}`;
|
||||
return s;
|
||||
};
|
||||
|
||||
rows.push({
|
||||
name,
|
||||
roomNumber: lastRoomNumber,
|
||||
gender: String(row.getCell(4).value || '').trim() || undefined,
|
||||
ethnicity: String(row.getCell(5).value || '').trim() || undefined,
|
||||
phone: String(row.getCell(6).value || '').trim() || undefined,
|
||||
idNumber: String(row.getCell(7).value || '').trim() || undefined,
|
||||
checkInDate: parseDate(row.getCell(8)),
|
||||
checkOutDate: parseDate(row.getCell(9)) || undefined,
|
||||
emergencyContact: String(row.getCell(10).value || '').trim() || undefined,
|
||||
emergencyPhone: String(row.getCell(11).value || '').trim() || undefined,
|
||||
organization: String(row.getCell(12).value || '').trim() || undefined,
|
||||
supervisor: String(row.getCell(13).value || '').trim() || undefined,
|
||||
});
|
||||
});
|
||||
const result = await this.service.batchImportCheckIn(rows, {
|
||||
autoDeposit: autoDeposit === 'true',
|
||||
depositAmount: depositAmount ? +depositAmount : undefined,
|
||||
});
|
||||
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '入住', action: '批量导入入住', detail: result.message, ipAddress, userAgent });
|
||||
return result;
|
||||
}
|
||||
}
|
||||
17
apps/server/src/occupancies/occupancies.module.ts
Normal file
17
apps/server/src/occupancies/occupancies.module.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/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 { OccupanciesService } from './occupancies.service';
|
||||
import { OccupanciesController } from './occupancies.controller';
|
||||
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Occupancy, Room, Student, Deposit]), OperationLogsModule],
|
||||
controllers: [OccupanciesController],
|
||||
providers: [OccupanciesService],
|
||||
exports: [OccupanciesService],
|
||||
})
|
||||
export class OccupanciesModule {}
|
||||
393
apps/server/src/occupancies/occupancies.service.ts
Normal file
393
apps/server/src/occupancies/occupancies.service.ts
Normal file
@@ -0,0 +1,393 @@
|
||||
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';
|
||||
|
||||
@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,
|
||||
) {}
|
||||
|
||||
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');
|
||||
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,
|
||||
notes: dto.notes,
|
||||
});
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user