feat: 重构各业务模块管理页面与服务
This commit is contained in:
311
apps/server/src/occupancies/occupancy-import.service.ts
Normal file
311
apps/server/src/occupancies/occupancy-import.service.ts
Normal file
@@ -0,0 +1,311 @@
|
||||
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||
import { DataSource, IsNull } from 'typeorm';
|
||||
import { Occupancy, Room, Student, Deposit, Bed, Locker, Organization } from '../entities';
|
||||
import { RoomsService } from '../rooms/rooms.service';
|
||||
|
||||
class ImportRowSkipped extends Error {}
|
||||
|
||||
@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: any = {};
|
||||
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: any = {
|
||||
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: any) {
|
||||
errors.push(
|
||||
e instanceof ImportRowSkipped
|
||||
? e.message
|
||||
: `第${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,
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user