feat(task1): restructure directories for turborepo monorepo
- Move backend/ to apps/server/ via git mv - Move frontend/ to apps/admin/ via git mv - Create packages/typescript-config/ with base, nestjs, and react-vite presets
This commit is contained in:
54
apps/server/src/expenses/dto/expense.dto.ts
Normal file
54
apps/server/src/expenses/dto/expense.dto.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { IsInt, IsString, IsNumber, IsOptional, IsEnum } from 'class-validator';
|
||||
|
||||
export class CreateRoomExpenseDto {
|
||||
@IsInt()
|
||||
roomId: number;
|
||||
|
||||
@IsEnum(['water', 'electricity', 'cleaning', 'damage', 'other'])
|
||||
expenseType: string;
|
||||
|
||||
@IsNumber()
|
||||
amount: number;
|
||||
|
||||
@IsString()
|
||||
periodStart: string;
|
||||
|
||||
@IsString()
|
||||
periodEnd: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export class CreatePersonalExpenseDto {
|
||||
@IsInt()
|
||||
studentId: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
roomId?: number;
|
||||
|
||||
@IsEnum(['damage', 'cleaning', 'penalty', 'key', 'remote', 'deposit_deduction', 'other'])
|
||||
expenseType: string;
|
||||
|
||||
@IsNumber()
|
||||
amount: number;
|
||||
|
||||
@IsString()
|
||||
expenseDate: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export class BatchRoomExpenseDto {
|
||||
@IsString()
|
||||
periodStart: string;
|
||||
|
||||
@IsString()
|
||||
periodEnd: string;
|
||||
|
||||
expenses: { roomId: number; expenseType: string; amount: number; description?: string }[];
|
||||
}
|
||||
286
apps/server/src/expenses/expenses.controller.ts
Normal file
286
apps/server/src/expenses/expenses.controller.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards, Request, Res, UseInterceptors, UploadedFile } from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import type { Response } from 'express';
|
||||
import { ExpensesService } from './expenses.service';
|
||||
import { CreateRoomExpenseDto, CreatePersonalExpenseDto, BatchRoomExpenseDto } 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 { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||
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 as any).result;
|
||||
// 富文本:{ richText: [...] }
|
||||
else if ('richText' in v && Array.isArray((v as any).richText)) {
|
||||
return (v as any).richText.map((r: any) => r.text || '').join('');
|
||||
}
|
||||
// 超链接:{ text, hyperlink }
|
||||
else if ('text' in v) v = (v as any).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) {}
|
||||
|
||||
@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 });
|
||||
return result;
|
||||
}
|
||||
|
||||
@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 });
|
||||
return result;
|
||||
}
|
||||
|
||||
@Get('room')
|
||||
@RequirePermission('expense:view')
|
||||
findRoomExpenses(
|
||||
@Query('roomId') roomId?: string,
|
||||
@Query('periodStart') periodStart?: string,
|
||||
@Query('periodEnd') periodEnd?: string,
|
||||
) {
|
||||
return this.service.findRoomExpenses({
|
||||
roomId: roomId ? +roomId : undefined,
|
||||
periodStart, periodEnd,
|
||||
});
|
||||
}
|
||||
|
||||
@Delete('room/:id')
|
||||
@RequirePermission('expense:delete')
|
||||
async deleteRoomExpense(@Param('id') id: string, @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 });
|
||||
return result;
|
||||
}
|
||||
|
||||
@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 });
|
||||
return result;
|
||||
}
|
||||
|
||||
@Put('room/:id')
|
||||
@RequirePermission('expense:edit')
|
||||
async updateRoomExpense(@Param('id') id: string, @Body() dto: CreateRoomExpenseDto, @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 });
|
||||
return result;
|
||||
}
|
||||
|
||||
@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 });
|
||||
return result;
|
||||
}
|
||||
|
||||
@Get('personal')
|
||||
@RequirePermission('expense:view')
|
||||
findPersonalExpenses(@Query('studentId') studentId?: string) {
|
||||
return this.service.findPersonalExpenses({ studentId: studentId ? +studentId : undefined });
|
||||
}
|
||||
|
||||
@Delete('personal/:id')
|
||||
@RequirePermission('expense:delete')
|
||||
async deletePersonalExpense(@Param('id') id: string, @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 });
|
||||
return result;
|
||||
}
|
||||
|
||||
@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 });
|
||||
return result;
|
||||
}
|
||||
|
||||
@Put('personal/:id')
|
||||
@RequirePermission('expense:edit')
|
||||
async updatePersonalExpense(@Param('id') id: string, @Body() dto: CreatePersonalExpenseDto, @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 });
|
||||
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 { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.load(file.buffer as any);
|
||||
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 this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '费用', action: '导入水电费', detail: result.message, ipAddress, userAgent });
|
||||
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 { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
await workbook.xlsx.load(file.buffer as any);
|
||||
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 this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '费用', action: '导入个人附加费', detail: result.message, ipAddress, userAgent });
|
||||
return result;
|
||||
}
|
||||
|
||||
@Get('personal/export')
|
||||
@RequirePermission('expense:view')
|
||||
async exportPersonalExpenses(@Res() res: Response) {
|
||||
const data = await this.service.findPersonalExpenses();
|
||||
const typeMap: Record<string, string> = { damage: '物品损坏', cleaning: '保洁费', penalty: '罚款', key: '钥匙费', remote: '空调遥控器', deposit_deduction: '押金扣除', other: '其他' };
|
||||
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: typeMap[d.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();
|
||||
}
|
||||
}
|
||||
17
apps/server/src/expenses/expenses.module.ts
Normal file
17
apps/server/src/expenses/expenses.module.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { RoomExpense } from '../entities/room-expense.entity';
|
||||
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 { ExpensesController } from './expenses.controller';
|
||||
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([RoomExpense, PersonalExpense, Room, Student]), OperationLogsModule],
|
||||
controllers: [ExpensesController],
|
||||
providers: [ExpensesService],
|
||||
exports: [ExpensesService],
|
||||
})
|
||||
export class ExpensesModule {}
|
||||
330
apps/server/src/expenses/expenses.service.ts
Normal file
330
apps/server/src/expenses/expenses.service.ts
Normal file
@@ -0,0 +1,330 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { RoomExpense } from '../entities/room-expense.entity';
|
||||
import { PersonalExpense } from '../entities/personal-expense.entity';
|
||||
import { Room } from '../entities/room.entity';
|
||||
import { Student } from '../entities/student.entity';
|
||||
import { CreateRoomExpenseDto, CreatePersonalExpenseDto, BatchRoomExpenseDto } from './dto/expense.dto';
|
||||
import { RoomsService } from '../rooms/rooms.service';
|
||||
|
||||
@Injectable()
|
||||
export class ExpensesService {
|
||||
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>,
|
||||
) {}
|
||||
|
||||
// 宿舍费用
|
||||
async createRoomExpense(dto: CreateRoomExpenseDto, userId?: number) {
|
||||
return this.roomExpRepo.save(this.roomExpRepo.create({ ...dto, recordedBy: userId }));
|
||||
}
|
||||
|
||||
async batchCreateRoomExpenses(dto: BatchRoomExpenseDto, userId?: number) {
|
||||
const entities = dto.expenses.map((e) =>
|
||||
this.roomExpRepo.create({
|
||||
roomId: e.roomId,
|
||||
expenseType: e.expenseType,
|
||||
amount: e.amount,
|
||||
description: e.description,
|
||||
periodStart: dto.periodStart,
|
||||
periodEnd: dto.periodEnd,
|
||||
recordedBy: userId,
|
||||
}),
|
||||
);
|
||||
return this.roomExpRepo.save(entities);
|
||||
}
|
||||
|
||||
async findRoomExpenses(query?: { roomId?: number; periodStart?: string; periodEnd?: string }) {
|
||||
const qb = this.roomExpRepo.createQueryBuilder('e')
|
||||
.leftJoinAndSelect('e.room', 'room')
|
||||
.orderBy('e.createdAt', 'DESC');
|
||||
if (query?.roomId) qb.andWhere('e.roomId = :roomId', { roomId: query.roomId });
|
||||
if (query?.periodStart) qb.andWhere('e.periodStart >= :ps', { ps: query.periodStart });
|
||||
if (query?.periodEnd) qb.andWhere('e.periodEnd <= :pe', { pe: query.periodEnd });
|
||||
return qb.getMany();
|
||||
}
|
||||
|
||||
async deleteRoomExpense(id: number) {
|
||||
const e = await this.roomExpRepo.findOne({ where: { id } });
|
||||
if (!e) throw new NotFoundException('费用记录不存在');
|
||||
await this.roomExpRepo.delete(id);
|
||||
return { message: '删除成功' };
|
||||
}
|
||||
|
||||
async batchDeleteRoomExpenses(ids: number[]) {
|
||||
if (!ids || ids.length === 0) throw new BadRequestException('请选择要删除的记录');
|
||||
const result = await this.roomExpRepo.createQueryBuilder()
|
||||
.delete()
|
||||
.where('id IN (:...ids)', { ids })
|
||||
.execute();
|
||||
return { message: '批量删除成功', deleted: result.affected || 0 };
|
||||
}
|
||||
|
||||
async updateRoomExpense(id: number, dto: Partial<CreateRoomExpenseDto>) {
|
||||
const e = await this.roomExpRepo.findOne({ where: { id } });
|
||||
if (!e) throw new NotFoundException('费用记录不存在');
|
||||
Object.assign(e, dto);
|
||||
return this.roomExpRepo.save(e);
|
||||
}
|
||||
|
||||
// 个人附加费
|
||||
async createPersonalExpense(dto: CreatePersonalExpenseDto, userId?: number) {
|
||||
return this.personalExpRepo.save(this.personalExpRepo.create({ ...dto, recordedBy: userId }));
|
||||
}
|
||||
|
||||
async findPersonalExpenses(query?: { studentId?: number }) {
|
||||
const where: any = {};
|
||||
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('费用记录不存在');
|
||||
await this.personalExpRepo.delete(id);
|
||||
return { message: '删除成功' };
|
||||
}
|
||||
|
||||
async batchDeletePersonalExpenses(ids: number[]) {
|
||||
if (!ids || ids.length === 0) throw new BadRequestException('请选择要删除的记录');
|
||||
const result = await this.personalExpRepo.createQueryBuilder()
|
||||
.delete()
|
||||
.where('id IN (:...ids)', { ids })
|
||||
.execute();
|
||||
return { message: '批量删除成功', deleted: result.affected || 0 };
|
||||
}
|
||||
|
||||
async updatePersonalExpense(id: number, dto: Partial<CreatePersonalExpenseDto>) {
|
||||
const e = await this.personalExpRepo.findOne({ where: { id } });
|
||||
if (!e) 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;
|
||||
}
|
||||
|
||||
// 关键校验:电费 + 水费 都为 0 时,多半是 Excel 公式未正确计算或字段缺失,
|
||||
// 必须给出明确错误,避免出现"提示成功但无数据"的迷之现象。
|
||||
if ((row.electricityFee || 0) <= 0 && (row.waterFee || 0) <= 0) {
|
||||
errors.push(`第${rowNum}行: ${row.roomNumber} 电费和水费均为 0,可能 Excel 中是未生效的公式(请打开文件让公式重算后再保存导入),已跳过`);
|
||||
skipped++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// 幂等:先删除该房间在同一周期已有的水/电费用记录,避免重复导入产生脏数据
|
||||
await this.roomExpRepo.createQueryBuilder()
|
||||
.delete()
|
||||
.where('roomId = :roomId', { roomId: room.id })
|
||||
.andWhere('periodStart = :ps AND periodEnd = :pe', { ps: periodStart, pe: periodEnd })
|
||||
.andWhere('expenseType IN (:...types)', { types: ['water', 'electricity'] })
|
||||
.execute();
|
||||
|
||||
let savedAny = false;
|
||||
// 导入电费
|
||||
if (row.electricityFee > 0) {
|
||||
await this.roomExpRepo.save(this.roomExpRepo.create({
|
||||
roomId: room.id,
|
||||
expenseType: 'electricity',
|
||||
amount: row.electricityFee,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
description: `电量${row.electricityAmount}kWh`,
|
||||
recordedBy: userId,
|
||||
}));
|
||||
savedAny = true;
|
||||
}
|
||||
|
||||
// 导入水费
|
||||
if (row.waterFee > 0) {
|
||||
await this.roomExpRepo.save(this.roomExpRepo.create({
|
||||
roomId: room.id,
|
||||
expenseType: 'water',
|
||||
amount: row.waterFee,
|
||||
periodStart,
|
||||
periodEnd,
|
||||
description: `用水${row.waterAmount}吨`,
|
||||
recordedBy: 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,
|
||||
};
|
||||
}
|
||||
|
||||
/** 把 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[] = [];
|
||||
|
||||
const typeMap: Record<string, string> = {
|
||||
'物品损坏': 'damage', '损坏': 'damage',
|
||||
'保洁费': 'cleaning', '保洁': 'cleaning',
|
||||
'罚款': 'penalty',
|
||||
'钥匙费': 'key', '钥匙': 'key',
|
||||
'空调遥控器': 'remote', '遥控器': 'remote',
|
||||
'押金扣除': 'deposit_deduction',
|
||||
'其他': 'other',
|
||||
};
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
// 解析费用类型
|
||||
let expenseType = row.expenseType?.trim() || '';
|
||||
if (typeMap[expenseType]) {
|
||||
expenseType = typeMap[expenseType];
|
||||
}
|
||||
const validTypes = ['damage', 'cleaning', 'penalty', 'key', 'remote', 'deposit_deduction', 'other'];
|
||||
if (!validTypes.includes(expenseType)) {
|
||||
errors.push(`第${rowNum}行: 费用类型"${row.expenseType}"无效`);
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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