feat: 重构各业务模块管理页面与服务

This commit is contained in:
2026-08-05 17:12:00 +08:00
parent 80e6fccf05
commit fd39e1686a
163 changed files with 18409 additions and 13449 deletions

View 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,
};
}
}

View File

@@ -1,5 +1,6 @@
import { BadRequestException, NotFoundException } from '@nestjs/common';
import { ExpensesService } from './expenses.service';
import { ExpenseOperationsService } from './expense-operations.service';
import { PersonalExpense } from '../entities/personal-expense.entity';
const qb = (affected = 1) => ({
@@ -35,7 +36,25 @@ function createService(options?: {
};
const studentRepo = { findOne: jest.fn().mockResolvedValue({ id: 1 }) };
return {
service: new ExpensesService(roomExpRepo as any, personalExpRepo as any, roomRepo as any, studentRepo as any, {} as any),
service: (() => {
const operations = new ExpenseOperationsService(
roomExpRepo as any,
personalExpRepo as any,
roomRepo as any,
studentRepo as any,
{} as any,
undefined as any,
);
return new ExpensesService(
roomExpRepo as any,
personalExpRepo as any,
roomRepo as any,
studentRepo as any,
{} as any,
undefined as any,
operations,
);
})(),
roomExpRepo,
personalExpRepo,
roomRepo,

View File

@@ -31,7 +31,7 @@ import {
} from './dto/expense.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { extractRequestInfo } from '../common/request-utils';
import { logAudit } from '../common/with-audit-log';
import { RequirePermission } from '../auth/decorators/permission.decorator';
import { BatchIdsDto } from '../common/batch-ids.dto';
import * as ExcelJS from 'exceljs';
@@ -91,17 +91,8 @@ export class ExpensesController {
@RequirePermission('expense:create')
async createStudentUtilityBill(@Body() dto: CreateStudentUtilityBillDto, @Request() req: any) {
const result = await this.service.createStudentUtilityBill(dto, req.user?.id);
const { ipAddress, userAgent } = extractRequestInfo(req);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '费用管理',
action: '录入学生水电费并出账',
targetId: result.bill.id,
targetType: 'bill',
detail: `学生${dto.studentId} ${dto.expenseType} ¥${dto.amount},自动扣款 ¥${result.bill.paidAmount}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '费用管理', action: '录入学生水电费并出账', targetId: result.bill.id, targetType: 'bill', detail: `学生${dto.studentId} ${dto.expenseType} ¥${dto.amount},自动扣款 ¥${result.bill.paidAmount}`,
});
return result;
}
@@ -109,18 +100,9 @@ export class ExpensesController {
@Post('room')
@RequirePermission('expense:create')
async createRoomExpense(@Body() dto: CreateRoomExpenseDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.createRoomExpense(dto, req.user?.id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '费用管理',
action: '录入费用',
targetId: result.id,
targetType: 'room_expense',
detail: `房间${dto.roomId} ¥${dto.amount} ${dto.expenseType}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '费用管理', action: '录入费用', targetId: result.id, targetType: 'room_expense', detail: `房间${dto.roomId} ¥${dto.amount} ${dto.expenseType}`,
});
return result;
}
@@ -128,16 +110,9 @@ export class ExpensesController {
@Post('room/batch')
@RequirePermission('expense:create')
async batchCreateRoomExpenses(@Body() dto: BatchRoomExpenseDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.batchCreateRoomExpenses(dto, req.user?.id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '费用管理',
action: '批量录入费用',
detail: JSON.stringify(dto),
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '费用管理', action: '批量录入费用', detail: JSON.stringify(dto),
});
return result;
}
@@ -151,17 +126,9 @@ export class ExpensesController {
@Delete('room/:id')
@RequirePermission('expense:delete')
async deleteRoomExpense(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.deleteRoomExpense(id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '费用管理',
action: '归档费用',
targetId: id,
targetType: 'room_expense',
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '费用管理', action: '归档费用', targetId: id, targetType: 'room_expense',
});
return result;
}
@@ -169,16 +136,29 @@ export class ExpensesController {
@Post('room/batch-delete')
@RequirePermission('expense:delete')
async batchDeleteRoomExpenses(@Body() body: { ids: number[] }, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.batchDeleteRoomExpenses(body.ids || []);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '费用管理',
action: '批量归档宿舍费用',
detail: `IDs: ${(body.ids || []).join(',')}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '费用管理', action: '批量归档宿舍费用', detail: `IDs: ${(body.ids || []).join(',')}`,
});
return result;
}
@Delete('room/:id/permanent')
@RequirePermission('expense:purge')
async purgeRoomExpense(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
const result = await this.service.purgeRoomExpense(id);
await logAudit(this.logService, req, {
module: '费用管理', action: '永久删除宿舍费用', targetId: id, targetType: 'room_expense', detail: '物理删除,不可恢复',
});
return result;
}
@Post('room/batch-permanent-delete')
@RequirePermission('expense:purge')
async batchPurgeRoomExpenses(@Body() body: { ids: number[] }, @Request() req: any) {
const result = await this.service.batchPurgeRoomExpenses(body.ids || []);
await logAudit(this.logService, req, {
module: '费用管理', action: '批量永久删除宿舍费用', detail: `IDs: ${(body.ids || []).join(',')}`,
});
return result;
}
@@ -187,16 +167,9 @@ export class ExpensesController {
@RequirePermission('expense:edit')
@UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }))
async batchRestoreRoomExpenses(@Body() dto: BatchIdsDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.batchRestoreRoomExpenses(dto.ids);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '费用管理',
action: '批量恢复宿舍费用',
detail: `IDs: ${dto.ids.join(',')}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '费用管理', action: '批量恢复宿舍费用', detail: `IDs: ${dto.ids.join(',')}`,
});
return result;
}
@@ -208,18 +181,9 @@ export class ExpensesController {
@Body() dto: UpdateRoomExpenseDto,
@Request() req: any,
) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.updateRoomExpense(id, dto);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '费用管理',
action: '编辑费用',
targetId: id,
targetType: 'room_expense',
detail: `¥${dto.amount} ${dto.expenseType}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '费用管理', action: '编辑费用', targetId: id, targetType: 'room_expense', detail: `¥${dto.amount} ${dto.expenseType}`,
});
return result;
}
@@ -227,16 +191,9 @@ export class ExpensesController {
@Post('personal')
@RequirePermission('expense:create')
async createPersonalExpense(@Body() dto: CreatePersonalExpenseDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.createPersonalExpense(dto, req.user?.id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '费用管理',
action: '录入费用',
detail: `学生${dto.studentId} ¥${dto.amount} ${dto.expenseType}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '费用管理', action: '录入费用', detail: `学生${dto.studentId} ¥${dto.amount} ${dto.expenseType}`,
});
return result;
}
@@ -250,16 +207,9 @@ export class ExpensesController {
@Delete('personal/:id')
@RequirePermission('expense:delete')
async deletePersonalExpense(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.deletePersonalExpense(id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '费用管理',
action: '归档费用',
targetId: id,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '费用管理', action: '归档费用', targetId: id,
});
return result;
}
@@ -267,16 +217,29 @@ export class ExpensesController {
@Post('personal/batch-delete')
@RequirePermission('expense:delete')
async batchDeletePersonalExpenses(@Body() body: { ids: number[] }, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.batchDeletePersonalExpenses(body.ids || []);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '费用管理',
action: '批量归档个人费用',
detail: `IDs: ${(body.ids || []).join(',')}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '费用管理', action: '批量归档个人费用', detail: `IDs: ${(body.ids || []).join(',')}`,
});
return result;
}
@Delete('personal/:id/permanent')
@RequirePermission('expense:purge')
async purgePersonalExpense(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
const result = await this.service.purgePersonalExpense(id);
await logAudit(this.logService, req, {
module: '费用管理', action: '永久删除个人费用', targetId: id, targetType: 'personal_expense', detail: '物理删除,不可恢复',
});
return result;
}
@Post('personal/batch-permanent-delete')
@RequirePermission('expense:purge')
async batchPurgePersonalExpenses(@Body() body: { ids: number[] }, @Request() req: any) {
const result = await this.service.batchPurgePersonalExpenses(body.ids || []);
await logAudit(this.logService, req, {
module: '费用管理', action: '批量永久删除个人费用', detail: `IDs: ${(body.ids || []).join(',')}`,
});
return result;
}
@@ -285,16 +248,9 @@ export class ExpensesController {
@RequirePermission('expense:edit')
@UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }))
async batchRestorePersonalExpenses(@Body() dto: BatchIdsDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.batchRestorePersonalExpenses(dto.ids);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '费用管理',
action: '批量恢复个人费用',
detail: `IDs: ${dto.ids.join(',')}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '费用管理', action: '批量恢复个人费用', detail: `IDs: ${dto.ids.join(',')}`,
});
return result;
}
@@ -306,17 +262,9 @@ export class ExpensesController {
@Body() dto: UpdatePersonalExpenseDto,
@Request() req: any,
) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.updatePersonalExpense(id, dto);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '费用管理',
action: '编辑费用',
targetId: id,
detail: `¥${dto.amount} ${dto.expenseType}`,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '费用管理', action: '编辑费用', targetId: id, detail: `¥${dto.amount} ${dto.expenseType}`,
});
return result;
}
@@ -361,9 +309,8 @@ export class ExpensesController {
@RequirePermission('expense:create')
@UseInterceptors(FileInterceptor('file'))
async importUtilityExpenses(@UploadedFile() file: Express.Multer.File, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(file.buffer as any);
await workbook.xlsx.load(file.buffer.buffer as ArrayBuffer);
const ws = workbook.worksheets[0];
const rows: any[] = [];
ws.eachRow((row, idx) => {
@@ -381,14 +328,8 @@ export class ExpensesController {
});
});
const result = await this.service.batchImportUtilityExpenses(rows, req.user?.id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '费用管理',
action: '导入水电费',
detail: result.message,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '费用管理', action: '导入水电费', detail: result.message,
});
return result;
}
@@ -433,9 +374,8 @@ export class ExpensesController {
@RequirePermission('expense:create')
@UseInterceptors(FileInterceptor('file'))
async importPersonalExpenses(@UploadedFile() file: Express.Multer.File, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(file.buffer as any);
await workbook.xlsx.load(file.buffer.buffer as ArrayBuffer);
const ws = workbook.worksheets[0];
const rows: any[] = [];
ws.eachRow((row, idx) => {
@@ -451,14 +391,8 @@ export class ExpensesController {
});
});
const result = await this.service.batchImportPersonalExpenses(rows, req.user?.id);
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '费用管理',
action: '导入个人附加费',
detail: result.message,
ipAddress,
userAgent,
await logAudit(this.logService, req, {
module: '费用管理', action: '导入个人附加费', detail: result.message,
});
return result;
}

View File

@@ -5,6 +5,7 @@ import { PersonalExpense } from '../entities/personal-expense.entity';
import { Room } from '../entities/room.entity';
import { Student } from '../entities/student.entity';
import { ExpensesService } from './expenses.service';
import { ExpenseOperationsService } from './expense-operations.service';
import { ExpensesController } from './expenses.controller';
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
import { BillsModule } from '../bills/bills.module';
@@ -16,7 +17,7 @@ import { BillsModule } from '../bills/bills.module';
BillsModule,
],
controllers: [ExpensesController],
providers: [ExpensesService],
providers: [ExpensesService, ExpenseOperationsService],
exports: [ExpensesService],
})
export class ExpensesModule {}

View File

@@ -0,0 +1,34 @@
import 'reflect-metadata';
import { PERMISSION_KEY } from '../auth/decorators/permission.decorator';
import { ExpensesController } from './expenses.controller';
describe('ExpensesController purge routes', () => {
it('requires expense:purge on permanent delete routes', () => {
expect(
Reflect.getMetadata(PERMISSION_KEY, ExpensesController.prototype.purgeRoomExpense),
).toEqual(['expense:purge']);
expect(
Reflect.getMetadata(PERMISSION_KEY, ExpensesController.prototype.batchPurgeRoomExpenses),
).toEqual(['expense:purge']);
expect(
Reflect.getMetadata(PERMISSION_KEY, ExpensesController.prototype.purgePersonalExpense),
).toEqual(['expense:purge']);
expect(
Reflect.getMetadata(PERMISSION_KEY, ExpensesController.prototype.batchPurgePersonalExpenses),
).toEqual(['expense:purge']);
});
it('writes permanent delete audit logs', async () => {
const service = {
purgeRoomExpense: jest.fn().mockResolvedValue({ message: '已永久删除宿舍费用(不可恢复)' }),
};
const log = jest.fn().mockResolvedValue(undefined);
const controller = new ExpensesController(service as never, { log } as never);
const req = { user: { id: 1, username: 'admin' }, ip: '127.0.0.1', headers: {} };
await controller.purgeRoomExpense(1, req);
expect(service.purgeRoomExpense).toHaveBeenCalledWith(1);
expect(log).toHaveBeenCalledWith(
expect.objectContaining({ module: '费用管理', action: '永久删除宿舍费用', targetId: 1 }),
);
});
});

View File

@@ -0,0 +1,91 @@
import { BadRequestException } from '@nestjs/common';
import { ExpensesService } from './expenses.service';
import { ExpenseOperationsService } from './expense-operations.service';
describe('ExpensesService purge', () => {
const billItemsRepo = {
count: jest.fn().mockResolvedValue(0),
};
const dataSource = {
getRepository: jest.fn().mockReturnValue(billItemsRepo),
};
const roomExpRepo = {
findOne: jest.fn(),
delete: jest.fn().mockResolvedValue({ affected: 1 }),
find: jest.fn(),
};
const personalExpRepo = {
findOne: jest.fn(),
delete: jest.fn().mockResolvedValue({ affected: 1 }),
find: jest.fn(),
};
const createService = () =>
new ExpensesService(
roomExpRepo as never,
personalExpRepo as never,
{} as never,
{} as never,
{} as never,
dataSource as never,
new ExpenseOperationsService(
roomExpRepo as never,
personalExpRepo as never,
{} as never,
{} as never,
{} as never,
dataSource as never,
),
);
beforeEach(() => {
jest.clearAllMocks();
billItemsRepo.count.mockResolvedValue(0);
});
it('room expense purge rejects non-archived records', async () => {
roomExpRepo.findOne.mockResolvedValue({ id: 1, status: 'active' });
const service = createService();
await expect(service.purgeRoomExpense(1)).rejects.toThrow(
new BadRequestException('仅已归档费用可以永久删除,请先归档'),
);
expect(roomExpRepo.delete).not.toHaveBeenCalled();
});
it('room expense purge rejects billed records', async () => {
roomExpRepo.findOne.mockResolvedValue({ id: 1, status: 'archived' });
billItemsRepo.count.mockResolvedValue(1);
const service = createService();
await expect(service.purgeRoomExpense(1)).rejects.toThrow(
new BadRequestException('已计入账单的宿舍费用不能永久删除,请先取消账单'),
);
expect(roomExpRepo.delete).not.toHaveBeenCalled();
});
it('room expense purge deletes archived records', async () => {
roomExpRepo.findOne.mockResolvedValue({ id: 1, status: 'archived' });
const service = createService();
await expect(service.purgeRoomExpense(1)).resolves.toEqual({
message: '已永久删除宿舍费用(不可恢复)',
});
expect(roomExpRepo.delete).toHaveBeenCalledWith(1);
});
it('personal expense purge rejects records attached to a bill', async () => {
personalExpRepo.findOne.mockResolvedValue({ id: 1, status: 'archived', billId: 9 });
const service = createService();
await expect(service.purgePersonalExpense(1)).rejects.toThrow(
new BadRequestException('已计入账单的个人费用不能永久删除,请先取消账单'),
);
expect(personalExpRepo.delete).not.toHaveBeenCalled();
});
it('personal expense purge deletes archived records with no bill', async () => {
personalExpRepo.findOne.mockResolvedValue({ id: 1, status: 'archived', billId: null });
const service = createService();
await expect(service.purgePersonalExpense(1)).resolves.toEqual({
message: '已永久删除个人费用(不可恢复)',
});
expect(personalExpRepo.delete).toHaveBeenCalledWith(1);
});
});

View File

@@ -11,8 +11,8 @@ import {
BatchRoomExpenseDto,
CreateStudentUtilityBillDto,
} from './dto/expense.dto';
import { RoomsService } from '../rooms/rooms.service';
import { BillsService } from '../bills/bills.service';
import { ExpenseOperationsService } from './expense-operations.service';
@Injectable()
@@ -24,6 +24,7 @@ export class ExpensesService {
@InjectRepository(Student) private studentRepo: Repository<Student>,
private billsService: BillsService,
private dataSource: DataSource,
private operations: ExpenseOperationsService,
) {}
async getFormLookups() {
@@ -120,13 +121,18 @@ export class ExpensesService {
const roomQb = this.roomExpRepo
.createQueryBuilder('e')
.leftJoin('e.room', 'room')
.select('e.id', 'id')
.addSelect('e.expenseType', 'expenseType')
.addSelect('e.amount', 'amount')
.addSelect('e.periodStart', 'periodStart')
.addSelect('e.periodEnd', 'periodEnd')
.addSelect('room.roomNumber', 'roomNumber')
.where('e.status = :status', { status: 'active' });
.select('e.id', 'id');
const roomExpenseSelects = [
['e.expenseType', 'expenseType'],
['e.amount', 'amount'],
['e.periodStart', 'periodStart'],
['e.periodEnd', 'periodEnd'],
['room.roomNumber', 'roomNumber'],
] as const;
for (const [column, alias] of roomExpenseSelects) {
roomQb.addSelect(column, alias);
}
roomQb.where('e.status = :status', { status: 'active' });
if (query?.keyword) {
roomQb.andWhere('room.roomNumber LIKE :keyword', { keyword: `%${query.keyword}%` });
}
@@ -144,13 +150,18 @@ export class ExpensesService {
const personalQb = this.personalExpRepo
.createQueryBuilder('e')
.leftJoin('e.student', 'student')
.select('e.id', 'id')
.addSelect('e.expenseType', 'expenseType')
.addSelect('e.amount', 'amount')
.addSelect('e.expenseDate', 'expenseDate')
.addSelect('student.name', 'studentName')
.addSelect('student.studentNo', 'studentNo')
.where('e.status = :status', { status: 'active' });
.select('e.id', 'id');
const personalExpenseSelects = [
['e.expenseType', 'expenseType'],
['e.amount', 'amount'],
['e.expenseDate', 'expenseDate'],
['student.name', 'studentName'],
['student.studentNo', 'studentNo'],
] as const;
for (const [column, alias] of personalExpenseSelects) {
personalQb.addSelect(column, alias);
}
personalQb.where('e.status = :status', { status: 'active' });
if (query?.keyword) {
personalQb.andWhere(
'(student.name LIKE :keyword OR student.studentNo LIKE :keyword)',
@@ -243,6 +254,48 @@ export class ExpensesService {
return { message: `已批量恢复 ${restored} 条宿舍费用`, restored, skipped };
}
async purgeRoomExpense(id: number) {
const e = await this.roomExpRepo.findOne({ where: { id } });
if (!e) throw new NotFoundException('费用记录不存在');
if (e.status !== 'archived') throw new BadRequestException('仅已归档费用可以永久删除,请先归档');
const billed = await this.dataSource
.getRepository('bill_items')
.count({ where: { roomExpenseId: id } });
if (billed) throw new BadRequestException('已计入账单的宿舍费用不能永久删除,请先取消账单');
await this.roomExpRepo.delete(id);
return { message: '已永久删除宿舍费用(不可恢复)' };
}
async batchPurgeRoomExpenses(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.roomExpRepo.find({ where: { id: In(uniqueIds) } });
if (existing.length !== uniqueIds.length) throw new NotFoundException('部分费用记录不存在');
const billed = await this.dataSource
.getRepository('bill_items')
.count({ where: { roomExpenseId: In(uniqueIds) } });
if (billed) 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.roomExpRepo.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 updateRoomExpense(id: number, dto: Partial<CreateRoomExpenseDto>) {
const e = await this.roomExpRepo.findOne({ where: { id } });
const billed = await this.dataSource?.getRepository('bill_items').count({ where: { roomExpenseId: id } });
@@ -298,97 +351,37 @@ export class ExpensesService {
// 个人附加费
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);
return this.operations.createPersonalExpense(dto, userId);
}
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' },
});
return this.operations.findPersonalExpenses(query);
}
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: '已归档' };
return this.operations.deletePersonalExpense(id);
}
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 };
return this.operations.batchDeletePersonalExpenses(ids);
}
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('选中记录包含已计入账单的个人费用');
}
return this.operations.batchRestorePersonalExpenses(ids);
}
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) {
return this.operations.purgePersonalExpense(id);
}
async batchPurgePersonalExpenses(ids: number[]) {
return this.operations.batchPurgePersonalExpenses(ids);
}
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);
return this.operations.updatePersonalExpense(id, dto);
}
/**
* 水电费Excel批量导入
* Excel格式: 序号|时间|房间号|房间电量|电费|冷水用量(吨)|水费|应缴金额
* 时间格式: "2026-01-21 - 2026-02-08"
*/
async batchImportUtilityExpenses(
rows: {
periodStr: string;
@@ -401,156 +394,9 @@ export class ExpensesService {
}[],
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) {
const expense = byType.get('electricity') || this.roomExpRepo.create({
roomId: room.id,
expenseType: 'electricity',
periodStart,
periodEnd,
importKey: `${room.id}:${periodStart}:${periodEnd}:electricity`,
});
if (expense.id && await this.dataSource?.getRepository('bill_items').count({ where: { roomExpenseId: expense.id } })) {
throw new BadRequestException('该周期电费已计入账单,不能覆盖');
}
expense.amount = row.electricityFee;
expense.description = `电量${row.electricityAmount}kWh`;
expense.recordedBy = userId!;
await this.roomExpRepo.save(expense);
savedAny = true;
}
// 导入水费
if (row.waterFee > 0) {
const expense = byType.get('water') || this.roomExpRepo.create({
roomId: room.id,
expenseType: 'water',
periodStart,
periodEnd,
importKey: `${room.id}:${periodStart}:${periodEnd}:water`,
});
if (expense.id && await this.dataSource?.getRepository('bill_items').count({ where: { roomExpenseId: expense.id } })) {
throw new BadRequestException('该周期水费已计入账单,不能覆盖');
}
expense.amount = row.waterFee;
expense.description = `用水${row.waterAmount}`;
expense.recordedBy = userId!;
await this.roomExpRepo.save(expense);
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,
};
return this.operations.batchImportUtilityExpenses(rows, userId);
}
/** 把 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;
@@ -561,83 +407,6 @@ export class ExpensesService {
}[],
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,
};
return this.operations.batchImportPersonalExpenses(rows, userId);
}
}