import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; import { InjectRepository } from '@nestjs/typeorm'; import { Repository } from 'typeorm'; import { Deposit } from '../entities/deposit.entity'; import { Student } from '../entities/student.entity'; import { DepositInstallment } from '../entities/deposit-installment.entity'; import { Occupancy } from '../entities/occupancy.entity'; import { BatchCreateDepositDto, CreateDepositDto, RefundDepositDto } from './dto/deposit.dto'; const money = (value: number | string | null | undefined) => Number(Number(value || 0).toFixed(2)); /** getRawMany 返回的原始行:数据库标量值(string/number/Date)或 NULL */ type RawScalarRow = Record; const capacityRoomTypeText: Record = { 1: '单人间', 2: '二人间', 3: '三人间', 4: '四人间', 5: '五人间', 6: '六人间', 8: '八人间', }; const normalizeRoomType = (roomType?: string | null, capacity?: number | string | null) => { const trimmed = roomType?.trim(); if (trimmed) return trimmed; const normalizedCapacity = Number(capacity); return capacityRoomTypeText[normalizedCapacity] || (normalizedCapacity > 0 ? `${normalizedCapacity}人间` : ''); }; const roomTypeCapacity = (roomType?: string) => { const text = roomType?.trim(); if (!text) return undefined; const knownCapacity = Object.entries(capacityRoomTypeText).find(([, label]) => label === text); if (knownCapacity) return Number(knownCapacity[0]); const match = text.match(/^(\d+)人间$/); return match ? Number(match[1]) : undefined; }; @Injectable() export class DepositsService { constructor( @InjectRepository(Deposit) private repo: Repository, @InjectRepository(DepositInstallment) private installmentRepo: Repository, @InjectRepository(Student) private studentRepo: Repository, @InjectRepository(Occupancy) private occupancyRepo?: Repository, ) {} async getStudentLookups() { return this.studentRepo.find({ select: ['id', 'name', 'studentNo'], where: { status: 'active' }, order: { name: 'ASC' }, }); } async getEligibleStudents(roomType?: string) { const trimmedRoomType = roomType?.trim(); const fallbackCapacity = roomTypeCapacity(trimmedRoomType); const qb = this.occupancyRepo! .createQueryBuilder('o') .innerJoin('o.student', 'student') .innerJoin('o.room', 'room') .leftJoin(Deposit, 'deposit', 'deposit.student_id = student.id AND deposit.status != :archived', { archived: 'archived', }) .select('student.id', 'studentId'); const eligibleSelects = [ ['student.name', 'studentName'], ['student.studentNo', 'studentNo'], ['room.id', 'roomId'], ['room.roomNumber', 'roomNumber'], ['room.building', 'building'], ['room.roomType', 'roomType'], ['room.capacity', 'capacity'], ['deposit.amount', 'depositAmount'], ] as const; for (const [column, alias] of eligibleSelects) { qb.addSelect(column, alias); } qb.where('o.status = :activeStatus', { activeStatus: 'active' }) .andWhere('o.checkOutDate IS NULL') .andWhere('student.status = :studentStatus', { studentStatus: 'active' }) .orderBy('room.building', 'ASC') .addOrderBy('room.roomNumber', 'ASC') .addOrderBy('student.name', 'ASC'); if (trimmedRoomType) { if (fallbackCapacity) { qb.andWhere( '(room.roomType = :roomType OR ((room.roomType IS NULL OR room.roomType = :emptyRoomType) AND room.capacity = :fallbackCapacity))', { roomType: trimmedRoomType, emptyRoomType: '', fallbackCapacity }, ); } else { qb.andWhere('room.roomType = :roomType', { roomType: trimmedRoomType }); } } const rows = await qb.getRawMany(); return rows.map((row) => ({ studentId: Number(row.studentId), studentName: row.studentName, studentNo: row.studentNo ?? null, roomId: Number(row.roomId), roomNumber: row.roomNumber, building: row.building ?? null, roomType: normalizeRoomType( row.roomType == null ? null : String(row.roomType), row.capacity == null ? null : Number(row.capacity), ), capacity: Number(row.capacity), depositAmount: money(row.depositAmount == null ? null : Number(row.depositAmount)), })); } async batchCreate(dto: BatchCreateDepositDto, userId?: number) { const studentIds = [...new Set(dto.studentIds)]; if (studentIds.length === 0) throw new BadRequestException('请选择学生'); const amount = money(dto.amount); if (!Number.isFinite(dto.amount) || Math.abs(dto.amount * 100 - Math.round(dto.amount * 100)) > 1e-8) { throw new BadRequestException('收取金额最多保留两位小数'); } if (amount <= 0) throw new BadRequestException('收取金额必须大于0'); const results: Deposit[] = []; for (const studentId of studentIds) { results.push(await this.create({ studentId, amount, paidDate: dto.paidDate, notes: dto.notes, }, userId)); } return { count: results.length, amount, results }; } async findAll(query?: { studentId?: number; status?: string }) { const qb = this.repo .createQueryBuilder('d') .leftJoinAndSelect('d.student', 'student') .leftJoinAndSelect('d.installments', 'installments') .orderBy('d.createdAt', 'DESC'); if (query?.studentId) qb.andWhere('d.studentId = :studentId', { studentId: query.studentId }); if (query?.status) qb.andWhere('d.status = :status', { status: query.status }); else qb.andWhere('d.status != :archived', { archived: 'archived' }); const deposits = await qb.getMany(); for (const deposit of deposits) { deposit.installments = deposit.installments?.filter((item) => item.status !== 'archived') ?? []; } return deposits; } /** * Agent tool: 押金查询,返回白名单字段(学生姓名/学号、金额、状态、退款)。 */ async agentSearchDeposits(query?: { keyword?: string; status?: string; limit?: number; }): Promise< { id: number; studentName: string; studentNo: string; amount: number; status: string; paidDate: string; refundAmount: number | null; refundDate: string | null; }[] > { const qb = this.repo .createQueryBuilder('d') .leftJoin('d.student', 'student') .select('d.id', 'id'); const depositSelects = [ ['student.name', 'studentName'], ['student.studentNo', 'studentNo'], ['d.amount', 'amount'], ['d.status', 'status'], ['d.paidDate', 'paidDate'], ['d.refundAmount', 'refundAmount'], ['d.refundDate', 'refundDate'], ] as const; for (const [column, alias] of depositSelects) { qb.addSelect(column, alias); } qb.where('d.status != :archived', { archived: 'archived' }); if (query?.keyword) { qb.andWhere( '(student.name LIKE :keyword OR student.studentNo LIKE :keyword)', { keyword: `%${query.keyword}%` }, ); } if (query?.status && query.status !== 'archived') { qb.andWhere('d.status = :status', { status: query.status }); } const rows = await qb .orderBy('d.createdAt', 'DESC') .limit(Math.max(1, Math.min(query?.limit ?? 20, 50))) .getRawMany(); return rows.map((row) => ({ id: Number(row.id), studentName: row.studentName == null ? '' : String(row.studentName), studentNo: row.studentNo == null ? '' : String(row.studentNo), amount: money(row.amount as number | string | null | undefined), status: String(row.status), paidDate: row.paidDate == null ? '' : String(row.paidDate), refundAmount: row.refundAmount == null ? null : money(row.refundAmount as number | string | null | undefined), refundDate: row.refundDate == null ? null : String(row.refundDate), })); } async findOne(id: number) { const deposit = await this.repo.findOne({ where: { id }, relations: ['student', 'installments'] }); if (!deposit) throw new NotFoundException('押金记录不存在'); deposit.installments = deposit.installments?.filter((item) => item.status !== 'archived') ?? []; return deposit; } async create(dto: CreateDepositDto, userId?: number) { const student = await this.studentRepo.findOne({ where: { id: dto.studentId } }); if (!student) throw new NotFoundException('学生不存在'); const amount = money(dto.amount); if (!Number.isFinite(dto.amount) || Math.abs(dto.amount * 100 - Math.round(dto.amount * 100)) > 1e-8) { throw new BadRequestException('收取金额最多保留两位小数'); } if (amount <= 0) throw new BadRequestException('收取金额必须大于0'); const existing = await this.repo.findOne({ where: { studentId: dto.studentId } }); if (existing) { existing.amount = money(Number(existing.amount || 0) + amount); existing.paidDate = dto.paidDate; existing.status = 'paid'; existing.recordedBy = userId ?? null; existing.refundDate = null; existing.refundAmount = null; existing.refundedBy = null; existing.refundedAt = null; if (dto.notes) existing.notes = dto.notes; return this.repo.save(existing); } return this.repo.save( this.repo.create({ studentId: dto.studentId, amount, paidDate: dto.paidDate, notes: dto.notes, status: 'paid', recordedBy: userId, }), ); } async addInstallment(depositId: number, amount: number, dueDate: string) { const normalizedAmount = money(amount); if (!Number.isFinite(amount) || Math.abs(amount * 100 - Math.round(amount * 100)) > 1e-8) { throw new BadRequestException('分期金额最多保留两位小数'); } if (normalizedAmount <= 0) throw new BadRequestException('分期金额必须大于0'); const deposit = await this.repo.findOne({ where: { id: depositId } }); if (!deposit) throw new NotFoundException('押金记录不存在'); const installment = this.installmentRepo.create({ depositId, amount: normalizedAmount, dueDate, status: 'pending', }); return this.installmentRepo.save(installment); } async updateInstallment(id: number, data: { paidDate?: string; status?: string }) { const installment = await this.installmentRepo.findOne({ where: { id } }); if (!installment) throw new NotFoundException('分期记录不存在'); if (data.paidDate !== undefined) installment.paidDate = data.paidDate; if (data.status !== undefined) installment.status = data.status; return this.installmentRepo.save(installment); } async deleteInstallment(id: number) { const installment = await this.installmentRepo.findOne({ where: { id } }); if (!installment) throw new NotFoundException('分期记录不存在'); if (installment.status === 'archived') throw new BadRequestException('分期记录已归档'); await this.installmentRepo.update(id, { status: 'archived' }); return { message: '已归档' }; } async refund(id: number, dto: RefundDepositDto, userId?: number) { const deposit = await this.repo.findOne({ where: { id } }); if (!deposit) throw new NotFoundException('押金记录不存在'); if (deposit.status !== 'paid' || Number(deposit.amount) <= 0) { throw new BadRequestException('该学生当前没有可退押金'); } const refundAmount = money(deposit.amount); deposit.refundDate = dto.refundDate; deposit.refundAmount = refundAmount; deposit.amount = 0; deposit.status = 'refunded'; if (dto.notes) deposit.notes = dto.notes; deposit.refundedBy = userId ?? null; deposit.refundedAt = new Date(); return this.repo.save(deposit); } async remove(id: number) { const deposit = await this.repo.findOne({ where: { id } }); if (!deposit) throw new NotFoundException('押金记录不存在'); if (deposit.status === 'archived') throw new BadRequestException('押金记录已归档'); await this.repo.update(id, { status: 'archived' }); return { message: '已归档' }; } async purge(id: number) { const deposit = await this.repo.findOne({ where: { id } }); if (!deposit) throw new NotFoundException('押金记录不存在'); if (deposit.status !== 'archived') { throw new BadRequestException('仅已归档押金可以永久删除,请先归档'); } if (Number(deposit.refundAmount || 0) > 0) { throw new BadRequestException('该押金已有退款金额,无法永久删除'); } if (Number(deposit.deductionAmount || 0) > 0) { throw new BadRequestException('该押金已有抵扣金额,无法永久删除'); } const paidInstallments = await this.installmentRepo.count({ where: { depositId: id, status: 'paid' }, }); if (paidInstallments > 0) { throw new BadRequestException('该押金存在已支付分期,无法永久删除'); } await this.repo.delete(id); return { message: '已永久删除押金(不可恢复)' }; } async getStats() { const qb = this.repo .createQueryBuilder('d') .select('d.status', 'status') .addSelect('COUNT(*)', 'count') .addSelect('SUM(d.amount)', 'totalAmount'); qb.groupBy('d.status'); return qb.getRawMany(); } }