Files
gongxue-base/apps/server/src/expenses/expenses.service.ts
wangziqi 4dd7b5a0b1 fix(server): 时间戳→业务日期/时间全部统一为中国时区(UTC+8)
此前仅'今天'默认值统一为中国日期,Date→字符串的转换仍是 UTC:
- sync 考勤同步窗口 lastSyncAt 时间戳按 UTC 取日期,凌晨会偏一天
- 考勤导出 punchTime/createdAt 显示 UTC 时间
- normalizeDateOnly 把 ISO datetime 按 UTC 归一化,业务日期少一天
- getCourseClock/getChinaDateParts 用 dayjs.utc(date).add(8h) 隐式转换

统一为 dayjs(date).utcOffset(8),纯日期字符串运算(shiftDate/addDays/
daysInMonth/nextMonth)保留 dayjs.utc;date-normalization 测试断言
同步更新为北京时间语义。
2026-08-08 18:01:35 +08:00

426 lines
17 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 { BillsService } from '../bills/bills.service';
import dayjs from '../common/dayjs';
import { ExpenseOperationsService } from './expense-operations.service';
/** getRawMany 返回的原始行:数据库标量值(string/number/Date)或 NULL */
type RawScalarRow = Record<string, string | number | Date | null>;
@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,
private operations: ExpenseOperationsService,
) {}
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 getStudentLookups() {
return this.studentRepo.find({
select: ['id', 'name', 'studentNo'],
where: { status: 'active' },
order: { name: 'ASC' },
});
}
// 宿舍费用
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');
const roomExpenseSelects = [
['e.expenseType', 'expenseType'],
['e.amount', 'amount'],
['e.periodStart', 'periodStart'],
['e.periodEnd', 'periodEnd'],
['room.roomNumber', 'roomNumber'],
] as const;
for (const [column, alias] of roomExpenseSelects) {
roomQb.addSelect(column, alias);
}
roomQb.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<RawScalarRow>();
const personalQb = this.personalExpRepo
.createQueryBuilder('e')
.leftJoin('e.student', 'student')
.select('e.id', 'id');
const personalExpenseSelects = [
['e.expenseType', 'expenseType'],
['e.amount', 'amount'],
['e.expenseDate', 'expenseDate'],
['student.name', 'studentName'],
['student.studentNo', 'studentNo'],
] as const;
for (const [column, alias] of personalExpenseSelects) {
personalQb.addSelect(column, alias);
}
personalQb.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<RawScalarRow>();
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 purgeRoomExpense(id: number) {
const e = await this.roomExpRepo.findOne({ where: { id } });
if (!e) throw new NotFoundException('费用记录不存在');
if (e.status !== 'archived') throw new BadRequestException('仅已归档费用可以永久删除,请先归档');
const billed = await this.dataSource
.getRepository('bill_items')
.count({ where: { roomExpenseId: id } });
if (billed) throw new BadRequestException('已计入账单的宿舍费用不能永久删除,请先取消账单');
await this.roomExpRepo.delete(id);
return { message: '已永久删除宿舍费用(不可恢复)' };
}
async batchPurgeRoomExpenses(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 billed = await this.dataSource
.getRepository('bill_items')
.count({ where: { roomExpenseId: In(uniqueIds) } });
if (billed) throw new BadRequestException('选中记录包含已计入账单的宿舍费用');
const deleted: number[] = [];
const skipped: string[] = [];
for (const e of existing) {
if (e.status !== 'archived') {
skipped.push(`记录${e.id}(未归档)`);
continue;
}
await this.roomExpRepo.delete(e.id);
deleted.push(e.id);
}
const message =
skipped.length > 0
? `已永久删除 ${deleted.length} 条;${skipped.length} 条被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''}`
: `已永久删除 ${deleted.length} 条宿舍费用(不可恢复)`;
return { message, deleted: deleted.length, skipped: skipped.length };
}
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()) && dayjs(date).utcOffset(8).format('YYYY-MM-DD') === 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) {
return this.operations.createPersonalExpense(dto, userId);
}
async findPersonalExpenses(query?: { studentId?: number; status?: 'active' | 'archived' }) {
return this.operations.findPersonalExpenses(query);
}
async deletePersonalExpense(id: number) {
return this.operations.deletePersonalExpense(id);
}
async batchDeletePersonalExpenses(ids: number[]) {
return this.operations.batchDeletePersonalExpenses(ids);
}
async batchRestorePersonalExpenses(ids: number[]) {
return this.operations.batchRestorePersonalExpenses(ids);
}
async purgePersonalExpense(id: number) {
return this.operations.purgePersonalExpense(id);
}
async batchPurgePersonalExpenses(ids: number[]) {
return this.operations.batchPurgePersonalExpenses(ids);
}
async updatePersonalExpense(id: number, dto: Partial<CreatePersonalExpenseDto>) {
return this.operations.updatePersonalExpense(id, dto);
}
async batchImportUtilityExpenses(
rows: {
periodStr: string;
roomNumber: string;
electricityAmount: number;
electricityFee: number;
waterAmount: number;
waterFee: number;
totalFee: number;
}[],
userId?: number,
) {
return this.operations.batchImportUtilityExpenses(rows, userId);
}
async batchImportPersonalExpenses(
rows: {
studentName: string;
expenseType: string;
amount: number;
expenseDate: string;
description?: string;
}[],
userId?: number,
) {
return this.operations.batchImportPersonalExpenses(rows, userId);
}
}