fix: 归档删除改为软删除

This commit is contained in:
2026-07-17 14:13:17 +08:00
parent 3131d2e141
commit f7328d670d
49 changed files with 520 additions and 354 deletions

View File

@@ -16,6 +16,7 @@ function createService(bills: Partial<Bill>[] = []) {
find: jest.fn().mockResolvedValue(bills),
findOne: jest.fn().mockResolvedValue(bills[0] ?? null),
save: jest.fn(async (value) => value),
update: jest.fn(),
createQueryBuilder: jest.fn(() => queryBuilder()),
};
const manager = {
@@ -55,23 +56,26 @@ describe('BillsService state and batch boundaries', () => {
expect(billRepo.save).not.toHaveBeenCalled();
});
it('rejects an empty batch delete', async () => {
it('rejects an empty batch archive', async () => {
const { service, dataSource } = createService();
await expect(service.batchRemove([])).rejects.toBeInstanceOf(BadRequestException);
expect(dataSource.transaction).not.toHaveBeenCalled();
});
it('rejects a batch delete when some ids do not exist', async () => {
it('rejects a batch archive when some ids do not exist', async () => {
const { service, dataSource } = createService([{ id: 1, paidAmount: 0, status: 'unpaid' }]);
await expect(service.batchRemove([1, 2])).rejects.toBeInstanceOf(NotFoundException);
expect(dataSource.transaction).not.toHaveBeenCalled();
});
it('deletes a bill and its links in one transaction', async () => {
const { service, dataSource, manager } = createService([{ id: 1, paidAmount: 0, status: 'unpaid' }]);
await expect(service.remove(1)).resolves.toEqual({ message: '账单已删除' });
expect(dataSource.transaction).toHaveBeenCalledTimes(1);
expect(manager.delete).toHaveBeenCalledTimes(2);
expect(manager.update).toHaveBeenCalledTimes(1);
it('archives an unpaid bill without deleting rows', async () => {
const { service, billRepo, dataSource, manager } = createService([{ id: 1, paidAmount: 0, status: 'unpaid' }]);
await expect(service.remove(1)).resolves.toEqual({ message: '账单已归档' });
expect(billRepo.save).not.toHaveBeenCalled();
expect(billRepo.createQueryBuilder).not.toHaveBeenCalled();
expect(billRepo.findOne).toHaveBeenCalledWith({ where: { id: 1 } });
expect((billRepo as any).update).toHaveBeenCalledWith(1, expect.objectContaining({ status: 'cancelled' }));
expect(dataSource.transaction).not.toHaveBeenCalled();
expect(manager.delete).not.toHaveBeenCalled();
});
});

View File

@@ -187,7 +187,7 @@ export class BillsController {
userId: req.user?.id,
username: req.user?.username,
module: '账单管理',
action: '删除账单',
action: '归档账单',
targetId: id,
targetType: 'bill',
ipAddress,
@@ -205,7 +205,7 @@ export class BillsController {
userId: req.user?.id,
username: req.user?.username,
module: '账单管理',
action: '批量删除账单',
action: '批量归档账单',
detail: `IDs: ${body.ids.join(',')}`,
ipAddress,
userAgent,

View File

@@ -71,6 +71,7 @@ export class BillsService {
periodStart,
periodEnd,
})
.andWhere('e.status = :status', { status: 'active' })
.getMany();
// 按宿舍分组费用
@@ -177,6 +178,7 @@ export class BillsService {
periodStart,
periodEnd,
})
.andWhere('pe.status = :status', { status: 'active' })
.andWhere('pe.billId IS NULL')
.getMany();
@@ -392,31 +394,42 @@ export class BillsService {
async remove(id: number) {
const exists = await this.billRepo.findOne({ where: { id } });
if (!exists) throw new NotFoundException('账单不存在');
if (Number(exists.paidAmount) > 0 || exists.status === 'cancelled') {
throw new BadRequestException('已发生资金流水的账单不能删除,请使用取消账单');
if (exists.status === 'cancelled') throw new BadRequestException('账单已归档');
if (Number(exists.paidAmount) > 0) {
throw new BadRequestException('已发生资金流水的账单请使用取消账单并冲正');
}
await this.dataSource.transaction(async (manager) => {
await manager.delete(BillItem, { billId: id });
await manager.update(PersonalExpense, { billId: id }, { billId: null });
await manager.delete(Bill, id);
await this.billRepo.update(id, {
status: 'cancelled',
outstandingAmount: 0,
cancelReason: '归档未支付账单',
cancelledAt: new Date(),
});
return { message: '账单已删除' };
return { message: '账单已归档' };
}
async batchRemove(ids: number[]) {
const uniqueIds = [...new Set(ids || [])];
if (uniqueIds.length === 0) throw new BadRequestException('请选择要删除的账单');
if (uniqueIds.length === 0) throw new BadRequestException('请选择要归档的账单');
const bills = await this.billRepo.find({ where: { id: In(uniqueIds) } });
if (bills.length !== uniqueIds.length) throw new NotFoundException('部分账单不存在');
if (bills.some((bill) => Number(bill.paidAmount) > 0 || bill.status === 'cancelled')) {
throw new BadRequestException('选中账单包含资金流水,不能批量删除');
if (bills.some((bill) => Number(bill.paidAmount) > 0)) {
throw new BadRequestException('选中账单包含资金流水,请逐条取消并冲正');
}
await this.dataSource.transaction(async (manager) => {
await manager.delete(BillItem, { billId: In(uniqueIds) });
await manager.update(PersonalExpense, { billId: In(uniqueIds) }, { billId: null });
await manager.delete(Bill, uniqueIds);
});
return { message: `成功删除 ${uniqueIds.length} 条账单` };
const targetIds = bills.filter((bill) => bill.status !== 'cancelled').map((bill) => bill.id);
if (targetIds.length > 0) {
await this.billRepo
.createQueryBuilder()
.update()
.set({
status: 'cancelled',
outstandingAmount: 0,
cancelReason: '批量归档未支付账单',
cancelledAt: new Date(),
})
.where('id IN (:...ids)', { ids: targetIds })
.execute();
}
return { message: `成功归档 ${targetIds.length} 条账单`, archived: targetIds.length };
}
private assertStatusMatchesAmounts(bill: Bill, status: string) {