Files
gongxue-base/apps/server/src/occupancies/occupancy-import.service.ts
wangziqi a644a8de42 refactor(server): 清理全量 any 类型安全警告 (692 → 0)
- 全模块类型化:controller 的 req: any → AuthenticatedRequest/RequestUser,
  聚合查询 getRawMany 泛型标注、导入行/响应体定义具体 interface、
  catch (e: any) → unknown + 收窄、no-base-to-string 用 String() 显式转换
- 第三方无类型库边界(pdfkit/exceljs)文件级或单行 disable 并注明理由
- 顺带修复:get-business-context.tool 两个 require-await error、
  bills.controller 参数顺序隐患、main.ts compression 调用
- 运行时逻辑零改动;测试 142 套件 / 1065 用例全部通过
2026-08-08 09:28:23 +08:00

323 lines
13 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, 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<StudentUpdateFields> = {};
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<Occupancy> = {
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);
}
}