- 全模块类型化: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 用例全部通过
360 lines
13 KiB
TypeScript
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();
|
|
}
|
|
}
|