import { Injectable, BadRequestException } from '@nestjs/common'; import { DataSource, IsNull, DeepPartial } from 'typeorm'; import { Occupancy, Room, Student, Deposit, Bed, Locker, Organization } from '../entities'; import { RoomsService } from '../rooms/rooms.service'; class ImportRowSkipped extends Error {} type StudentUpdateFields = Pick< Student, | 'studentNo' | 'idNumber' | 'gender' | 'ethnicity' | 'emergencyContact' | 'emergencyPhone' | 'supervisor' >; @Injectable() export class OccupancyImportService { constructor(private dataSource: DataSource) {} 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 }, ) { let imported = 0; let skipped = 0; let depositsCreated = 0; const errors: string[] = []; const importDepositAmount = options?.autoDeposit ? this.normalizePositiveMoney(options.depositAmount ?? 500, '押金金额') : undefined; 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 { const result = await this.dataSource.transaction(async (manager) => { const occupancyRepo = manager.getRepository(Occupancy); const roomRepo = manager.getRepository(Room); const studentRepo = manager.getRepository(Student); const depositRepo = manager.getRepository(Deposit); const bedRepo = manager.getRepository(Bed); const lockerRepo = manager.getRepository(Locker); const organizationRepo = manager.getRepository(Organization); let rowDepositsCreated = 0; // 1. 通过手机号关联学生;未找到时创建学生并归入本机构 const phone = row.phone?.trim(); if (!phone) throw new BadRequestException('手机号不能为空,无法关联学生'); let student = await studentRepo.findOne({ where: { phone } }); if (!student) { const hostOrganization = await organizationRepo.findOne({ where: { isHost: true, status: 'active' }, }); if (!hostOrganization) throw new BadRequestException('尚未配置本机构'); student = await studentRepo.save( studentRepo.create({ name: row.name.trim(), phone, studentNo: row.studentNo?.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: hostOrganization.id, supervisor: row.supervisor?.trim() || undefined, }), ); } else { // 更新已有学生的缺失信息 const updates: Partial = {}; if (!student.studentNo && row.studentNo?.trim()) updates.studentNo = row.studentNo.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.supervisor && row.supervisor?.trim()) updates.supervisor = row.supervisor.trim(); if (Object.keys(updates).length > 0) { await studentRepo.update(student.id, updates); Object.assign(student, updates); } } // 2. 查找或创建宿舍(使用智能解析) let room = await roomRepo.findOne({ where: { roomNumber: row.roomNumber.trim() } }); if (!room) { const parsed = RoomsService.parseRoomNumber(row.roomNumber.trim()); room = await roomRepo.save( 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, }), ); } const checkInDate = row.checkInDate?.trim() || new Date().toISOString().split('T')[0]; const checkOutDate = row.checkOutDate?.trim(); const billingStartDate = row.billingStartDate?.trim() || checkInDate; const isHistoricalRecord = Boolean(checkOutDate); this.assertDateOnly(checkInDate, '入住日期'); this.assertDateOnly(billingStartDate, '计费起始日'); this.assertDateOrder(checkInDate, billingStartDate, '计费起始日不能早于入住日期'); if (checkOutDate) { this.assertDateOnly(checkOutDate, '退宿日期'); this.assertDateOrder(checkInDate, checkOutDate, '退宿日期不能早于入住日期'); this.assertDateOrder(billingStartDate, checkOutDate, '退宿日期不能早于计费起始日'); } // 3. 检查是否已有活跃入住(历史记录不影响当前入住) const existing = await occupancyRepo.findOne({ where: { studentId: student.id, checkOutDate: IsNull() }, relations: ['room'], }); if (existing && !isHistoricalRecord) { throw new ImportRowSkipped( `第${rowNum}行: ${row.name} 已在住(${existing.room?.roomNumber || '房间' + existing.roomId}),跳过`, ); } // 4. 检查宿舍容量 const count = await occupancyRepo.count({ where: { roomId: room.id, checkOutDate: IsNull() }, }); if (!isHistoricalRecord && count >= (room.capacity ?? 0)) { throw new ImportRowSkipped( `第${rowNum}行: 宿舍 ${row.roomNumber} 已满(${count}/${room.capacity ?? '?'}),跳过 ${row.name}`, ); } // 5. 匹配或创建床位、柜子,并校验是否可用 let bed: Bed | null = null; if (row.bedNumber?.trim()) { const bedNumber = row.bedNumber.trim(); bed = await bedRepo.findOne({ where: { roomId: room.id, bedNumber } }); if (!bed) { const existingBedCount = await bedRepo.count({ where: { roomId: room.id } }); if (existingBedCount >= (room.capacity ?? 0)) { throw new BadRequestException( `宿舍 ${room.roomNumber} 已有 ${existingBedCount} 张床位,不能超过额定人数 ${room.capacity ?? '?'}`, ); } bed = await bedRepo.save( bedRepo.create({ roomId: room.id, bedNumber, status: 'available' }), ); } if (!isHistoricalRecord && bed.status !== 'available') { throw new BadRequestException(`床位 ${bedNumber} 已被占用或维修中`); } } let locker: Locker | null = null; if (row.lockerNumber?.trim()) { const lockerNumber = row.lockerNumber.trim(); locker = await lockerRepo.findOne({ where: { roomId: room.id, lockerNumber } }); if (!locker) { locker = await lockerRepo.save( lockerRepo.create({ roomId: room.id, lockerNumber, status: 'available' }), ); } if (!isHistoricalRecord && locker.status !== 'available') { throw new BadRequestException(`柜子 ${lockerNumber} 已被占用或维修中`); } } // 6. 创建入住记录 const occData: DeepPartial = { studentId: student.id, roomId: room.id, checkInDate, billingStartDate, stayType: row.stayType || undefined, responsibleOrganizationId: student.organizationId, notes: row.notes || undefined, bedId: bed?.id, lockerId: locker?.id, }; // 如果有退宿日期,直接记录 if (checkOutDate) { occData.checkOutDate = checkOutDate; occData.billingEndDate = checkOutDate; } await occupancyRepo.save(occupancyRepo.create(occData)); // 7. 更新床位、柜子和宿舍状态 if (!isHistoricalRecord) { if (bed) await bedRepo.update(bed.id, { status: 'occupied' }); if (locker) await lockerRepo.update(locker.id, { status: 'occupied' }); if (count + 1 >= (room.capacity ?? 0)) { await roomRepo.update(room.id, { status: 'full' }); } } // 9. 自动收取押金(仅对新入住且非历史记录的学生) if (options?.autoDeposit && !isHistoricalRecord) { const existingDeposit = await depositRepo.findOne({ where: { studentId: student.id }, }); const depositAmount = importDepositAmount!; const hasPaidDeposit = existingDeposit?.status === 'paid' && Number(existingDeposit.amount || 0) > 0; if (hasPaidDeposit) { // 导入重试或重复导入时,已有已缴押金不重复收取。 } else if (existingDeposit) { existingDeposit.amount = depositAmount; existingDeposit.status = 'paid'; existingDeposit.paidDate = checkInDate; (existingDeposit as { refundDate: string | null }).refundDate = null; (existingDeposit as { refundAmount: number | null }).refundAmount = null; (existingDeposit as { refundedBy: number | null }).refundedBy = null; (existingDeposit as { refundedAt: Date | null }).refundedAt = null; existingDeposit.notes = '入住导入自动收取'; await depositRepo.save(existingDeposit); rowDepositsCreated++; } else { await depositRepo.save( depositRepo.create({ studentId: student.id, amount: depositAmount, paidDate: checkInDate, status: 'paid', notes: '入住导入自动收取', }), ); rowDepositsCreated++; } } return { depositsCreated: rowDepositsCreated }; }); imported++; depositsCreated += result.depositsCreated; } catch (e: unknown) { errors.push( e instanceof ImportRowSkipped ? e.message : `第${rowNum}行: ${row.name} 导入失败 - ${e instanceof Error ? e.message : String(e)}`, ); skipped++; } } const depositMsg = depositsCreated > 0 ? `,自动收取 ${depositsCreated} 笔押金` : ''; return { message: `成功导入 ${imported} 条入住记录,跳过 ${skipped} 条${depositMsg}`, imported, skipped, depositsCreated, errors: errors.length > 0 ? errors : undefined, }; } private normalizePositiveMoney(value: number, label: string): number { const amount = value; if (!Number.isFinite(amount) || Math.abs(amount * 100 - Math.round(amount * 100)) > 1e-8) { throw new BadRequestException(`${label}最多保留两位小数`); } if (amount <= 0) throw new BadRequestException(`${label}必须大于0`); return Number(amount.toFixed(2)); } private assertDateOnly(value: string, label: string): void { if (!/^\d{4}-\d{2}-\d{2}$/.test(value)) { throw new BadRequestException(`${label}必须为有效的 YYYY-MM-DD 日期`); } const [year, month, day] = value.split('-').map(Number); const date = new Date(Date.UTC(year, month - 1, day)); if ( date.getUTCFullYear() !== year || date.getUTCMonth() + 1 !== month || date.getUTCDate() !== day ) { throw new BadRequestException(`${label}必须为有效的 YYYY-MM-DD 日期`); } } private assertDateOrder(start: string, end: string | undefined, message: string): void { this.assertDateOnly(start, '起始日期'); if (!end) return; this.assertDateOnly(end, '结束日期'); if (end < start) throw new BadRequestException(message); } }