432 lines
16 KiB
TypeScript
432 lines
16 KiB
TypeScript
import {
|
|
Controller,
|
|
Get,
|
|
Post,
|
|
Put,
|
|
Delete,
|
|
Body,
|
|
Param,
|
|
Query,
|
|
UseGuards,
|
|
Request,
|
|
Res,
|
|
UseInterceptors,
|
|
UploadedFile,
|
|
ParseIntPipe,
|
|
UsePipes,
|
|
ValidationPipe,
|
|
} from '@nestjs/common';
|
|
import { FileInterceptor } from '@nestjs/platform-express';
|
|
import type { Response } from 'express';
|
|
import { ExpensesService } from './expenses.service';
|
|
import {
|
|
CreateRoomExpenseDto,
|
|
CreatePersonalExpenseDto,
|
|
BatchRoomExpenseDto,
|
|
CreateStudentUtilityBillDto,
|
|
QueryPersonalExpenseDto,
|
|
QueryRoomExpenseDto,
|
|
UpdatePersonalExpenseDto,
|
|
UpdateRoomExpenseDto,
|
|
} from './dto/expense.dto';
|
|
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
|
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
|
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';
|
|
|
|
/** 提取 ExcelJS 单元格的真实值,兼容公式、富文本、日期、超链接等情况 */
|
|
function readCell(cell: ExcelJS.Cell): any {
|
|
let v: any = cell?.value;
|
|
if (v == null) return '';
|
|
if (typeof v === 'object') {
|
|
// 公式单元格:{ formula, result }
|
|
if ('result' in v) v = v.result;
|
|
// 富文本:{ richText: [...] }
|
|
else if ('richText' in v && Array.isArray(v.richText)) {
|
|
return v.richText.map((r: any) => r.text || '').join('');
|
|
}
|
|
// 超链接:{ text, hyperlink }
|
|
else if ('text' in v) v = v.text;
|
|
// 错误值:{ error: '#DIV/0!' }
|
|
else if ('error' in v) return '';
|
|
}
|
|
if (v instanceof Date) {
|
|
const y = v.getFullYear();
|
|
const m = String(v.getMonth() + 1).padStart(2, '0');
|
|
const d = String(v.getDate()).padStart(2, '0');
|
|
return `${y}-${m}-${d}`;
|
|
}
|
|
return v;
|
|
}
|
|
|
|
function readCellNum(cell: ExcelJS.Cell): number {
|
|
const v = readCell(cell);
|
|
if (v === '' || v == null) return 0;
|
|
const n = Number(v);
|
|
return isFinite(n) ? n : 0;
|
|
}
|
|
|
|
function readCellStr(cell: ExcelJS.Cell): string {
|
|
const v = readCell(cell);
|
|
return v == null ? '' : String(v).trim();
|
|
}
|
|
|
|
@UseGuards(JwtAuthGuard)
|
|
@Controller('expenses')
|
|
export class ExpensesController {
|
|
constructor(
|
|
private service: ExpensesService,
|
|
private logService: OperationLogsService,
|
|
) {}
|
|
|
|
@Get('lookups')
|
|
@RequirePermission('expense:create', 'expense:edit')
|
|
getFormLookups() {
|
|
return this.service.getFormLookups();
|
|
}
|
|
|
|
@Post('student-utility')
|
|
@RequirePermission('expense:create')
|
|
async createStudentUtilityBill(@Body() dto: CreateStudentUtilityBillDto, @Request() req: any) {
|
|
const result = await this.service.createStudentUtilityBill(dto, req.user?.id);
|
|
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;
|
|
}
|
|
|
|
@Post('room')
|
|
@RequirePermission('expense:create')
|
|
async createRoomExpense(@Body() dto: CreateRoomExpenseDto, @Request() req: any) {
|
|
const result = await this.service.createRoomExpense(dto, req.user?.id);
|
|
await logAudit(this.logService, req, {
|
|
module: '费用管理', action: '录入费用', targetId: result.id, targetType: 'room_expense', detail: `房间${dto.roomId} ¥${dto.amount} ${dto.expenseType}`,
|
|
});
|
|
return result;
|
|
}
|
|
|
|
@Post('room/batch')
|
|
@RequirePermission('expense:create')
|
|
async batchCreateRoomExpenses(@Body() dto: BatchRoomExpenseDto, @Request() req: any) {
|
|
const result = await this.service.batchCreateRoomExpenses(dto, req.user?.id);
|
|
await logAudit(this.logService, req, {
|
|
module: '费用管理', action: '批量录入费用', detail: JSON.stringify(dto),
|
|
});
|
|
return result;
|
|
}
|
|
|
|
@Get('room')
|
|
@RequirePermission('expense:view')
|
|
findRoomExpenses(@Query() query: QueryRoomExpenseDto) {
|
|
return this.service.findRoomExpenses(query);
|
|
}
|
|
|
|
@Delete('room/:id')
|
|
@RequirePermission('expense:delete')
|
|
async deleteRoomExpense(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
|
const result = await this.service.deleteRoomExpense(id);
|
|
await logAudit(this.logService, req, {
|
|
module: '费用管理', action: '归档费用', targetId: id, targetType: 'room_expense',
|
|
});
|
|
return result;
|
|
}
|
|
|
|
@Post('room/batch-delete')
|
|
@RequirePermission('expense:delete')
|
|
async batchDeleteRoomExpenses(@Body() body: { ids: number[] }, @Request() req: any) {
|
|
const result = await this.service.batchDeleteRoomExpenses(body.ids || []);
|
|
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;
|
|
}
|
|
|
|
@Put('room/batch-restore')
|
|
@RequirePermission('expense:edit')
|
|
@UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }))
|
|
async batchRestoreRoomExpenses(@Body() dto: BatchIdsDto, @Request() req: any) {
|
|
const result = await this.service.batchRestoreRoomExpenses(dto.ids);
|
|
await logAudit(this.logService, req, {
|
|
module: '费用管理', action: '批量恢复宿舍费用', detail: `IDs: ${dto.ids.join(',')}`,
|
|
});
|
|
return result;
|
|
}
|
|
|
|
@Put('room/:id')
|
|
@RequirePermission('expense:edit')
|
|
async updateRoomExpense(
|
|
@Param('id', ParseIntPipe) id: number,
|
|
@Body() dto: UpdateRoomExpenseDto,
|
|
@Request() req: any,
|
|
) {
|
|
const result = await this.service.updateRoomExpense(id, dto);
|
|
await logAudit(this.logService, req, {
|
|
module: '费用管理', action: '编辑费用', targetId: id, targetType: 'room_expense', detail: `¥${dto.amount} ${dto.expenseType}`,
|
|
});
|
|
return result;
|
|
}
|
|
|
|
@Post('personal')
|
|
@RequirePermission('expense:create')
|
|
async createPersonalExpense(@Body() dto: CreatePersonalExpenseDto, @Request() req: any) {
|
|
const result = await this.service.createPersonalExpense(dto, req.user?.id);
|
|
await logAudit(this.logService, req, {
|
|
module: '费用管理', action: '录入费用', detail: `学生${dto.studentId} ¥${dto.amount} ${dto.expenseType}`,
|
|
});
|
|
return result;
|
|
}
|
|
|
|
@Get('personal')
|
|
@RequirePermission('expense:view')
|
|
findPersonalExpenses(@Query() query: QueryPersonalExpenseDto) {
|
|
return this.service.findPersonalExpenses(query);
|
|
}
|
|
|
|
@Delete('personal/:id')
|
|
@RequirePermission('expense:delete')
|
|
async deletePersonalExpense(@Param('id', ParseIntPipe) id: number, @Request() req: any) {
|
|
const result = await this.service.deletePersonalExpense(id);
|
|
await logAudit(this.logService, req, {
|
|
module: '费用管理', action: '归档费用', targetId: id,
|
|
});
|
|
return result;
|
|
}
|
|
|
|
@Post('personal/batch-delete')
|
|
@RequirePermission('expense:delete')
|
|
async batchDeletePersonalExpenses(@Body() body: { ids: number[] }, @Request() req: any) {
|
|
const result = await this.service.batchDeletePersonalExpenses(body.ids || []);
|
|
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;
|
|
}
|
|
|
|
@Put('personal/batch-restore')
|
|
@RequirePermission('expense:edit')
|
|
@UsePipes(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }))
|
|
async batchRestorePersonalExpenses(@Body() dto: BatchIdsDto, @Request() req: any) {
|
|
const result = await this.service.batchRestorePersonalExpenses(dto.ids);
|
|
await logAudit(this.logService, req, {
|
|
module: '费用管理', action: '批量恢复个人费用', detail: `IDs: ${dto.ids.join(',')}`,
|
|
});
|
|
return result;
|
|
}
|
|
|
|
@Put('personal/:id')
|
|
@RequirePermission('expense:edit')
|
|
async updatePersonalExpense(
|
|
@Param('id', ParseIntPipe) id: number,
|
|
@Body() dto: UpdatePersonalExpenseDto,
|
|
@Request() req: any,
|
|
) {
|
|
const result = await this.service.updatePersonalExpense(id, dto);
|
|
await logAudit(this.logService, req, {
|
|
module: '费用管理', action: '编辑费用', targetId: id, detail: `¥${dto.amount} ${dto.expenseType}`,
|
|
});
|
|
return result;
|
|
}
|
|
|
|
@Get('utility/template')
|
|
@RequirePermission('expense:view')
|
|
async downloadUtilityTemplate(@Res() res: Response) {
|
|
const workbook = new ExcelJS.Workbook();
|
|
const ws = workbook.addWorksheet('水电费导入模板');
|
|
ws.columns = [
|
|
{ header: '序号', key: 'seq', width: 8 },
|
|
{ header: '时间', key: 'period', width: 30 },
|
|
{ header: '房间号', key: 'roomNumber', width: 12 },
|
|
{ header: '房间电量', key: 'electricity', width: 12 },
|
|
{ header: '电费', key: 'electricityFee', width: 10 },
|
|
{ header: '冷水用量(吨)', key: 'water', width: 14 },
|
|
{ header: '水费', key: 'waterFee', width: 10 },
|
|
{ header: '应缴金额', key: 'total', width: 12 },
|
|
];
|
|
ws.getRow(1).font = { bold: true };
|
|
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
|
|
ws.addRow({
|
|
seq: 1,
|
|
period: '2026-01-21 - 2026-02-08',
|
|
roomNumber: '4-102',
|
|
electricity: 50,
|
|
electricityFee: 25.5,
|
|
water: 3,
|
|
waterFee: 14.7,
|
|
total: 40.2,
|
|
});
|
|
res.setHeader(
|
|
'Content-Type',
|
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
);
|
|
res.setHeader('Content-Disposition', 'attachment; filename=utility_template.xlsx');
|
|
await workbook.xlsx.write(res);
|
|
res.end();
|
|
}
|
|
|
|
@Post('utility/import')
|
|
@RequirePermission('expense:create')
|
|
@UseInterceptors(FileInterceptor('file'))
|
|
async importUtilityExpenses(@UploadedFile() file: Express.Multer.File, @Request() req: any) {
|
|
const workbook = new ExcelJS.Workbook();
|
|
await workbook.xlsx.load(file.buffer.buffer as ArrayBuffer);
|
|
const ws = workbook.worksheets[0];
|
|
const rows: any[] = [];
|
|
ws.eachRow((row, idx) => {
|
|
if (idx === 1) return; // 跳过表头
|
|
const roomNumber = readCellStr(row.getCell(3));
|
|
if (!roomNumber) return;
|
|
rows.push({
|
|
periodStr: readCellStr(row.getCell(2)),
|
|
roomNumber,
|
|
electricityAmount: readCellNum(row.getCell(4)),
|
|
electricityFee: readCellNum(row.getCell(5)),
|
|
waterAmount: readCellNum(row.getCell(6)),
|
|
waterFee: readCellNum(row.getCell(7)),
|
|
totalFee: readCellNum(row.getCell(8)),
|
|
});
|
|
});
|
|
const result = await this.service.batchImportUtilityExpenses(rows, req.user?.id);
|
|
await logAudit(this.logService, req, {
|
|
module: '费用管理', action: '导入水电费', detail: result.message,
|
|
});
|
|
return result;
|
|
}
|
|
|
|
@Get('personal/template')
|
|
@RequirePermission('expense:view')
|
|
async downloadPersonalTemplate(@Res() res: Response) {
|
|
const workbook = new ExcelJS.Workbook();
|
|
const ws = workbook.addWorksheet('个人附加费导入模板');
|
|
ws.columns = [
|
|
{ header: '学生姓名', key: 'studentName', width: 15 },
|
|
{ header: '费用类型', key: 'expenseType', width: 15 },
|
|
{ header: '金额', key: 'amount', width: 12 },
|
|
{ header: '费用日期', key: 'expenseDate', width: 15 },
|
|
{ header: '说明', key: 'description', width: 25 },
|
|
];
|
|
ws.getRow(1).font = { bold: true };
|
|
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
|
|
ws.addRow({
|
|
studentName: '张三',
|
|
expenseType: '钥匙费',
|
|
amount: 30,
|
|
expenseDate: '2026-01-15',
|
|
description: '丢失宿舍钥匙',
|
|
});
|
|
// 添加费用类型说明
|
|
const noteSheet = workbook.addWorksheet('费用类型说明');
|
|
noteSheet.columns = [{ header: '费用类型可用值', key: 'type', width: 25 }];
|
|
['物品损坏', '保洁费', '罚款', '钥匙费', '空调遥控器', '押金扣除', '其他'].forEach((t) =>
|
|
noteSheet.addRow({ type: t }),
|
|
);
|
|
res.setHeader(
|
|
'Content-Type',
|
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
);
|
|
res.setHeader('Content-Disposition', 'attachment; filename=personal_expense_template.xlsx');
|
|
await workbook.xlsx.write(res);
|
|
res.end();
|
|
}
|
|
|
|
@Post('personal/import')
|
|
@RequirePermission('expense:create')
|
|
@UseInterceptors(FileInterceptor('file'))
|
|
async importPersonalExpenses(@UploadedFile() file: Express.Multer.File, @Request() req: any) {
|
|
const workbook = new ExcelJS.Workbook();
|
|
await workbook.xlsx.load(file.buffer.buffer as ArrayBuffer);
|
|
const ws = workbook.worksheets[0];
|
|
const rows: any[] = [];
|
|
ws.eachRow((row, idx) => {
|
|
if (idx === 1) return;
|
|
const studentName = readCellStr(row.getCell(1));
|
|
if (!studentName) return;
|
|
rows.push({
|
|
studentName,
|
|
expenseType: readCellStr(row.getCell(2)),
|
|
amount: readCellNum(row.getCell(3)),
|
|
expenseDate: readCellStr(row.getCell(4)),
|
|
description: readCellStr(row.getCell(5)) || undefined,
|
|
});
|
|
});
|
|
const result = await this.service.batchImportPersonalExpenses(rows, req.user?.id);
|
|
await logAudit(this.logService, req, {
|
|
module: '费用管理', action: '导入个人附加费', detail: result.message,
|
|
});
|
|
return result;
|
|
}
|
|
|
|
@Get('personal/export')
|
|
@RequirePermission('expense:view')
|
|
async exportPersonalExpenses(@Res() res: Response) {
|
|
const data = await this.service.findPersonalExpenses();
|
|
const workbook = new ExcelJS.Workbook();
|
|
const ws = workbook.addWorksheet('个人附加费');
|
|
ws.columns = [
|
|
{ header: '学生姓名', key: 'studentName', width: 15 },
|
|
{ header: '费用类型', key: 'expenseType', width: 15 },
|
|
{ header: '金额', key: 'amount', width: 12 },
|
|
{ header: '费用日期', key: 'expenseDate', width: 15 },
|
|
{ header: '说明', key: 'description', width: 30 },
|
|
];
|
|
ws.getRow(1).font = { bold: true };
|
|
data.forEach((d: any) => {
|
|
ws.addRow({
|
|
studentName: d.student?.name || '',
|
|
expenseType: d.expenseType,
|
|
amount: Number(d.amount),
|
|
expenseDate: d.expenseDate,
|
|
description: d.description || '',
|
|
});
|
|
});
|
|
res.setHeader(
|
|
'Content-Type',
|
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
);
|
|
res.setHeader('Content-Disposition', 'attachment; filename=personal_expenses_export.xlsx');
|
|
await workbook.xlsx.write(res);
|
|
res.end();
|
|
}
|
|
}
|