Files
gongxue-base/apps/server/src/expenses/expense-operations.service.ts

439 lines
16 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 { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { DataSource, In, Repository } from 'typeorm';
import { RoomExpense, PersonalExpense, Room, Student } from '../entities';
import { BillsService } from '../bills/bills.service';
import { RoomsService } from '../rooms/rooms.service';
import type { CreatePersonalExpenseDto } from './dto/expense.dto';
@Injectable()
export class ExpenseOperationsService {
constructor(
@InjectRepository(RoomExpense) private roomExpRepo: Repository<RoomExpense>,
@InjectRepository(PersonalExpense) private personalExpRepo: Repository<PersonalExpense>,
@InjectRepository(Room) private roomRepo: Repository<Room>,
@InjectRepository(Student) private studentRepo: Repository<Student>,
private billsService: BillsService,
private dataSource: DataSource,
) {}
private assertPositiveAmount(amount: number) {
if (!Number.isFinite(amount) || Math.abs(amount * 100 - Math.round(amount * 100)) > 1e-8) {
throw new BadRequestException('费用金额最多保留两位小数');
}
if (amount <= 0) throw new BadRequestException('费用金额必须大于0');
}
private isValidDate(value: string) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) return false;
const date = new Date(`${value}T00:00:00Z`);
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value;
}
async createPersonalExpense(dto: CreatePersonalExpenseDto, userId?: number) {
this.assertPositiveAmount(dto.amount);
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
if (!student) throw new NotFoundException('学生不存在');
const entity = this.personalExpRepo.create({ ...dto, recordedBy: userId });
return this.personalExpRepo.save(entity);
}
async findPersonalExpenses(query?: { studentId?: number; status?: 'active' | 'archived' }) {
const status = query?.status ?? 'active';
if (status !== 'active' && status !== 'archived') throw new BadRequestException('费用状态无效');
const where: Record<string, unknown> = { status };
if (query?.studentId) where.studentId = query.studentId;
return this.personalExpRepo.find({
where,
relations: ['student'],
order: { createdAt: 'DESC' },
});
}
async deletePersonalExpense(id: number) {
const e = await this.personalExpRepo.findOne({ where: { id } });
if (!e) throw new NotFoundException('费用记录不存在');
if (e.billId) throw new BadRequestException('已计入账单的个人费用不能归档,请先取消账单');
if (e.status === 'archived') throw new BadRequestException('费用记录已归档');
await this.personalExpRepo.update(id, { status: 'archived' });
return { message: '已归档' };
}
async batchDeletePersonalExpenses(ids: number[]) {
const uniqueIds = [...new Set(ids || [])];
if (uniqueIds.length === 0) throw new BadRequestException('请选择要归档的记录');
const existing = await this.personalExpRepo.find({ where: { id: In(uniqueIds) } });
if (existing.length !== uniqueIds.length) throw new NotFoundException('部分费用记录不存在');
if (existing.some((expense) => expense.billId)) {
throw new BadRequestException('选中记录包含已计入账单的个人费用');
}
const result = await this.personalExpRepo
.createQueryBuilder()
.update()
.set({ status: 'archived' })
.where('id IN (:...ids)', { ids: uniqueIds })
.execute();
return { message: `已批量归档 ${result.affected || 0}`, archived: result.affected || 0 };
}
async batchRestorePersonalExpenses(ids: number[]) {
const uniqueIds = [...new Set(ids || [])];
if (uniqueIds.length === 0) throw new BadRequestException('请选择要恢复的记录');
if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) {
throw new BadRequestException('费用记录 ID 无效');
}
const existing = await this.personalExpRepo.find({ where: { id: In(uniqueIds) } });
if (existing.length !== uniqueIds.length) throw new NotFoundException('部分费用记录不存在');
const targets = existing.filter((expense) => expense.status === 'archived');
if (targets.some((expense) => expense.billId)) {
throw new BadRequestException('选中记录包含已计入账单的个人费用');
}
const targetIds = targets.map((expense) => expense.id);
const skipped = existing.length - targetIds.length;
let restored = 0;
if (targetIds.length > 0) {
const result = await this.personalExpRepo
.createQueryBuilder()
.update()
.set({ status: 'active' })
.where('id IN (:...ids)', { ids: targetIds })
.execute();
restored = result.affected || 0;
}
return { message: `已批量恢复 ${restored} 条个人费用`, restored, skipped };
}
async purgePersonalExpense(id: number) {
const e = await this.personalExpRepo.findOne({ where: { id } });
if (!e) throw new NotFoundException('费用记录不存在');
if (e.status !== 'archived') throw new BadRequestException('仅已归档费用可以永久删除,请先归档');
if (e.billId) throw new BadRequestException('已计入账单的个人费用不能永久删除,请先取消账单');
const billed = await this.dataSource
.getRepository('bill_items')
.count({ where: { personalExpenseId: id } });
if (billed) throw new BadRequestException('已计入账单明细的个人费用不能永久删除,请先取消账单');
await this.personalExpRepo.delete(id);
return { message: '已永久删除个人费用(不可恢复)' };
}
async batchPurgePersonalExpenses(ids: number[]) {
const uniqueIds = [...new Set(ids || [])];
if (uniqueIds.length === 0) throw new BadRequestException('请选择要永久删除的个人费用');
if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) {
throw new BadRequestException('费用记录 ID 无效');
}
const existing = await this.personalExpRepo.find({ where: { id: In(uniqueIds) } });
if (existing.length !== uniqueIds.length) throw new NotFoundException('部分费用记录不存在');
const billed = await this.dataSource
.getRepository('bill_items')
.count({ where: { personalExpenseId: In(uniqueIds) } });
if (billed) throw new BadRequestException('选中记录包含已计入账单明细的个人费用');
if (existing.some((expense) => expense.billId)) {
throw new BadRequestException('选中记录包含已计入账单的个人费用');
}
const deleted: number[] = [];
const skipped: string[] = [];
for (const e of existing) {
if (e.status !== 'archived') {
skipped.push(`记录${e.id}(未归档)`);
continue;
}
await this.personalExpRepo.delete(e.id);
deleted.push(e.id);
}
const message =
skipped.length > 0
? `已永久删除 ${deleted.length} 条;${skipped.length} 条被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''}`
: `已永久删除 ${deleted.length} 条个人费用(不可恢复)`;
return { message, deleted: deleted.length, skipped: skipped.length };
}
async updatePersonalExpense(id: number, dto: Partial<CreatePersonalExpenseDto>) {
const e = await this.personalExpRepo.findOne({ where: { id } });
if (!e) throw new NotFoundException('费用记录不存在');
if (e.billId) throw new BadRequestException('已计入账单的个人费用不能修改,请先取消账单');
if (dto.amount !== undefined) this.assertPositiveAmount(dto.amount);
if (dto.studentId !== undefined && dto.studentId !== e.studentId) {
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
if (!student) throw new NotFoundException('学生不存在');
}
Object.assign(e, dto);
return this.personalExpRepo.save(e);
}
/**
* 水电费Excel批量导入
* Excel格式: 序号|时间|房间号|房间电量|电费|冷水用量(吨)|水费|应缴金额
* 时间格式: "2026-01-21 - 2026-02-08"
*/
async batchImportUtilityExpenses(
rows: {
periodStr: string;
roomNumber: string;
electricityAmount: number;
electricityFee: number;
waterAmount: number;
waterFee: number;
totalFee: number;
}[],
userId?: number,
) {
let imported = 0;
let skipped = 0;
const errors: string[] = [];
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const rowNum = i + 2;
if (!row.roomNumber?.trim()) {
skipped++;
continue;
}
try {
// 查找或创建宿舍
let room = await this.roomRepo.findOne({ where: { roomNumber: row.roomNumber.trim() } });
if (!room) {
const parsed = RoomsService.parseRoomNumber(row.roomNumber.trim());
room = await this.roomRepo.save(
this.roomRepo.create({
roomNumber: row.roomNumber.trim(),
building: parsed.building || undefined,
floor: parsed.floor || undefined,
capacity: parsed.capacity || 4,
roomType: parsed.roomType || undefined,
}),
);
}
// 解析时间段 "2026-01-21 - 2026-02-08" 或 "2026-01-21~2026-02-08"
let periodStart = '';
let periodEnd = '';
if (row.periodStr) {
// 先尝试用" - "或" ~ "分割(带空格的分隔符,避免拆分日期内部的连字符)
let parts = row.periodStr.split(/\s+[-~]\s+/);
if (parts.length < 2) {
// 回退:尝试用正则提取 YYYY-MM-DD 格式的日期
const dateMatches = row.periodStr.match(/(\d{4}-\d{1,2}-\d{1,2})/g);
if (dateMatches && dateMatches.length >= 2) {
parts = [dateMatches[0], dateMatches[1]];
}
}
if (parts.length >= 2) {
periodStart = this.normalizeDate(parts[0].trim());
periodEnd = this.normalizeDate(parts[1].trim());
}
}
if (!periodStart || !periodEnd) {
errors.push(`${rowNum}行: 时间格式无法解析 "${row.periodStr}"`);
skipped++;
continue;
}
if (!this.isValidDate(periodStart) || !this.isValidDate(periodEnd) || periodEnd < periodStart) {
errors.push(`${rowNum}行: ${row.roomNumber} 账期无效(${periodStart} ~ ${periodEnd}),已跳过`);
skipped++;
continue;
}
// 关键校验:电费 + 水费 都为 0 时,多半是 Excel 公式未正确计算或字段缺失,
// 必须给出明确错误,避免出现"提示成功但无数据"的迷之现象。
if ((row.electricityFee || 0) <= 0 && (row.waterFee || 0) <= 0) {
errors.push(
`${rowNum}行: ${row.roomNumber} 电费和水费均为 0可能 Excel 中是未生效的公式(请打开文件让公式重算后再保存导入),已跳过`,
);
skipped++;
continue;
}
const existing = await this.roomExpRepo.find({
where: [
{ importKey: `${room.id}:${periodStart}:${periodEnd}:electricity` },
{ importKey: `${room.id}:${periodStart}:${periodEnd}:water` },
],
});
const byType = new Map(existing.map((expense) => [expense.expenseType, expense]));
let savedAny = false;
if (row.electricityFee > 0) {
await this.importUtilityExpense(
room.id,
'electricity',
periodStart,
periodEnd,
row.electricityFee,
`电量${row.electricityAmount}kWh`,
byType,
userId!,
);
savedAny = true;
}
if (row.waterFee > 0) {
await this.importUtilityExpense(
room.id,
'water',
periodStart,
periodEnd,
row.waterFee,
`用水${row.waterAmount}`,
byType,
userId!,
);
savedAny = true;
}
if (savedAny) imported++;
else {
skipped++;
errors.push(`${rowNum}行: ${row.roomNumber} 无有效金额`);
}
} catch (e: any) {
errors.push(`${rowNum}行: ${row.roomNumber} 导入失败 - ${e.message}`);
skipped++;
}
}
return {
message:
imported > 0
? `成功导入 ${imported} 间宿舍水电费${skipped > 0 ? `,跳过 ${skipped}` : ''}`
: `未导入任何记录${skipped > 0 ? `,共 ${skipped} 条被跳过` : ''}`,
imported,
skipped,
errors: errors.length > 0 ? errors : undefined,
};
}
private async importUtilityExpense(
roomId: number,
expenseType: 'electricity' | 'water',
periodStart: string,
periodEnd: string,
amount: number,
description: string,
byType: Map<string, RoomExpense>,
recordedBy: number,
): Promise<void> {
const expense = byType.get(expenseType) || this.roomExpRepo.create({
roomId,
expenseType,
periodStart,
periodEnd,
importKey: `${roomId}:${periodStart}:${periodEnd}:${expenseType}`,
});
if (expense.id && await this.dataSource?.getRepository('bill_items').count({ where: { roomExpenseId: expense.id } })) {
throw new BadRequestException(`该周期${expenseType === 'electricity' ? '电费' : '水费'}已计入账单,不能覆盖`);
}
expense.amount = amount;
expense.description = description;
expense.recordedBy = recordedBy;
await this.roomExpRepo.save(expense);
}
/** 把 2026/4/1、2026-4-1 之类格式归一化为 YYYY-MM-DD */
private normalizeDate(s: string): string {
if (!s) return '';
if (/^\d{4}-\d{2}-\d{2}$/.test(s)) return s;
const m = s.match(/(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})/);
if (m) return `${m[1]}-${m[2].padStart(2, '0')}-${m[3].padStart(2, '0')}`;
return s;
}
/**
* 个人附加费Excel批量导入
* Excel格式: 学生姓名|费用类型|金额|费用日期|说明
*/
async batchImportPersonalExpenses(
rows: {
studentName: string;
expenseType: string;
amount: number;
expenseDate: string;
description?: string;
}[],
userId?: number,
) {
let imported = 0;
let skipped = 0;
const errors: string[] = [];
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const rowNum = i + 2;
if (!row.studentName?.trim()) {
skipped++;
continue;
}
try {
// 查找学生
const student = await this.studentRepo.findOne({ where: { name: row.studentName.trim() } });
if (!student) {
errors.push(`${rowNum}行: 学生"${row.studentName}"未找到`);
skipped++;
continue;
}
// 解析费用类型
const expenseType = row.expenseType?.trim() || '';
if (!expenseType) {
errors.push(`${rowNum}行: 费用类型不能为空`);
skipped++;
continue;
}
// 解析日期
let expenseDate = row.expenseDate?.trim() || '';
if (!expenseDate.match(/^\d{4}-\d{2}-\d{2}$/)) {
// 尝试从各种格式解析
const dateMatch = expenseDate.match(/(\d{4})[-/](\d{1,2})[-/](\d{1,2})/);
if (dateMatch) {
expenseDate = `${dateMatch[1]}-${dateMatch[2].padStart(2, '0')}-${dateMatch[3].padStart(2, '0')}`;
} else {
errors.push(`${rowNum}行: 日期格式"${row.expenseDate}"无效需要YYYY-MM-DD`);
skipped++;
continue;
}
}
// 校验金额
try {
this.assertPositiveAmount(row.amount);
} catch (e: any) {
errors.push(`${rowNum}行: ${row.studentName} ${e.message}`);
skipped++;
continue;
}
await this.personalExpRepo.save(
this.personalExpRepo.create({
studentId: student.id,
expenseType,
amount: row.amount,
expenseDate,
description: row.description || undefined,
recordedBy: userId,
}),
);
imported++;
} catch (e: any) {
errors.push(`${rowNum}行: ${row.studentName} 导入失败 - ${e.message}`);
skipped++;
}
}
return {
message: `成功导入 ${imported} 条个人附加费,跳过 ${skipped}`,
imported,
skipped,
errors: errors.length > 0 ? errors : undefined,
};
}
}