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(); } }