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[];