refactor(deposits): remove refund approval remnants
This commit is contained in:
@@ -28,13 +28,6 @@ const statusMap: Record<string, { text: string; color: string }> = {
|
|||||||
deducted: { text: '已全扣', color: 'red' },
|
deducted: { text: '已全扣', color: 'red' },
|
||||||
};
|
};
|
||||||
|
|
||||||
const refundStatusMap: Record<string, { text: string; color: string }> = {
|
|
||||||
pending: { text: '历史退款处理中', color: 'orange' },
|
|
||||||
head_teacher_approved: { text: '历史退款处理中', color: 'blue' },
|
|
||||||
finance_approved: { text: '已退款', color: 'green' },
|
|
||||||
refunded: { text: '已退款', color: 'green' },
|
|
||||||
};
|
|
||||||
|
|
||||||
const installmentStatusMap: Record<string, { text: string; color: string }> = {
|
const installmentStatusMap: Record<string, { text: string; color: string }> = {
|
||||||
pending: { text: '待缴', color: 'orange' },
|
pending: { text: '待缴', color: 'orange' },
|
||||||
paid: { text: '已缴', color: 'green' },
|
paid: { text: '已缴', color: 'green' },
|
||||||
@@ -187,12 +180,6 @@ const DepositsPage: React.FC = () => {
|
|||||||
dataIndex: 'status',
|
dataIndex: 'status',
|
||||||
render: (s: string) => <Tag color={statusMap[s]?.color}>{statusMap[s]?.text || s}</Tag>,
|
render: (s: string) => <Tag color={statusMap[s]?.color}>{statusMap[s]?.text || s}</Tag>,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
title: '退款状态',
|
|
||||||
dataIndex: 'refundStatus',
|
|
||||||
render: (s: string) =>
|
|
||||||
s ? <Tag color={refundStatusMap[s]?.color}>{refundStatusMap[s]?.text || s}</Tag> : '-',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
title: '退还金额',
|
title: '退还金额',
|
||||||
dataIndex: 'refundAmount',
|
dataIndex: 'refundAmount',
|
||||||
@@ -220,7 +207,7 @@ const DepositsPage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
详情
|
详情
|
||||||
</PermissionButton>
|
</PermissionButton>
|
||||||
{record.status === 'paid' && !record.refundStatus && (
|
{record.status === 'paid' && (
|
||||||
<>
|
<>
|
||||||
<PermissionButton
|
<PermissionButton
|
||||||
permission="deposit:refund"
|
permission="deposit:refund"
|
||||||
@@ -407,14 +394,6 @@ const DepositsPage: React.FC = () => {
|
|||||||
{statusMap[detailModal.status]?.text || detailModal.status}
|
{statusMap[detailModal.status]?.text || detailModal.status}
|
||||||
</Tag>
|
</Tag>
|
||||||
</p>
|
</p>
|
||||||
{detailModal.refundStatus && (
|
|
||||||
<p>
|
|
||||||
<strong>退款状态:</strong>{' '}
|
|
||||||
<Tag color={refundStatusMap[detailModal.refundStatus]?.color}>
|
|
||||||
{refundStatusMap[detailModal.refundStatus]?.text || detailModal.refundStatus}
|
|
||||||
</Tag>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{detailModal.notes && <p><strong>备注:</strong> {detailModal.notes}</p>}
|
{detailModal.notes && <p><strong>备注:</strong> {detailModal.notes}</p>}
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
|
|||||||
@@ -119,8 +119,7 @@ export class DashboardService {
|
|||||||
const pendingQb = this.depositRepo
|
const pendingQb = this.depositRepo
|
||||||
.createQueryBuilder('d')
|
.createQueryBuilder('d')
|
||||||
.select('SUM(d.amount)', 'total')
|
.select('SUM(d.amount)', 'total')
|
||||||
.where('d.status = :paid', { paid: 'paid' })
|
.where('d.status = :paid', { paid: 'paid' });
|
||||||
.andWhere('d.refundStatus IS NULL');
|
|
||||||
const pendingResult = await pendingQb.getRawOne();
|
const pendingResult = await pendingQb.getRawOne();
|
||||||
const pendingDeposits = parseFloat(pendingResult?.total || '0');
|
const pendingDeposits = parseFloat(pendingResult?.total || '0');
|
||||||
|
|
||||||
|
|||||||
@@ -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<typeof createRunner>) {
|
||||||
|
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<void>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,6 +15,7 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
|||||||
await this.normalizeClassDates();
|
await this.normalizeClassDates();
|
||||||
await this.protectAttendanceHistory();
|
await this.protectAttendanceHistory();
|
||||||
await this.removeUnusedClassroomColumns();
|
await this.removeUnusedClassroomColumns();
|
||||||
|
await this.cleanupDepositRefundColumns();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async removeUnusedClassroomColumns(): Promise<void> {
|
private async removeUnusedClassroomColumns(): Promise<void> {
|
||||||
@@ -36,6 +37,44 @@ export class DatabaseMigrationsService implements OnApplicationBootstrap {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async cleanupDepositRefundColumns(): Promise<void> {
|
||||||
|
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<void> {
|
private async ensureAiConfigTable(): Promise<void> {
|
||||||
const runner = this.dataSource.createQueryRunner();
|
const runner = this.dataSource.createQueryRunner();
|
||||||
await runner.connect();
|
await runner.connect();
|
||||||
|
|||||||
38
apps/server/src/deposits/deposits.refund.spec.ts
Normal file
38
apps/server/src/deposits/deposits.refund.spec.ts
Normal file
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -103,9 +103,8 @@ export class DepositsService {
|
|||||||
deposit.status =
|
deposit.status =
|
||||||
deduction > 0 ? (refundAmount > 0 ? 'partial_refund' : 'deducted') : 'refunded';
|
deduction > 0 ? (refundAmount > 0 ? 'partial_refund' : 'deducted') : 'refunded';
|
||||||
if (dto.notes) deposit.notes = dto.notes;
|
if (dto.notes) deposit.notes = dto.notes;
|
||||||
deposit.refundStatus = 'refunded';
|
deposit.refundedBy = userId ?? null;
|
||||||
deposit.refundApprovedBy = userId ?? null as unknown as number;
|
deposit.refundedAt = new Date();
|
||||||
deposit.refundApprovedAt = new Date();
|
|
||||||
|
|
||||||
return this.repo.save(deposit);
|
return this.repo.save(deposit);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,20 +46,11 @@ export class Deposit {
|
|||||||
@Column({ name: 'recorded_by', nullable: true })
|
@Column({ name: 'recorded_by', nullable: true })
|
||||||
recordedBy: number;
|
recordedBy: number;
|
||||||
|
|
||||||
@Column({ name: 'refund_status', length: 30, nullable: true })
|
@Column({ name: 'refunded_by', type: 'integer', nullable: true })
|
||||||
refundStatus: string; // pending | head_teacher_approved | finance_approved | refunded
|
refundedBy: number | null;
|
||||||
|
|
||||||
@Column({ name: 'refund_requested_at', type: 'datetime', nullable: true })
|
@Column({ name: 'refunded_at', type: 'datetime', nullable: true })
|
||||||
refundRequestedAt: Date;
|
refundedAt: Date | null;
|
||||||
|
|
||||||
@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;
|
|
||||||
|
|
||||||
@OneToMany(() => DepositInstallment, (i) => i.deposit, { cascade: true, eager: true })
|
@OneToMany(() => DepositInstallment, (i) => i.deposit, { cascade: true, eager: true })
|
||||||
installments: DepositInstallment[];
|
installments: DepositInstallment[];
|
||||||
|
|||||||
Reference in New Issue
Block a user