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

644 lines
25 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 { DataSource, In, 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,
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,
private dataSource: DataSource,
) {}
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) {
this.assertValidPeriod(dto.periodStart, dto.periodEnd);
this.assertPositiveAmount(dto.amount);
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) {
this.assertValidPeriod(dto.periodStart, dto.periodEnd);
if (!dto.expenses?.length) throw new BadRequestException('请至少填写一条费用');
dto.expenses.forEach((expense) => this.assertPositiveAmount(expense.amount));
const roomIds = [...new Set(dto.expenses.map((expense) => expense.roomId))];
const existingRooms = await this.roomRepo.find({ where: { id: In(roomIds) }, select: ['id'] });
if (existingRooms.length !== roomIds.length) throw new NotFoundException('部分宿舍不存在');
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; status?: 'active' | 'archived' }) {
const status = query?.status ?? 'active';
if (status !== 'active' && status !== 'archived') throw new BadRequestException('费用状态无效');
const qb = this.roomExpRepo
.createQueryBuilder('e')
.leftJoinAndSelect('e.room', 'room')
.where('e.status = :status', { status })
.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();
}
/**
* Agent tool: 费用查询(宿舍费 + 个人附加费),返回白名单字段。
*/
async agentSearchExpenses(query?: {
keyword?: string;
periodStart?: string;
periodEnd?: string;
limit?: number;
}): Promise<{
roomExpenses: {
id: number;
expenseType: string;
amount: number;
periodStart: string;
periodEnd: string;
roomNumber: string;
status: string;
}[];
personalExpenses: {
id: number;
expenseType: string;
amount: number;
expenseDate: string;
studentName: string;
studentNo: string;
status: string;
}[];
}> {
const limit = Math.max(1, Math.min(query?.limit ?? 10, 30));
const roomQb = this.roomExpRepo
.createQueryBuilder('e')
.leftJoin('e.room', 'room')
.select('e.id', 'id')
.addSelect('e.expenseType', 'expenseType')
.addSelect('e.amount', 'amount')
.addSelect('e.periodStart', 'periodStart')
.addSelect('e.periodEnd', 'periodEnd')
.addSelect('room.roomNumber', 'roomNumber')
.where('e.status = :status', { status: 'active' });
if (query?.keyword) {
roomQb.andWhere('room.roomNumber LIKE :keyword', { keyword: `%${query.keyword}%` });
}
if (query?.periodStart) {
roomQb.andWhere('e.periodStart >= :periodStart', { periodStart: query.periodStart });
}
if (query?.periodEnd) {
roomQb.andWhere('e.periodEnd <= :periodEnd', { periodEnd: query.periodEnd });
}
const roomRows = await roomQb
.orderBy('e.createdAt', 'DESC')
.limit(limit)
.getRawMany<Record<string, unknown>>();
const personalQb = this.personalExpRepo
.createQueryBuilder('e')
.leftJoin('e.student', 'student')
.select('e.id', 'id')
.addSelect('e.expenseType', 'expenseType')
.addSelect('e.amount', 'amount')
.addSelect('e.expenseDate', 'expenseDate')
.addSelect('student.name', 'studentName')
.addSelect('student.studentNo', 'studentNo')
.where('e.status = :status', { status: 'active' });
if (query?.keyword) {
personalQb.andWhere(
'(student.name LIKE :keyword OR student.studentNo LIKE :keyword)',
{ keyword: `%${query.keyword}%` },
);
}
if (query?.periodStart) {
personalQb.andWhere('e.expenseDate >= :periodStart', { periodStart: query.periodStart });
}
if (query?.periodEnd) {
personalQb.andWhere('e.expenseDate <= :periodEnd', { periodEnd: query.periodEnd });
}
const personalRows = await personalQb
.orderBy('e.createdAt', 'DESC')
.limit(limit)
.getRawMany<Record<string, unknown>>();
return {
roomExpenses: roomRows.map((row) => ({
id: Number(row.id),
expenseType: String(row.expenseType),
amount: Number(row.amount),
periodStart: String(row.periodStart),
periodEnd: String(row.periodEnd),
roomNumber: row.roomNumber == null ? '' : String(row.roomNumber),
status: String(row.status),
})),
personalExpenses: personalRows.map((row) => ({
id: Number(row.id),
expenseType: String(row.expenseType),
amount: Number(row.amount),
expenseDate: String(row.expenseDate),
studentName: row.studentName == null ? '' : String(row.studentName),
studentNo: row.studentNo == null ? '' : String(row.studentNo),
status: String(row.status),
})),
};
}
async deleteRoomExpense(id: number) {
const e = await this.roomExpRepo.findOne({ where: { id } });
if (!e) throw new NotFoundException('费用记录不存在');
const billed = await this.dataSource?.getRepository('bill_items').count({ where: { roomExpenseId: id } });
if (billed) throw new BadRequestException('已计入账单的宿舍费用不能归档,请先取消账单');
if (e.status === 'archived') throw new BadRequestException('费用记录已归档');
await this.roomExpRepo.update(id, { status: 'archived' });
return { message: '已归档' };
}
async batchDeleteRoomExpenses(ids: number[]) {
const uniqueIds = [...new Set(ids || [])];
if (uniqueIds.length === 0) throw new BadRequestException('请选择要归档的记录');
const existing = await this.roomExpRepo.find({ where: { id: In(uniqueIds) }, select: ['id'] });
const billed = await this.dataSource?.getRepository('bill_items').count({ where: { roomExpenseId: In(uniqueIds) } });
if (billed) throw new BadRequestException('选中记录包含已计入账单的宿舍费用');
if (existing.length !== uniqueIds.length) throw new NotFoundException('部分费用记录不存在');
const result = await this.roomExpRepo
.createQueryBuilder()
.update()
.set({ status: 'archived' })
.where('id IN (:...ids)', { ids: uniqueIds })
.execute();
return { message: `已批量归档 ${result.affected || 0}`, archived: result.affected || 0 };
}
async batchRestoreRoomExpenses(ids: number[]) {
const uniqueIds = [...new Set(ids || [])];
if (uniqueIds.length === 0) throw new BadRequestException('请选择要恢复的记录');
if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) {
throw new BadRequestException('费用记录 ID 无效');
}
const existing = await this.roomExpRepo.find({ where: { id: In(uniqueIds) } });
if (existing.length !== uniqueIds.length) throw new NotFoundException('部分费用记录不存在');
const targetIds = existing.filter((expense) => expense.status === 'archived').map((expense) => expense.id);
const skipped = existing.length - targetIds.length;
let restored = 0;
if (targetIds.length > 0) {
const billed = await this.dataSource
.getRepository('bill_items')
.count({ where: { roomExpenseId: In(targetIds) } });
if (billed) throw new BadRequestException('选中记录包含已计入账单的宿舍费用');
const result = await this.roomExpRepo
.createQueryBuilder()
.update()
.set({ status: 'active' })
.where('id IN (:...ids)', { ids: targetIds })
.execute();
restored = result.affected || 0;
}
return { message: `已批量恢复 ${restored} 条宿舍费用`, restored, skipped };
}
async updateRoomExpense(id: number, dto: Partial<CreateRoomExpenseDto>) {
const e = await this.roomExpRepo.findOne({ where: { id } });
const billed = await this.dataSource?.getRepository('bill_items').count({ where: { roomExpenseId: id } });
if (billed) throw new BadRequestException('已计入账单的宿舍费用不能修改,请先取消账单');
if (!e) throw new NotFoundException('费用记录不存在');
const periodStart = dto.periodStart ?? e.periodStart;
const periodEnd = dto.periodEnd ?? e.periodEnd;
this.assertValidPeriod(periodStart, periodEnd);
if (dto.amount !== undefined) this.assertPositiveAmount(dto.amount);
if (dto.roomId !== undefined && dto.roomId !== e.roomId) {
const room = await this.roomRepo.findOne({ where: { id: dto.roomId } });
if (!room) throw new NotFoundException('宿舍不存在');
}
Object.assign(e, dto);
return this.roomExpRepo.save(e);
}
private assertPositiveAmount(amount: number) {
if (!Number.isFinite(amount) || Math.abs(amount * 100 - Math.round(amount * 100)) > 1e-8) {
throw new BadRequestException('费用金额最多保留两位小数');
}
if (amount <= 0) throw new BadRequestException('费用金额必须大于0');
}
private assertValidPeriod(periodStart: string, periodEnd: string) {
if (!this.isValidDate(periodStart) || !this.isValidDate(periodEnd) || periodEnd < periodStart) {
throw new BadRequestException('账期无效,结束日期不能早于开始日期');
}
}
private isValidDate(value: string) {
if (!/^\d{4}-\d{2}-\d{2}$/.test(value || '')) return false;
const date = new Date(`${value}T00:00:00Z`);
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value;
}
async createStudentUtilityBill(dto: CreateStudentUtilityBillDto, userId?: number) {
this.assertValidPeriod(dto.periodStart, dto.periodEnd);
this.assertPositiveAmount(dto.amount);
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
if (!student) throw new NotFoundException('学生不存在');
const expense = {
studentId: dto.studentId,
expenseType: dto.expenseType,
amount: dto.amount,
expenseDate: dto.periodEnd,
description: dto.description || (dto.expenseType === 'water' ? '学生水费' : '学生电费'),
recordedBy: userId,
billId: null,
} as PersonalExpense;
return this.billsService.createImmediatePersonalBill(expense, dto.periodStart, dto.periodEnd, userId);
}
// 个人附加费
async createPersonalExpense(dto: CreatePersonalExpenseDto, userId?: number) {
this.assertPositiveAmount(dto.amount);
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; status?: 'active' | 'archived' }) {
const status = query?.status ?? 'active';
if (status !== 'active' && status !== 'archived') throw new BadRequestException('费用状态无效');
const where: Record<string, unknown> = { status };
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('费用记录不存在');
if (e.billId) throw new BadRequestException('已计入账单的个人费用不能归档,请先取消账单');
if (e.status === 'archived') throw new BadRequestException('费用记录已归档');
await this.personalExpRepo.update(id, { status: 'archived' });
return { message: '已归档' };
}
async batchDeletePersonalExpenses(ids: number[]) {
const uniqueIds = [...new Set(ids || [])];
if (uniqueIds.length === 0) throw new BadRequestException('请选择要归档的记录');
const existing = await this.personalExpRepo.find({ where: { id: In(uniqueIds) } });
if (existing.length !== uniqueIds.length) throw new NotFoundException('部分费用记录不存在');
if (existing.some((expense) => expense.billId)) {
throw new BadRequestException('选中记录包含已计入账单的个人费用');
}
const result = await this.personalExpRepo
.createQueryBuilder()
.update()
.set({ status: 'archived' })
.where('id IN (:...ids)', { ids: uniqueIds })
.execute();
return { message: `已批量归档 ${result.affected || 0}`, archived: result.affected || 0 };
}
async batchRestorePersonalExpenses(ids: number[]) {
const uniqueIds = [...new Set(ids || [])];
if (uniqueIds.length === 0) throw new BadRequestException('请选择要恢复的记录');
if (uniqueIds.some((id) => !Number.isInteger(id) || id <= 0)) {
throw new BadRequestException('费用记录 ID 无效');
}
const existing = await this.personalExpRepo.find({ where: { id: In(uniqueIds) } });
if (existing.length !== uniqueIds.length) throw new NotFoundException('部分费用记录不存在');
const targets = existing.filter((expense) => expense.status === 'archived');
if (targets.some((expense) => expense.billId)) {
throw new BadRequestException('选中记录包含已计入账单的个人费用');
}
const targetIds = targets.map((expense) => expense.id);
const skipped = existing.length - targetIds.length;
let restored = 0;
if (targetIds.length > 0) {
const result = await this.personalExpRepo
.createQueryBuilder()
.update()
.set({ status: 'active' })
.where('id IN (:...ids)', { ids: targetIds })
.execute();
restored = result.affected || 0;
}
return { message: `已批量恢复 ${restored} 条个人费用`, restored, skipped };
}
async updatePersonalExpense(id: number, dto: Partial<CreatePersonalExpenseDto>) {
const e = await this.personalExpRepo.findOne({ where: { id } });
if (!e) throw new NotFoundException('费用记录不存在');
if (e.billId) throw new BadRequestException('已计入账单的个人费用不能修改,请先取消账单');
if (dto.amount !== undefined) this.assertPositiveAmount(dto.amount);
if (dto.studentId !== undefined && dto.studentId !== e.studentId) {
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
if (!student) 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;
}
if (!this.isValidDate(periodStart) || !this.isValidDate(periodEnd) || periodEnd < periodStart) {
errors.push(`${rowNum}行: ${row.roomNumber} 账期无效(${periodStart} ~ ${periodEnd}),已跳过`);
skipped++;
continue;
}
// 关键校验:电费 + 水费 都为 0 时,多半是 Excel 公式未正确计算或字段缺失,
// 必须给出明确错误,避免出现"提示成功但无数据"的迷之现象。
if ((row.electricityFee || 0) <= 0 && (row.waterFee || 0) <= 0) {
errors.push(
`${rowNum}行: ${row.roomNumber} 电费和水费均为 0可能 Excel 中是未生效的公式(请打开文件让公式重算后再保存导入),已跳过`,
);
skipped++;
continue;
}
const existing = await this.roomExpRepo.find({
where: [
{ importKey: `${room.id}:${periodStart}:${periodEnd}:electricity` },
{ importKey: `${room.id}:${periodStart}:${periodEnd}:water` },
],
});
const byType = new Map(existing.map((expense) => [expense.expenseType, expense]));
let savedAny = false;
// 导入电费
if (row.electricityFee > 0) {
const expense = byType.get('electricity') || this.roomExpRepo.create({
roomId: room.id,
expenseType: 'electricity',
periodStart,
periodEnd,
importKey: `${room.id}:${periodStart}:${periodEnd}:electricity`,
});
if (expense.id && await this.dataSource?.getRepository('bill_items').count({ where: { roomExpenseId: expense.id } })) {
throw new BadRequestException('该周期电费已计入账单,不能覆盖');
}
expense.amount = row.electricityFee;
expense.description = `电量${row.electricityAmount}kWh`;
expense.recordedBy = userId!;
await this.roomExpRepo.save(expense);
savedAny = true;
}
// 导入水费
if (row.waterFee > 0) {
const expense = byType.get('water') || this.roomExpRepo.create({
roomId: room.id,
expenseType: 'water',
periodStart,
periodEnd,
importKey: `${room.id}:${periodStart}:${periodEnd}:water`,
});
if (expense.id && await this.dataSource?.getRepository('bill_items').count({ where: { roomExpenseId: expense.id } })) {
throw new BadRequestException('该周期水费已计入账单,不能覆盖');
}
expense.amount = row.waterFee;
expense.description = `用水${row.waterAmount}`;
expense.recordedBy = userId!;
await this.roomExpRepo.save(expense);
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;
}
}
// 校验金额
try {
this.assertPositiveAmount(row.amount);
} catch (e: any) {
errors.push(`${rowNum}行: ${row.studentName} ${e.message}`);
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,
};
}
}