feat: 重构各业务模块管理页面与服务
This commit is contained in:
438
apps/server/src/expenses/expense-operations.service.ts
Normal file
438
apps/server/src/expenses/expense-operations.service.ts
Normal file
@@ -0,0 +1,438 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user