Files
gongxue-base/apps/server/src/occupancies/occupancies.service.ts
wangziqi c7ba2799b9 feat: 拆分学号/身份证字段 + 考勤教师展示 + 代码优化
- 入住导入模板:学号和身份证号拆为独立字段,前后端对齐
- 排课查询关联教师,考勤归档页展示教师姓名
- 抽查时段增加 IsIn 校验
- 抽取 withPessimisticWriteLock 去重悲观锁查询
- import 增加文件空 buffer 校验
- 测试 mock 补全,适配事务 manager
- MySQL init.sql VALUES() 语法兼容修复
2026-07-20 11:54:01 +08:00

719 lines
29 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, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import {
Repository,
DataSource,
IsNull,
Between,
LessThanOrEqual,
MoreThanOrEqual,
In,
SelectQueryBuilder,
ObjectLiteral,
} 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 { CheckInDto, CheckOutDto, TransferRoomDto } from './dto/occupancy.dto';
import { RoomsService } from '../rooms/rooms.service';
class ImportRowSkipped extends Error {}
@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>,
@InjectRepository(Bed) private bedRepo: Repository<Bed>,
@InjectRepository(Locker) private lockerRepo: Repository<Locker>,
@InjectRepository(Organization) private organizationRepo: Repository<Organization>,
private dataSource: DataSource,
) {}
private withPessimisticWriteLock<T extends ObjectLiteral>(
qb: SelectQueryBuilder<T>,
): SelectQueryBuilder<T> {
const type = this.dataSource.options.type;
if (type === 'mysql' || type === 'mariadb' || type === 'postgres' || type === 'cockroachdb') {
return qb.setLock('pessimistic_write');
}
return qb;
}
async findAll(query?: { roomId?: number; studentId?: number; active?: boolean }) {
const qb = this.repo
.createQueryBuilder('o')
.leftJoinAndSelect('o.student', 'student')
.leftJoinAndSelect('o.room', 'room')
.leftJoinAndSelect('o.bed', 'bed')
.leftJoinAndSelect('o.locker', 'locker')
.where('o.status = :status', { status: 'active' })
.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, userId?: number) {
this.assertDateOrder(dto.checkInDate, dto.billingStartDate, '计费起始日不能早于入住日期');
return this.dataSource.transaction(async (manager) => {
const existing = await this.withPessimisticWriteLock(
manager
.createQueryBuilder(Occupancy, 'occupancy')
.where('occupancy.studentId = :studentId', { studentId: dto.studentId })
.andWhere('occupancy.checkOutDate IS NULL'),
).getOne();
if (existing) throw new BadRequestException('该学生已有在住记录,请先办理退宿');
const room = await this.withPessimisticWriteLock(
manager.createQueryBuilder(Room, 'room').where('room.id = :roomId', { roomId: dto.roomId }),
).getOne();
if (!room) throw new NotFoundException('宿舍不存在');
if (room.status === 'archived' || room.status === 'maintenance') {
throw new BadRequestException('该宿舍当前不可入住');
}
const count = await manager.count(Occupancy, {
where: { roomId: dto.roomId, checkOutDate: IsNull() },
});
if (count >= room.capacity) throw new BadRequestException('宿舍已满');
const student = await manager.findOne(Student, { where: { id: dto.studentId } });
if (!student) throw new NotFoundException('学生不存在');
if (dto.bedId) {
const bed = await this.withPessimisticWriteLock(
manager.createQueryBuilder(Bed, 'bed').where('bed.id = :bedId AND bed.roomId = :roomId', {
bedId: dto.bedId,
roomId: dto.roomId,
}),
).getOne();
if (!bed) throw new BadRequestException('床位不存在或不属于该宿舍');
if (bed.status !== 'available') throw new BadRequestException('该床位已被占用或维修中');
}
if (dto.lockerId) {
const locker = await this.withPessimisticWriteLock(
manager
.createQueryBuilder(Locker, 'locker')
.where('locker.id = :lockerId AND locker.roomId = :roomId', {
lockerId: dto.lockerId,
roomId: dto.roomId,
}),
).getOne();
if (!locker) throw new BadRequestException('柜子不存在或不属于该宿舍');
if (locker.status !== 'available') throw new BadRequestException('柜子已被占用或维修中');
}
const saved = await manager.save(
manager.create(Occupancy, {
studentId: dto.studentId,
roomId: dto.roomId,
checkInDate: dto.checkInDate,
billingStartDate: dto.billingStartDate || dto.checkInDate,
stayType: dto.stayType,
responsibleOrganizationId: student.organizationId,
notes: dto.notes,
bedId: dto.bedId,
lockerId: dto.lockerId,
}),
);
if (dto.bedId) await manager.update(Bed, dto.bedId, { status: 'occupied' });
if (dto.lockerId) await manager.update(Locker, dto.lockerId, { status: 'occupied' });
if (count + 1 >= room.capacity) await manager.update(Room, room.id, { status: 'full' });
if (dto.collectDeposit) {
let deposit = await manager.findOne(Deposit, { where: { studentId: dto.studentId } });
if (deposit) {
deposit.amount = Number(
(Number(deposit.amount || 0) + Number(dto.depositAmount ?? 500)).toFixed(2),
);
deposit.status = 'paid';
deposit.paidDate = dto.checkInDate;
deposit.recordedBy = userId ?? null;
deposit.notes = '入住登记自动收取';
} else {
deposit = manager.create(Deposit, {
studentId: dto.studentId,
amount: dto.depositAmount ?? 500,
paidDate: dto.checkInDate,
status: 'paid',
recordedBy: userId,
notes: '入住登记自动收取',
});
}
await manager.save(deposit);
}
return saved;
});
}
async checkOut(occupancyId: number, dto: CheckOutDto) {
return this.dataSource.transaction(async (manager) => {
const occ = await this.withPessimisticWriteLock(
manager
.createQueryBuilder(Occupancy, 'occupancy')
.where('occupancy.id = :id', { id: occupancyId }),
).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 this.withPessimisticWriteLock(
runner.manager
.createQueryBuilder(Occupancy, 'occupancy')
.where('occupancy.id = :id', { id: occupancyId }),
).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 this.withPessimisticWriteLock(
runner.manager
.createQueryBuilder(Room, 'room')
.where('room.id = :roomId', { roomId: dto.newRoomId }),
).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) throw new BadRequestException('目标宿舍已满');
// 新床位校验
if (dto.newBedId) {
const newBed = await this.withPessimisticWriteLock(
runner.manager
.createQueryBuilder(Bed, 'bed')
.where('bed.id = :bedId AND bed.roomId = :roomId', {
bedId: dto.newBedId,
roomId: dto.newRoomId,
}),
).getOne();
if (!newBed) throw new BadRequestException('目标床位不存在或不属于目标宿舍');
if (newBed.status !== 'available') throw new BadRequestException('目标床位已被占用');
}
if (dto.newLockerId) {
const newLocker = await this.withPessimisticWriteLock(
runner.manager
.createQueryBuilder(Locker, 'locker')
.where('locker.id = :lockerId AND locker.roomId = :roomId', {
lockerId: dto.newLockerId,
roomId: dto.newRoomId,
}),
).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) {
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 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 },
) {
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) {
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) {
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) {
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.refundDate = null as unknown as string;
existingDeposit.refundAmount = null as unknown as number;
existingDeposit.refundedBy = null;
existingDeposit.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 = Number(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);
}
}