406 lines
14 KiB
TypeScript
406 lines
14 KiB
TypeScript
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,
|
||
};
|
||
}
|
||
}
|