Files
gongxue-base/apps/server/src/deposits/deposits.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

360 lines
13 KiB
TypeScript

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<string, string | number | Date | null>;
const capacityRoomTypeText: Record<number, string> = {
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<Deposit>,
@InjectRepository(DepositInstallment)
private installmentRepo: Repository<DepositInstallment>,
@InjectRepository(Student)
private studentRepo: Repository<Student>,
@InjectRepository(Occupancy)
private occupancyRepo?: Repository<Occupancy>,
) {}
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<RawScalarRow>();
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<RawScalarRow>();
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();
}
}