From 1f32d1285b06f6f39577914fed87908e2648bfb6 Mon Sep 17 00:00:00 2001 From: wangziqi Date: Mon, 13 Jul 2026 14:19:58 +0800 Subject: [PATCH] refactor(deposits): remove refund approval remnants --- apps/admin/src/pages/Deposits/index.tsx | 23 +--- .../server/src/dashboard/dashboard.service.ts | 3 +- ...database-migrations.deposit-refund.spec.ts | 101 ++++++++++++++++++ .../database/database-migrations.service.ts | 39 +++++++ .../src/deposits/deposits.refund.spec.ts | 38 +++++++ apps/server/src/deposits/deposits.service.ts | 5 +- apps/server/src/entities/deposit.entity.ts | 17 +-- 7 files changed, 186 insertions(+), 40 deletions(-) create mode 100644 apps/server/src/database/database-migrations.deposit-refund.spec.ts create mode 100644 apps/server/src/deposits/deposits.refund.spec.ts diff --git a/apps/admin/src/pages/Deposits/index.tsx b/apps/admin/src/pages/Deposits/index.tsx index 3ddc854..1d50ca3 100644 --- a/apps/admin/src/pages/Deposits/index.tsx +++ b/apps/admin/src/pages/Deposits/index.tsx @@ -28,13 +28,6 @@ const statusMap: Record = { deducted: { text: '已全扣', color: 'red' }, }; -const refundStatusMap: Record = { - pending: { text: '历史退款处理中', color: 'orange' }, - head_teacher_approved: { text: '历史退款处理中', color: 'blue' }, - finance_approved: { text: '已退款', color: 'green' }, - refunded: { text: '已退款', color: 'green' }, -}; - const installmentStatusMap: Record = { pending: { text: '待缴', color: 'orange' }, paid: { text: '已缴', color: 'green' }, @@ -187,12 +180,6 @@ const DepositsPage: React.FC = () => { dataIndex: 'status', render: (s: string) => {statusMap[s]?.text || s}, }, - { - title: '退款状态', - dataIndex: 'refundStatus', - render: (s: string) => - s ? {refundStatusMap[s]?.text || s} : '-', - }, { title: '退还金额', dataIndex: 'refundAmount', @@ -220,7 +207,7 @@ const DepositsPage: React.FC = () => { > 详情 - {record.status === 'paid' && !record.refundStatus && ( + {record.status === 'paid' && ( <> { {statusMap[detailModal.status]?.text || detailModal.status}

- {detailModal.refundStatus && ( -

- 退款状态:{' '} - - {refundStatusMap[detailModal.refundStatus]?.text || detailModal.refundStatus} - -

- )} {detailModal.notes &&

备注: {detailModal.notes}

} diff --git a/apps/server/src/dashboard/dashboard.service.ts b/apps/server/src/dashboard/dashboard.service.ts index 96871af..aa7a0b4 100644 --- a/apps/server/src/dashboard/dashboard.service.ts +++ b/apps/server/src/dashboard/dashboard.service.ts @@ -119,8 +119,7 @@ export class DashboardService { const pendingQb = this.depositRepo .createQueryBuilder('d') .select('SUM(d.amount)', 'total') - .where('d.status = :paid', { paid: 'paid' }) - .andWhere('d.refundStatus IS NULL'); + .where('d.status = :paid', { paid: 'paid' }); const pendingResult = await pendingQb.getRawOne(); const pendingDeposits = parseFloat(pendingResult?.total || '0'); diff --git a/apps/server/src/database/database-migrations.deposit-refund.spec.ts b/apps/server/src/database/database-migrations.deposit-refund.spec.ts new file mode 100644 index 0000000..1b1ac7c --- /dev/null +++ b/apps/server/src/database/database-migrations.deposit-refund.spec.ts @@ -0,0 +1,101 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { getDataSourceToken } from '@nestjs/typeorm'; +import { DatabaseMigrationsService } from './database-migrations.service'; + +function createRunner(columns: string[]) { + return { + connect: jest.fn(), + release: jest.fn(), + query: jest.fn().mockResolvedValue([]), + getTables: jest.fn().mockResolvedValue(columns.length ? [{ name: 'deposits' }] : []), + getTable: jest.fn().mockResolvedValue({ + name: 'deposits', + columns: columns.map((name) => ({ name })), + }), + renameColumn: jest.fn().mockResolvedValue(undefined), + dropColumn: jest.fn().mockResolvedValue(undefined), + }; +} + +async function createService(runner: ReturnType) { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + DatabaseMigrationsService, + { + provide: getDataSourceToken(), + useValue: { + options: { type: 'better-sqlite3' }, + createQueryRunner: jest.fn().mockReturnValue(runner), + }, + }, + ], + }).compile(); + return module.get(DatabaseMigrationsService) as DatabaseMigrationsService & { + cleanupDepositRefundColumns(): Promise; + }; +} + +describe('DatabaseMigrationsService — deposit refund cleanup', () => { + it('renames refund audit fields and drops approval-flow remnants', async () => { + const runner = createRunner([ + 'id', + 'refund_status', + 'refund_requested_at', + 'refund_approved_by', + 'refund_approved_at', + 'refund_rejected_reason', + ]); + const service = await createService(runner); + + await service.cleanupDepositRefundColumns(); + + expect(runner.renameColumn).toHaveBeenCalledWith( + 'deposits', + 'refund_approved_by', + 'refunded_by', + ); + expect(runner.renameColumn).toHaveBeenCalledWith( + 'deposits', + 'refund_approved_at', + 'refunded_at', + ); + expect(runner.dropColumn).toHaveBeenCalledWith('deposits', 'refund_status'); + expect(runner.dropColumn).toHaveBeenCalledWith('deposits', 'refund_requested_at'); + expect(runner.dropColumn).toHaveBeenCalledWith('deposits', 'refund_rejected_reason'); + expect(runner.release).toHaveBeenCalled(); + }); + + it('merges legacy audit values before dropping duplicate legacy columns', async () => { + const runner = createRunner([ + 'id', + 'refund_approved_by', + 'refund_approved_at', + 'refunded_by', + 'refunded_at', + ]); + const service = await createService(runner); + + await service.cleanupDepositRefundColumns(); + + expect(runner.query).toHaveBeenCalledWith( + 'UPDATE deposits SET refunded_by = COALESCE(refunded_by, refund_approved_by)', + ); + expect(runner.query).toHaveBeenCalledWith( + 'UPDATE deposits SET refunded_at = COALESCE(refunded_at, refund_approved_at)', + ); + expect(runner.dropColumn).toHaveBeenCalledWith('deposits', 'refund_approved_by'); + expect(runner.dropColumn).toHaveBeenCalledWith('deposits', 'refund_approved_at'); + expect(runner.renameColumn).not.toHaveBeenCalled(); + }); + + it('does nothing when the deposits table is absent', async () => { + const runner = createRunner([]); + const service = await createService(runner); + + await service.cleanupDepositRefundColumns(); + + expect(runner.renameColumn).not.toHaveBeenCalled(); + expect(runner.dropColumn).not.toHaveBeenCalled(); + expect(runner.release).toHaveBeenCalled(); + }); +}); diff --git a/apps/server/src/database/database-migrations.service.ts b/apps/server/src/database/database-migrations.service.ts index b40d799..0254339 100644 --- a/apps/server/src/database/database-migrations.service.ts +++ b/apps/server/src/database/database-migrations.service.ts @@ -15,6 +15,7 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap { await this.normalizeClassDates(); await this.protectAttendanceHistory(); await this.removeUnusedClassroomColumns(); + await this.cleanupDepositRefundColumns(); } private async removeUnusedClassroomColumns(): Promise { @@ -36,6 +37,44 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap { } } + private async cleanupDepositRefundColumns(): Promise { + const runner = this.dataSource.createQueryRunner(); + await runner.connect(); + try { + const tables = await runner.getTables(['deposits']); + if (tables.length === 0) return; + + const table = await runner.getTable('deposits'); + const columnNames = new Set(table?.columns.map((column) => column.name) ?? []); + for (const [legacyName, currentName] of [ + ['refund_approved_by', 'refunded_by'], + ['refund_approved_at', 'refunded_at'], + ] as const) { + if (!columnNames.has(legacyName)) continue; + + if (columnNames.has(currentName)) { + await runner.query( + `UPDATE deposits SET ${currentName} = COALESCE(${currentName}, ${legacyName})`, + ); + await runner.dropColumn('deposits', legacyName); + } else { + await runner.renameColumn('deposits', legacyName, currentName); + columnNames.add(currentName); + } + columnNames.delete(legacyName); + } + + for (const columnName of ['refund_status', 'refund_requested_at', 'refund_rejected_reason']) { + if (columnNames.has(columnName)) { + await runner.dropColumn('deposits', columnName); + columnNames.delete(columnName); + } + } + } finally { + await runner.release(); + } + } + private async ensureAiConfigTable(): Promise { const runner = this.dataSource.createQueryRunner(); await runner.connect(); diff --git a/apps/server/src/deposits/deposits.refund.spec.ts b/apps/server/src/deposits/deposits.refund.spec.ts new file mode 100644 index 0000000..8400678 --- /dev/null +++ b/apps/server/src/deposits/deposits.refund.spec.ts @@ -0,0 +1,38 @@ +import { DepositsService } from './deposits.service'; +import { Deposit } from '../entities/deposit.entity'; + +describe('DepositsService — direct refund', () => { + it('stores the refund result on the main status and renamed audit fields', async () => { + const deposit = { + id: 1, + amount: 500, + status: 'paid', + } as Deposit; + const repo = { + findOne: jest.fn().mockResolvedValue(deposit), + save: jest.fn().mockImplementation(async (value: Deposit) => value), + }; + const service = new DepositsService(repo as never, {} as never, {} as never); + + const result = await service.refund( + 1, + { + refundDate: '2026-07-13', + deductionAmount: 100, + deductionReason: '物品损坏', + }, + 42, + ); + + expect(result).toMatchObject({ + refundDate: '2026-07-13', + refundAmount: 400, + deductionAmount: 100, + deductionReason: '物品损坏', + status: 'partial_refund', + refundedBy: 42, + }); + expect(result.refundedAt).toBeInstanceOf(Date); + expect(repo.save).toHaveBeenCalledWith(deposit); + }); +}); diff --git a/apps/server/src/deposits/deposits.service.ts b/apps/server/src/deposits/deposits.service.ts index b1a8238..b6597f9 100644 --- a/apps/server/src/deposits/deposits.service.ts +++ b/apps/server/src/deposits/deposits.service.ts @@ -103,9 +103,8 @@ export class DepositsService { deposit.status = deduction > 0 ? (refundAmount > 0 ? 'partial_refund' : 'deducted') : 'refunded'; if (dto.notes) deposit.notes = dto.notes; - deposit.refundStatus = 'refunded'; - deposit.refundApprovedBy = userId ?? null as unknown as number; - deposit.refundApprovedAt = new Date(); + deposit.refundedBy = userId ?? null; + deposit.refundedAt = new Date(); return this.repo.save(deposit); } diff --git a/apps/server/src/entities/deposit.entity.ts b/apps/server/src/entities/deposit.entity.ts index 258db25..f657010 100644 --- a/apps/server/src/entities/deposit.entity.ts +++ b/apps/server/src/entities/deposit.entity.ts @@ -46,20 +46,11 @@ export class Deposit { @Column({ name: 'recorded_by', nullable: true }) recordedBy: number; - @Column({ name: 'refund_status', length: 30, nullable: true }) - refundStatus: string; // pending | head_teacher_approved | finance_approved | refunded + @Column({ name: 'refunded_by', type: 'integer', nullable: true }) + refundedBy: number | null; - @Column({ name: 'refund_requested_at', type: 'datetime', nullable: true }) - refundRequestedAt: Date; - - @Column({ name: 'refund_approved_by', type: 'integer', nullable: true }) - refundApprovedBy: number; - - @Column({ name: 'refund_approved_at', type: 'datetime', nullable: true }) - refundApprovedAt: Date; - - @Column({ name: 'refund_rejected_reason', length: 500, nullable: true }) - refundRejectedReason: string; + @Column({ name: 'refunded_at', type: 'datetime', nullable: true }) + refundedAt: Date | null; @OneToMany(() => DepositInstallment, (i) => i.deposit, { cascade: true, eager: true }) installments: DepositInstallment[];