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:
2026-07-02 15:05:12 +08:00
parent 4704adcba1
commit 46a817503e
137 changed files with 52 additions and 0 deletions

View 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 }[];
}

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

View 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 {}

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