此前仅'今天'默认值统一为中国日期,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 测试断言 同步更新为北京时间语义。
426 lines
17 KiB
TypeScript
426 lines
17 KiB
TypeScript
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);
|
||
}
|
||
}
|