Files
gongxue-base/apps/server/src/expenses/expenses.service.ts

406 lines
14 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, In } 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,
CreateStudentUtilityBillDto,
} from './dto/expense.dto';
import { RoomsService } from '../rooms/rooms.service';
import { BillsService } from '../bills/bills.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>,
private billsService: BillsService,
) {}
async getFormLookups() {
const [rooms, students] = await Promise.all([
this.roomRepo.find({
select: ['id', 'roomNumber', 'building'],
order: { building: 'ASC', roomNumber: 'ASC' },
}),
this.studentRepo.find({
select: ['id', 'name', 'studentNo'],
where: { status: 'active' },
order: { name: 'ASC' },
}),
]);
return { rooms, students };
}
// 宿舍费用
async createRoomExpense(dto: CreateRoomExpenseDto, userId?: number) {
const room = await this.roomRepo.findOne({ where: { id: dto.roomId } });
if (!room) throw new NotFoundException('宿舍不存在');
const entity = this.roomExpRepo.create({ ...dto, recordedBy: userId });
return this.roomExpRepo.save(entity);
}
async batchCreateRoomExpenses(dto: BatchRoomExpenseDto, userId?: number) {
const entities = dto.expenses.map((e) => {
const entity = this.roomExpRepo.create({
roomId: e.roomId,
expenseType: e.expenseType,
amount: e.amount,
description: e.description,
periodStart: dto.periodStart,
periodEnd: dto.periodEnd,
recordedBy: userId,
});
return entity;
});
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 createStudentUtilityBill(dto: CreateStudentUtilityBillDto, userId?: number) {
if (dto.periodEnd < dto.periodStart) throw new BadRequestException('账期结束日期不能早于开始日期');
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
if (!student) throw new NotFoundException('学生不存在');
const expense = await this.personalExpRepo.save(
this.personalExpRepo.create({
studentId: dto.studentId,
expenseType: dto.expenseType,
amount: dto.amount,
expenseDate: dto.periodEnd,
description: dto.description || (dto.expenseType === 'water' ? '学生水费' : '学生电费'),
recordedBy: userId,
billId: null,
}),
);
try {
const bill = await this.billsService.createImmediatePersonalBill(expense, dto.periodStart, dto.periodEnd, userId);
return { expense, bill };
} catch (error) {
await this.personalExpRepo.delete(expense.id);
throw error;
}
}
// 个人附加费
async createPersonalExpense(dto: CreatePersonalExpenseDto, userId?: number) {
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 }) {
const where: Record<string, unknown> = {};
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[] = [];
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;
}
}
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,
};
}
}